fix: security hardening — block self-registration, SSE auth, audit logging fixes
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Security fixes: - Block self-registration via magic link (PrismaAdapter createUser throws) - Magic links only sent to existing ACTIVE users (prevents enumeration) - signIn callback rejects non-existent users (defense-in-depth) - Change schema default role from JURY_MEMBER to APPLICANT - Add authentication to live-voting SSE stream endpoint - Fix false FILE_OPENED/FILE_DOWNLOADED audit events on page load (remove purpose from eagerly pre-fetched URL queries) Bug fixes: - Fix impersonation skeleton screen on applicant dashboard - Fix onboarding redirect loop in auth layout Observer dashboard redesign (Steps 1-6): - Clickable round pipeline with selected round highlighting - Round-type-specific dashboard panels (intake, filtering, evaluation, submission, mentoring, live final, deliberation) - Enhanced activity feed with server-side humanization - Previous round comparison section - New backend queries for round-specific analytics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
148
src/components/observer/dashboard/previous-round-section.tsx
Normal file
148
src/components/observer/dashboard/previous-round-section.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { ArrowDown, ChevronDown, ChevronUp, TrendingDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function PreviousRoundSection({ currentRoundId }: { currentRoundId: string }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
const { data, isLoading } = trpc.analytics.getPreviousRoundComparison.useQuery(
|
||||
{ currentRoundId },
|
||||
{ refetchInterval: 60_000 },
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-40 w-full rounded-lg" />
|
||||
}
|
||||
|
||||
if (!data || !data.hasPrevious) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { previousRound, currentRound, eliminated, categoryBreakdown, countryAttrition } = data
|
||||
|
||||
return (
|
||||
<AnimatedCard index={5}>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||
<TrendingDown className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
Compared to Previous Round: {previousRound.name}
|
||||
</CardTitle>
|
||||
{collapsed
|
||||
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
: <ChevronUp className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
</CardHeader>
|
||||
|
||||
{!collapsed && (
|
||||
<CardContent className="space-y-4">
|
||||
{/* Headline Stat */}
|
||||
<div className="flex items-center gap-3 rounded-lg bg-rose-50 dark:bg-rose-950/20 p-4">
|
||||
<ArrowDown className="h-6 w-6 text-rose-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-lg font-semibold">
|
||||
{eliminated} project{eliminated !== 1 ? 's' : ''} eliminated
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{previousRound.projectCount} → {currentRound.projectCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Survival Bars */}
|
||||
{categoryBreakdown && categoryBreakdown.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
By Category
|
||||
</p>
|
||||
{categoryBreakdown.map((cat: any) => {
|
||||
const maxVal = Math.max(cat.previous, 1)
|
||||
const prevPct = 100
|
||||
const currPct = (cat.current / maxVal) * 100
|
||||
return (
|
||||
<div key={cat.category} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium truncate">{cat.category}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{cat.previous} → {cat.current}
|
||||
<span className="text-rose-500 ml-1">(-{cat.eliminated})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-slate-300 dark:bg-slate-600 transition-all"
|
||||
style={{ width: `${prevPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-brand-teal transition-all"
|
||||
style={{ width: `${currPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Country Attrition */}
|
||||
{countryAttrition && countryAttrition.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Country Attrition (Top 10)
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
{countryAttrition.map((c: any) => (
|
||||
<div key={c.country} className="flex items-center justify-between text-sm py-0.5">
|
||||
<span className="truncate">{c.country}</span>
|
||||
<Badge variant="destructive" className="tabular-nums text-xs">
|
||||
-{c.lost}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Score Comparison */}
|
||||
{previousRound.avgScore != null && currentRound.avgScore != null && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card className="p-3 text-center border-muted">
|
||||
<p className="text-xs text-muted-foreground mb-1">{previousRound.name}</p>
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{typeof previousRound.avgScore === 'number'
|
||||
? previousRound.avgScore.toFixed(1)
|
||||
: previousRound.avgScore}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">Avg Score</p>
|
||||
</Card>
|
||||
<Card className="p-3 text-center border-brand-teal/30">
|
||||
<p className="text-xs text-muted-foreground mb-1">{currentRound.name}</p>
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{typeof currentRound.avgScore === 'number'
|
||||
? currentRound.avgScore.toFixed(1)
|
||||
: currentRound.avgScore}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">Avg Score</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user