MOPC-App/src/app/(admin)/admin/rounds/[id]/page.tsx

1477 lines
60 KiB
TypeScript
Raw Normal View History

'use client'
import { Suspense, use, useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
ArrowLeft,
Edit,
Users,
FileText,
CheckCircle2,
Clock,
AlertCircle,
Archive,
Play,
Pause,
BarChart3,
Upload,
Filter,
Trash2,
Loader2,
Plus,
ArrowRightCircle,
Minus,
XCircle,
AlertTriangle,
ListChecks,
ClipboardCheck,
Sparkles,
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
LayoutTemplate,
ShieldCheck,
Download,
RotateCcw,
} from 'lucide-react'
import { toast } from 'sonner'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
import { Pagination } from '@/components/shared/pagination'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { format, formatDistanceToNow, isFuture } from 'date-fns'
const OUTCOME_BADGES: Record<
string,
{ variant: 'default' | 'destructive' | 'secondary' | 'outline'; icon: React.ReactNode; label: string }
> = {
PASSED: {
variant: 'default',
icon: <CheckCircle2 className="mr-1 h-3 w-3" />,
label: 'Passed',
},
FILTERED_OUT: {
variant: 'destructive',
icon: <XCircle className="mr-1 h-3 w-3" />,
label: 'Filtered Out',
},
FLAGGED: {
variant: 'secondary',
icon: <AlertTriangle className="mr-1 h-3 w-3" />,
label: 'Flagged',
},
}
interface PageProps {
params: Promise<{ id: string }>
}
function RoundDetailContent({ roundId }: { roundId: string }) {
const router = useRouter()
const [assignOpen, setAssignOpen] = useState(false)
const [advanceOpen, setAdvanceOpen] = useState(false)
const [removeOpen, setRemoveOpen] = useState(false)
const [activeJobId, setActiveJobId] = useState<string | null>(null)
// Inline filtering results state
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
const [resultsPage, setResultsPage] = useState(1)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [overrideDialog, setOverrideDialog] = useState<{
id: string
currentOutcome: string
} | null>(null)
const [overrideOutcome, setOverrideOutcome] = useState<string>('PASSED')
const [overrideReason, setOverrideReason] = useState('')
const [showExportDialog, setShowExportDialog] = useState(false)
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
// Check if this is a filtering round - roundType is stored directly on the round
const isFilteringRound = round?.roundType === 'FILTERING'
// Filtering queries (only fetch for FILTERING rounds)
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
trpc.filtering.getResultStats.useQuery(
{ roundId },
{ enabled: isFilteringRound, staleTime: 0 }
)
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
{ roundId },
{ enabled: isFilteringRound }
)
const { data: aiStatus } = trpc.filtering.checkAIStatus.useQuery(
{ roundId },
{ enabled: isFilteringRound }
)
const { data: latestJob, refetch: refetchLatestJob } =
trpc.filtering.getLatestJob.useQuery(
{ roundId },
{ enabled: isFilteringRound, staleTime: 0 }
)
// Poll for job status when there's an active job
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
{ jobId: activeJobId! },
{
enabled: !!activeJobId,
refetchInterval: activeJobId ? 2000 : false,
staleTime: 0,
}
)
const utils = trpc.useUtils()
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
utils.round.list.invalidate()
utils.program.list.invalidate({ includeRounds: true })
},
})
const deleteRound = trpc.round.delete.useMutation({
onSuccess: () => {
toast.success('Round deleted')
utils.program.list.invalidate()
utils.round.list.invalidate()
router.push('/admin/rounds')
},
onError: () => {
toast.error('Failed to delete round')
},
})
// Filtering mutations
const startJob = trpc.filtering.startJob.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
utils.project.list.invalidate()
},
})
// Inline filtering results
const resultsPerPage = 20
const { data: filteringResults, refetch: refetchResults } =
trpc.filtering.getResults.useQuery(
{
roundId,
outcome: outcomeFilter
? (outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED')
: undefined,
page: resultsPage,
perPage: resultsPerPage,
},
{
enabled: isFilteringRound && (filteringStats?.total ?? 0) > 0,
staleTime: 0,
}
)
const overrideResult = trpc.filtering.overrideResult.useMutation()
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
const exportResults = trpc.export.filteringResults.useQuery(
{ roundId },
{ enabled: false }
)
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
// Save as template
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
onSuccess: (data) => {
toast.success('Saved as template', {
action: {
label: 'View',
onClick: () => router.push(`/admin/round-templates/${data.id}`),
},
})
},
onError: (err) => {
toast.error(err.message)
},
})
// AI summary bulk generation
const bulkSummaries = trpc.evaluation.generateBulkSummaries.useMutation({
onSuccess: (data) => {
if (data.errors.length > 0) {
toast.warning(
`Generated ${data.generated} of ${data.total} summaries. ${data.errors.length} failed.`
)
} else {
toast.success(`Generated ${data.generated} AI summaries successfully`)
}
},
onError: (error) => {
toast.error(error.message || 'Failed to generate AI summaries')
},
})
// Set active job from latest job on load
useEffect(() => {
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
setActiveJobId(latestJob.id)
}
}, [latestJob])
// Handle job completion
useEffect(() => {
if (jobStatus?.status === 'COMPLETED') {
toast.success(
`Filtering complete: ${jobStatus.passedCount} passed, ${jobStatus.filteredCount} filtered out, ${jobStatus.flaggedCount} flagged`
)
setActiveJobId(null)
refetchFilteringStats()
refetchResults()
refetchLatestJob()
} else if (jobStatus?.status === 'FAILED') {
toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
setActiveJobId(null)
refetchLatestJob()
}
}, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchResults, refetchLatestJob])
const handleStartFiltering = async () => {
try {
const result = await startJob.mutateAsync({ roundId })
setActiveJobId(result.jobId)
toast.info('Filtering job started. Progress will update automatically.')
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to start filtering'
)
}
}
const handleFinalizeFiltering = async () => {
try {
const result = await finalizeResults.mutateAsync({ roundId })
if (result.advancedToRoundName) {
toast.success(
`Finalized: ${result.passed} projects advanced to "${result.advancedToRoundName}", ${result.filteredOut} filtered out`
)
} else {
toast.success(
`Finalized: ${result.passed} passed, ${result.filteredOut} filtered out. No next round to advance to.`
)
}
refetchFilteringStats()
refetchRound()
utils.project.list.invalidate()
utils.program.list.invalidate({ includeRounds: true })
utils.round.get.invalidate({ id: roundId })
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to finalize'
)
}
}
// Inline results handlers
const toggleResultRow = (id: string) => {
const next = new Set(expandedRows)
if (next.has(id)) next.delete(id)
else next.add(id)
setExpandedRows(next)
}
const handleOverride = async () => {
if (!overrideDialog || !overrideReason.trim()) return
try {
await overrideResult.mutateAsync({
id: overrideDialog.id,
finalOutcome: overrideOutcome as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
reason: overrideReason.trim(),
})
toast.success('Result overridden')
setOverrideDialog(null)
setOverrideReason('')
refetchResults()
refetchFilteringStats()
utils.project.list.invalidate()
} catch {
toast.error('Failed to override result')
}
}
const handleReinstate = async (projectId: string) => {
try {
await reinstateProject.mutateAsync({ roundId, projectId })
toast.success('Project reinstated')
refetchResults()
refetchFilteringStats()
utils.project.list.invalidate()
} catch {
toast.error('Failed to reinstate project')
}
}
const handleRequestExportData = useCallback(async () => {
const result = await exportResults.refetch()
return result.data ?? undefined
}, [exportResults])
const isJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
const progressPercent = jobStatus?.totalBatches
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
: 0
if (isLoading) {
return <RoundDetailSkeleton />
}
if (!round) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
)
}
const now = new Date()
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
const getStatusBadge = () => {
if (round.status === 'ACTIVE' && isVotingOpen) {
return (
<Badge variant="default" className="bg-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voting Open
</Badge>
)
}
switch (round.status) {
case 'DRAFT':
return <Badge variant="secondary">Draft</Badge>
case 'ACTIVE':
return (
<Badge variant="default">
<Clock className="mr-1 h-3 w-3" />
Active
</Badge>
)
case 'CLOSED':
return <Badge variant="outline">Closed</Badge>
case 'ARCHIVED':
return (
<Badge variant="outline">
<Archive className="mr-1 h-3 w-3" />
Archived
</Badge>
)
default:
return <Badge variant="secondary">{round.status}</Badge>
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</Link>
</Button>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
{round.program.year} Edition
</Link>
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{round.name}</h1>
{getStatusBadge()}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
{round.status === 'DRAFT' && (
<Button
onClick={() => updateStatus.mutate({ id: round.id, status: 'ACTIVE' })}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Activate
</Button>
)}
{round.status === 'ACTIVE' && (
<Button
variant="secondary"
onClick={() => updateStatus.mutate({ id: round.id, status: 'CLOSED' })}
disabled={updateStatus.isPending}
>
<Pause className="mr-2 h-4 w-4" />
Close Round
</Button>
)}
{round.status === 'CLOSED' && (
<Button
onClick={() => updateStatus.mutate({ id: round.id, status: 'ACTIVE' })}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Reopen Round
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{round.status === 'ACTIVE' && (
<AlertTriangle className="h-5 w-5 text-destructive" />
)}
Delete Round
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
{round.status === 'ACTIVE' && (
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm font-medium">
Warning: This round is currently ACTIVE. Deleting it will immediately end all ongoing evaluations.
</div>
)}
<p>
This will permanently delete &ldquo;{round.name}&rdquo; and all
associated data:
</p>
<ul className="list-disc list-inside text-sm space-y-1">
<li>{progress?.totalProjects || 0} projects in this round</li>
<li>{progress?.totalAssignments || 0} jury assignments</li>
<li>{progress?.completedAssignments || 0} submitted evaluations</li>
</ul>
<p className="font-medium">This action cannot be undone.</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteRound.mutate({ id: round.id })}
disabled={deleteRound.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteRound.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
'Delete Round'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<Separator />
{/* Stats Grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.projects}</div>
<Button variant="link" size="sm" className="px-0" asChild>
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Judge Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.assignments}</div>
<Button variant="link" size="sm" className="px-0" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
Manage judge assignments
</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round.requiredReviews}</div>
<p className="text-xs text-muted-foreground">per project</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completion</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{progress?.completionPercentage || 0}%
</div>
<p className="text-xs text-muted-foreground">
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0}
</p>
</CardContent>
</Card>
</div>
{/* Progress */}
{progress && progress.totalAssignments > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Progress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="flex items-center justify-between text-sm mb-2">
<span>Overall Completion</span>
<span>{progress.completionPercentage}%</span>
</div>
<Progress value={progress.completionPercentage} />
</div>
<div className="grid gap-4 sm:grid-cols-4">
{Object.entries(progress.evaluationsByStatus).map(([status, count]) => (
<div key={status} className="text-center p-3 rounded-lg bg-muted">
<p className="text-2xl font-bold">{count}</p>
<p className="text-xs text-muted-foreground capitalize">
{status.toLowerCase().replace('_', ' ')}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Voting Window */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground mb-1">Start Date</p>
{round.votingStartAt ? (
<div>
<p className="font-medium">
{format(new Date(round.votingStartAt), 'PPP')}
</p>
<p className="text-sm text-muted-foreground">
{format(new Date(round.votingStartAt), 'p')}
</p>
</div>
) : (
<p className="text-muted-foreground italic">Not set</p>
)}
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">End Date</p>
{round.votingEndAt ? (
<div>
<p className="font-medium">
{format(new Date(round.votingEndAt), 'PPP')}
</p>
<p className="text-sm text-muted-foreground">
{format(new Date(round.votingEndAt), 'p')}
</p>
{isFuture(new Date(round.votingEndAt)) && (
<p className="text-sm text-amber-600 mt-1">
Ends {formatDistanceToNow(new Date(round.votingEndAt), { addSuffix: true })}
</p>
)}
</div>
) : (
<p className="text-muted-foreground italic">Not set</p>
)}
</div>
</div>
{/* Voting status */}
{round.votingStartAt && round.votingEndAt && (
<div
className={`p-4 rounded-lg ${
isVotingOpen
? 'bg-green-500/10 text-green-700'
: isFuture(new Date(round.votingStartAt))
? 'bg-amber-500/10 text-amber-700'
: isFuture(new Date(round.votingEndAt))
? 'bg-blue-500/10 text-blue-700'
: 'bg-muted text-muted-foreground'
}`}
>
{isVotingOpen ? (
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5" />
<span className="font-medium">Voting is currently open</span>
</div>
) : isFuture(new Date(round.votingStartAt)) ? (
<div className="flex items-center gap-2">
<Clock className="h-5 w-5" />
<span className="font-medium">
Voting opens {formatDistanceToNow(new Date(round.votingStartAt), { addSuffix: true })}
</span>
</div>
) : isFuture(new Date(round.votingEndAt)) ? (
<div className="flex items-center gap-2">
<Clock className="h-5 w-5" />
<span className="font-medium">
{round.status === 'DRAFT'
? 'Voting window configured (round not yet active)'
: `Voting ends ${formatDistanceToNow(new Date(round.votingEndAt), { addSuffix: true })}`
}
</span>
</div>
) : (
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
<span className="font-medium">Voting period has ended</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Filtering Section (for FILTERING rounds) */}
{isFilteringRound && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Filter className="h-5 w-5" />
Project Filtering
</CardTitle>
<CardDescription>
Run automated screening rules on projects in this round
</CardDescription>
</div>
<div className="flex gap-2">
<Button
onClick={handleStartFiltering}
disabled={startJob.isPending || isJobRunning || !filteringRules || filteringRules.length === 0}
>
{startJob.isPending || isJobRunning ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
{isJobRunning ? 'Running...' : 'Run Filtering'}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Progress Card (when job is running) */}
{isJobRunning && jobStatus && (
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
<div className="flex-1">
<p className="font-medium text-blue-900 dark:text-blue-100">
AI Filtering in Progress
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches
</p>
</div>
<Badge variant="outline" className="border-blue-300 text-blue-700">
<Clock className="mr-1 h-3 w-3" />
Batch {jobStatus.currentBatch} of {jobStatus.totalBatches}
</Badge>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-blue-700 dark:text-blue-300">
{jobStatus.processedCount} of {jobStatus.totalProjects} projects processed
</span>
<span className="font-medium text-blue-900 dark:text-blue-100">
{progressPercent}%
</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
</div>
</div>
)}
{/* AI Status Warning */}
{aiStatus?.hasAIRules && !aiStatus?.configured && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="h-5 w-5 text-amber-600 flex-shrink-0" />
<div className="flex-1">
<p className="font-medium text-amber-700">AI Configuration Required</p>
<p className="text-sm text-amber-600">
{aiStatus.error || 'AI screening rules require OpenAI to be configured.'}
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/settings">Configure AI</Link>
</Button>
</div>
)}
{/* Stats */}
{isLoadingFilteringStats && !isJobRunning ? (
<div className="grid gap-4 sm:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-7 w-12" />
<Skeleton className="h-4 w-16" />
</div>
</div>
))}
</div>
) : filteringStats && filteringStats.total > 0 ? (
<div className="grid gap-4 sm:grid-cols-4">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
<Filter className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{filteringStats.total}</p>
<p className="text-sm text-muted-foreground">Total</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-500/10">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/20">
<CheckCircle2 className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-2xl font-bold text-green-600">
{filteringStats.passed}
</p>
<p className="text-sm text-muted-foreground">Passed</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-500/10">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-500/20">
<XCircle className="h-5 w-5 text-red-600" />
</div>
<div>
<p className="text-2xl font-bold text-red-600">
{filteringStats.filteredOut}
</p>
<p className="text-sm text-muted-foreground">Filtered Out</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-500/10">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/20">
<AlertTriangle className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="text-2xl font-bold text-amber-600">
{filteringStats.flagged}
</p>
<p className="text-sm text-muted-foreground">Flagged</p>
</div>
</div>
</div>
) : !isJobRunning && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Filter className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No filtering results yet</p>
<p className="text-sm text-muted-foreground">
Configure rules and run filtering to screen projects
</p>
</div>
)}
{/* Inline Filtering Results Table */}
{filteringStats && filteringStats.total > 0 && (
<>
<Separator />
<div className="space-y-4">
{/* Outcome Filter Tabs */}
<div className="flex items-center justify-between">
<div className="flex gap-2">
{['', 'PASSED', 'FILTERED_OUT', 'FLAGGED'].map((outcome) => (
<Button
key={outcome || 'all'}
variant={outcomeFilter === outcome ? 'default' : 'outline'}
size="sm"
onClick={() => {
setOutcomeFilter(outcome)
setResultsPage(1)
}}
>
{outcome ? (
<>
{OUTCOME_BADGES[outcome].icon}
{OUTCOME_BADGES[outcome].label}
</>
) : (
'All'
)}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowExportDialog(true)}
disabled={exportResults.isFetching}
>
{exportResults.isFetching ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Export CSV
</Button>
</div>
{/* Results Table */}
{filteringResults && filteringResults.results.length > 0 ? (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Outcome</TableHead>
<TableHead className="w-[300px]">AI Reason</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteringResults.results.map((result) => {
const isExpanded = expandedRows.has(result.id)
const effectiveOutcome =
result.finalOutcome || result.outcome
const badge = OUTCOME_BADGES[effectiveOutcome]
const aiScreening = result.aiScreeningJson as Record<string, {
meetsCriteria?: boolean
confidence?: number
reasoning?: string
qualityScore?: number
spamRisk?: boolean
}> | null
const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null
const aiReasoning = firstAiResult?.reasoning
return (
<>
<TableRow
key={result.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => toggleResultRow(result.id)}
>
<TableCell>
<div>
<p className="font-medium">
{result.project.title}
</p>
<p className="text-sm text-muted-foreground">
{result.project.teamName}
{result.project.country && ` · ${result.project.country}`}
</p>
</div>
</TableCell>
<TableCell>
{result.project.competitionCategory ? (
<Badge variant="outline">
{result.project.competitionCategory.replace(
'_',
' '
)}
</Badge>
) : (
'-'
)}
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant={badge?.variant || 'secondary'}>
{badge?.icon}
{badge?.label || effectiveOutcome}
</Badge>
{result.overriddenByUser && (
<p className="text-xs text-muted-foreground">
Overridden by {result.overriddenByUser.name || result.overriddenByUser.email}
</p>
)}
</div>
</TableCell>
<TableCell>
{aiReasoning ? (
<div className="space-y-1">
<p className="text-sm line-clamp-2">
{aiReasoning}
</p>
{firstAiResult && (
<div className="flex gap-2 text-xs text-muted-foreground">
{firstAiResult.confidence !== undefined && (
<span>Confidence: {Math.round(firstAiResult.confidence * 100)}%</span>
)}
{firstAiResult.qualityScore !== undefined && (
<span>Quality: {firstAiResult.qualityScore}/10</span>
)}
{firstAiResult.spamRisk && (
<Badge variant="destructive" className="text-xs">Spam Risk</Badge>
)}
</div>
)}
</div>
) : (
<span className="text-sm text-muted-foreground italic">
No AI screening
</span>
)}
</TableCell>
<TableCell className="text-right">
<div
className="flex justify-end gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
onClick={() => {
setOverrideOutcome('PASSED')
setOverrideDialog({
id: result.id,
currentOutcome: effectiveOutcome,
})
}}
>
<ShieldCheck className="mr-1 h-3 w-3" />
Override
</Button>
{effectiveOutcome === 'FILTERED_OUT' && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleReinstate(result.projectId)
}
disabled={reinstateProject.isPending}
>
<RotateCcw className="mr-1 h-3 w-3" />
Reinstate
</Button>
)}
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow key={`${result.id}-detail`}>
<TableCell colSpan={5} className="bg-muted/30">
<div className="p-4 space-y-4">
{/* Rule Results */}
<div>
<p className="text-sm font-medium mb-2">
Rule Results
</p>
{result.ruleResultsJson &&
Array.isArray(result.ruleResultsJson) ? (
<div className="space-y-2">
{(
result.ruleResultsJson as Array<{
ruleName: string
ruleType: string
passed: boolean
action: string
reasoning?: string
}>
).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (
<div
key={i}
className="flex items-start gap-2 text-sm"
>
{rr.passed ? (
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
) : (
<XCircle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
)}
<div>
<div className="flex items-center gap-2">
<span className="font-medium">
{rr.ruleName}
</span>
<Badge variant="outline" className="text-xs">
{rr.ruleType.replace('_', ' ')}
</Badge>
</div>
{rr.reasoning && (
<p className="text-muted-foreground mt-0.5">
{rr.reasoning}
</p>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No detailed rule results available
</p>
)}
</div>
{/* AI Screening Details */}
{aiScreening && Object.keys(aiScreening).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">
AI Screening Analysis
</p>
<div className="space-y-3">
{Object.entries(aiScreening).map(([ruleId, screening]) => (
<div key={ruleId} className="p-3 bg-background rounded-lg border">
<div className="flex items-center gap-2 mb-2">
{screening.meetsCriteria ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<span className="font-medium text-sm">
{screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'}
</span>
{screening.spamRisk && (
<Badge variant="destructive" className="text-xs">
<AlertTriangle className="h-3 w-3 mr-1" />
Spam Risk
</Badge>
)}
</div>
{screening.reasoning && (
<p className="text-sm text-muted-foreground mb-2">
{screening.reasoning}
</p>
)}
<div className="flex gap-4 text-xs text-muted-foreground">
{screening.confidence !== undefined && (
<span>
Confidence: <strong>{Math.round(screening.confidence * 100)}%</strong>
</span>
)}
{screening.qualityScore !== undefined && (
<span>
Quality Score: <strong>{screening.qualityScore}/10</strong>
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Override Info */}
{result.overriddenByUser && (
<div className="pt-3 border-t">
<p className="text-sm font-medium mb-1">Manual Override</p>
<p className="text-sm text-muted-foreground">
Overridden to <strong>{result.finalOutcome}</strong> by{' '}
{result.overriddenByUser.name || result.overriddenByUser.email}
</p>
{result.overrideReason && (
<p className="text-sm text-muted-foreground mt-1">
Reason: {result.overrideReason}
</p>
)}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
})}
</TableBody>
</Table>
</div>
<Pagination
page={filteringResults.page}
totalPages={filteringResults.totalPages}
total={filteringResults.total}
perPage={resultsPerPage}
onPageChange={setResultsPage}
/>
</>
) : filteringResults ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CheckCircle2 className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-sm font-medium">No results match this filter</p>
</div>
) : null}
</div>
</>
)}
{/* Quick links */}
<div className="flex flex-wrap gap-3 pt-2 border-t">
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/filtering/rules`}>
<ListChecks className="mr-2 h-4 w-4" />
Configure Rules
<Badge variant="secondary" className="ml-2">
{filteringRules?.length || 0}
</Badge>
</Link>
</Button>
{filteringStats && filteringStats.total > 0 && (
<Button
onClick={handleFinalizeFiltering}
disabled={finalizeResults.isPending || isJobRunning}
variant="default"
>
{finalizeResults.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
Finalize Results
</Button>
)}
</div>
</CardContent>
</Card>
)}
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Project Management */}
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Project Management</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/projects/import?round=${round.id}`}>
<Upload className="mr-2 h-4 w-4" />
Import
</Link>
</Button>
<Button variant="outline" size="sm" onClick={() => setAssignOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Assign
</Button>
<Button variant="outline" size="sm" onClick={() => setAdvanceOpen(true)}>
<ArrowRightCircle className="mr-2 h-4 w-4" />
Advance
</Button>
<Button variant="outline" size="sm" onClick={() => setRemoveOpen(true)}>
<Minus className="mr-2 h-4 w-4" />
Remove
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/projects?round=${round.id}`}>
<FileText className="mr-2 h-4 w-4" />
View All
</Link>
</Button>
</div>
</div>
{/* Round Management */}
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Round Management</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Jury Assignments
</Link>
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
disabled={bulkSummaries.isPending}
>
{bulkSummaries.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
<Button
variant="outline"
size="sm"
onClick={() =>
saveAsTemplate.mutate({
roundId: round.id,
name: `${round.name} Template`,
})
}
disabled={saveAsTemplate.isPending}
>
{saveAsTemplate.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LayoutTemplate className="mr-2 h-4 w-4" />
)}
Save as Template
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Dialogs */}
<AssignProjectsDialog
roundId={roundId}
programId={round.program.id}
open={assignOpen}
onOpenChange={setAssignOpen}
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
/>
<AdvanceProjectsDialog
roundId={roundId}
programId={round.program.id}
open={advanceOpen}
onOpenChange={setAdvanceOpen}
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
/>
<RemoveProjectsDialog
roundId={roundId}
open={removeOpen}
onOpenChange={setRemoveOpen}
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
/>
{/* Override Dialog */}
<Dialog
open={!!overrideDialog}
onOpenChange={(open) => {
if (!open) {
setOverrideDialog(null)
setOverrideReason('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Override Filtering Result</DialogTitle>
<DialogDescription>
Change the outcome for this project. This will be logged in the
audit trail.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>New Outcome</Label>
<Select
value={overrideOutcome}
onValueChange={setOverrideOutcome}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PASSED">Passed</SelectItem>
<SelectItem value="FILTERED_OUT">Filtered Out</SelectItem>
<SelectItem value="FLAGGED">Flagged</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason</Label>
<Input
value={overrideReason}
onChange={(e) => setOverrideReason(e.target.value)}
placeholder="Explain why you're overriding..."
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOverrideDialog(null)}
>
Cancel
</Button>
<Button
onClick={handleOverride}
disabled={
overrideResult.isPending || !overrideReason.trim()
}
>
{overrideResult.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Override
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* CSV Export Dialog */}
<CsvExportDialog
open={showExportDialog}
onOpenChange={setShowExportDialog}
exportData={exportResults.data ?? undefined}
isLoading={exportResults.isFetching}
filename="filtering-results"
onRequestData={handleRequestExportData}
/>
</div>
)
}
function RoundDetailSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="flex items-start justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-64" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-28" />
</div>
</div>
<Skeleton className="h-px w-full" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-1 h-4 w-20" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function RoundDetailPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<RoundDetailSkeleton />}>
<RoundDetailContent roundId={id} />
</Suspense>
)
}