Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal

Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)

Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)

Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)

Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)

Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 21:58:27 +01:00
parent 002a9dbfc3
commit 699248e40b
38 changed files with 5437 additions and 533 deletions

View File

@@ -27,6 +27,7 @@ import { FileViewer } from '@/components/shared/file-viewer'
import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { UserAvatar } from '@/components/shared/user-avatar'
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
import {
ArrowLeft,
Edit,
@@ -635,6 +636,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardContent>
</Card>
)}
{/* AI Evaluation Summary */}
{project.roundId && stats && stats.totalEvaluations > 0 && (
<EvaluationSummaryCard
projectId={projectId}
roundId={project.roundId}
/>
)}
</div>
)
}

View File

@@ -67,6 +67,8 @@ import {
AlertCircle,
Layers,
FolderOpen,
X,
AlertTriangle,
} from 'lucide-react'
import {
Select,
@@ -75,6 +77,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
@@ -346,6 +349,77 @@ export default function ProjectsPage() {
? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100)
: 0
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
onSuccess: (result) => {
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
setSelectedIds(new Set())
setBulkStatus('')
setBulkConfirmOpen(false)
utils.project.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to update projects')
},
})
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleSelectAll = () => {
if (!data) return
const allVisible = data.projects.map((p) => p.id)
const allSelected = allVisible.every((id) => selectedIds.has(id))
if (allSelected) {
setSelectedIds((prev) => {
const next = new Set(prev)
allVisible.forEach((id) => next.delete(id))
return next
})
} else {
setSelectedIds((prev) => {
const next = new Set(prev)
allVisible.forEach((id) => next.add(id))
return next
})
}
}
const handleBulkApply = () => {
if (!bulkStatus || selectedIds.size === 0) return
setBulkConfirmOpen(true)
}
const handleBulkConfirm = () => {
if (!bulkStatus || selectedIds.size === 0 || !filters.roundId) return
bulkUpdateStatus.mutate({
ids: Array.from(selectedIds),
roundId: filters.roundId,
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
})
}
// Determine if all visible projects are selected
const allVisibleSelected = data
? data.projects.length > 0 && data.projects.every((p) => selectedIds.has(p.id))
: false
const someVisibleSelected = data
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
: false
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
toast.success('Project deleted successfully')
@@ -468,6 +542,15 @@ export default function ProjectsPage() {
<Table>
<TableHeader>
<TableRow>
{filters.roundId && (
<TableHead className="w-10">
<Checkbox
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
aria-label="Select all projects"
/>
</TableHead>
)}
<TableHead>Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Files</TableHead>
@@ -484,6 +567,16 @@ export default function ProjectsPage() {
key={project.id}
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
>
{filters.roundId && (
<TableCell className="relative z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
)}
<TableCell>
<Link
href={`/admin/projects/${project.id}`}
@@ -577,14 +670,23 @@ export default function ProjectsPage() {
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{data.projects.map((project) => (
<div key={project.id} className="relative">
{filters.roundId && (
<div className="absolute left-3 top-4 z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
/>
</div>
)}
<Link
key={project.id}
href={`/admin/projects/${project.id}`}
className="block"
>
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<div className={`flex items-start gap-3 ${filters.roundId ? 'pl-8' : ''}`}>
<ProjectLogo
project={project}
size="md"
@@ -627,6 +729,7 @@ export default function ProjectsPage() {
</CardContent>
</Card>
</Link>
</div>
))}
</div>
@@ -641,6 +744,93 @@ export default function ProjectsPage() {
</>
) : null}
{/* Bulk Action Floating Toolbar */}
{selectedIds.size > 0 && filters.roundId && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
<Card className="border-2 shadow-lg">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<Badge variant="secondary" className="shrink-0 text-sm">
{selectedIds.size} selected
</Badge>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Set status..." />
</SelectTrigger>
<SelectContent>
{['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].map((s) => (
<SelectItem key={s} value={s}>
{s.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleBulkApply}
disabled={!bulkStatus || bulkUpdateStatus.isPending}
>
{bulkUpdateStatus.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Apply
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedIds(new Set())
setBulkStatus('')
}}
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Bulk Status Update Confirmation Dialog */}
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update Project Status</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
You are about to change the status of{' '}
<strong>{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}</strong>{' '}
to <Badge variant={statusColors[bulkStatus] || 'secondary'}>{bulkStatus.replace('_', ' ')}</Badge>.
</p>
{bulkStatus === 'REJECTED' && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 p-3 text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm">
Warning: Rejected projects will be marked as eliminated. This will send notifications to the project teams.
</p>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkUpdateStatus.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleBulkConfirm}
className={bulkStatus === 'REJECTED' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
disabled={bulkUpdateStatus.isPending}
>
{bulkUpdateStatus.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>

View File

@@ -0,0 +1,404 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
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 { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
ArrowLeft,
ShieldAlert,
CheckCircle2,
AlertCircle,
MoreHorizontal,
ShieldCheck,
UserX,
StickyNote,
Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
function COIManagementContent({ roundId }: { roundId: string }) {
const [conflictsOnly, setConflictsOnly] = useState(false)
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
const { data: coiList, isLoading: loadingCOI } = trpc.evaluation.listCOIByRound.useQuery({
roundId,
hasConflictOnly: conflictsOnly || undefined,
})
const utils = trpc.useUtils()
const reviewCOI = trpc.evaluation.reviewCOI.useMutation({
onSuccess: (data) => {
utils.evaluation.listCOIByRound.invalidate({ roundId })
toast.success(`COI marked as "${data.reviewAction}"`)
},
onError: (error) => {
toast.error(error.message || 'Failed to review COI')
},
})
if (loadingRound || loadingCOI) {
return <COISkeleton />
}
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 conflictCount = coiList?.filter((c) => c.hasConflict).length ?? 0
const totalCount = coiList?.length ?? 0
const reviewedCount = coiList?.filter((c) => c.reviewAction).length ?? 0
const getReviewBadge = (reviewAction: string | null) => {
switch (reviewAction) {
case 'cleared':
return (
<Badge variant="default" className="bg-green-600">
<ShieldCheck className="mr-1 h-3 w-3" />
Cleared
</Badge>
)
case 'reassigned':
return (
<Badge variant="default" className="bg-blue-600">
<UserX className="mr-1 h-3 w-3" />
Reassigned
</Badge>
)
case 'noted':
return (
<Badge variant="secondary">
<StickyNote className="mr-1 h-3 w-3" />
Noted
</Badge>
)
default:
return (
<Badge variant="outline" className="text-amber-600 border-amber-300">
Pending Review
</Badge>
)
}
}
const getConflictTypeBadge = (type: string | null) => {
switch (type) {
case 'financial':
return <Badge variant="destructive">Financial</Badge>
case 'personal':
return <Badge variant="secondary">Personal</Badge>
case 'organizational':
return <Badge variant="outline">Organizational</Badge>
case 'other':
return <Badge variant="outline">Other</Badge>
default:
return null
}
}
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/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<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.name}
</Link>
<span>/</span>
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
{round.name}
</Link>
</div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<ShieldAlert className="h-6 w-6" />
Conflict of Interest Declarations
</h1>
</div>
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Declarations</CardTitle>
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Conflicts Declared</CardTitle>
<AlertCircle className="h-4 w-4 text-amber-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-amber-600">{conflictCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Reviewed</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{reviewedCount}</div>
</CardContent>
</Card>
</div>
{/* COI Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Declarations</CardTitle>
<CardDescription>
Review and manage conflict of interest declarations from jury members
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Switch
id="conflicts-only"
checked={conflictsOnly}
onCheckedChange={setConflictsOnly}
/>
<Label htmlFor="conflicts-only" className="text-sm">
Conflicts only
</Label>
</div>
</div>
</CardHeader>
<CardContent>
{coiList && coiList.length > 0 ? (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Juror</TableHead>
<TableHead>Conflict</TableHead>
<TableHead>Type</TableHead>
<TableHead>Description</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-12">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{coiList.map((coi) => (
<TableRow key={coi.id}>
<TableCell className="font-medium max-w-[200px] truncate">
{coi.assignment.project.title}
</TableCell>
<TableCell>
{coi.user.name || coi.user.email}
</TableCell>
<TableCell>
{coi.hasConflict ? (
<Badge variant="destructive">Yes</Badge>
) : (
<Badge variant="outline" className="text-green-600 border-green-300">
No
</Badge>
)}
</TableCell>
<TableCell>
{coi.hasConflict ? getConflictTypeBadge(coi.conflictType) : '-'}
</TableCell>
<TableCell className="max-w-[200px]">
{coi.description ? (
<span className="text-sm text-muted-foreground truncate block">
{coi.description}
</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{coi.hasConflict ? (
<div className="space-y-1">
{getReviewBadge(coi.reviewAction)}
{coi.reviewedBy && (
<p className="text-xs text-muted-foreground">
by {coi.reviewedBy.name || coi.reviewedBy.email}
{coi.reviewedAt && (
<> {formatDistanceToNow(new Date(coi.reviewedAt), { addSuffix: true })}</>
)}
</p>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">N/A</span>
)}
</TableCell>
<TableCell>
{coi.hasConflict && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={reviewCOI.isPending}
>
{reviewCOI.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'cleared',
})
}
>
<ShieldCheck className="mr-2 h-4 w-4" />
Clear
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'reassigned',
})
}
>
<UserX className="mr-2 h-4 w-4" />
Reassign
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'noted',
})
}
>
<StickyNote className="mr-2 h-4 w-4" />
Note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Declarations Yet</p>
<p className="text-sm text-muted-foreground">
{conflictsOnly
? 'No conflicts of interest have been declared for this round'
: 'No jury members have submitted COI declarations for this round yet'}
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function COISkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-80" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-48 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function COIManagementPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<COISkeleton />}>
<COIManagementContent roundId={id} />
</Suspense>
)
}

View File

@@ -418,7 +418,45 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
{/* Team Notification - removed from schema, feature not implemented */}
{/* Upload Deadline Policy */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Upload Deadline Policy</CardTitle>
<CardDescription>
Control how file uploads are handled after the round starts
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Select
value={(roundSettings.uploadDeadlinePolicy as string) || ''}
onValueChange={(value) =>
setRoundSettings((prev) => ({
...prev,
uploadDeadlinePolicy: value || undefined,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Default (no restriction)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">
Default - No restriction
</SelectItem>
<SelectItem value="BLOCK">
Block uploads after round starts
</SelectItem>
<SelectItem value="ALLOW_LATE">
Allow late uploads (marked as late)
</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
When set to &ldquo;Block&rdquo;, applicants cannot upload files after the voting start date.
When set to &ldquo;Allow late&rdquo;, uploads are accepted but flagged as late submissions.
</p>
</CardContent>
</Card>
{/* Evaluation Criteria */}
<Card>

View File

@@ -53,6 +53,7 @@ import {
RotateCcw,
Loader2,
ShieldCheck,
Download,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -109,6 +110,41 @@ export default function FilteringResultsPage({
const overrideResult = trpc.filtering.overrideResult.useMutation()
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
const exportResults = trpc.export.filteringResults.useQuery(
{ roundId },
{ enabled: false }
)
const handleExport = async () => {
const result = await exportResults.refetch()
if (result.data) {
const { data: rows, columns } = result.data
const csvContent = [
columns.join(','),
...rows.map((row) =>
columns
.map((col) => {
const value = row[col as keyof typeof row]
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`
}
return value ?? ''
})
.join(',')
),
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `filtering-results-${new Date().toISOString().split('T')[0]}.csv`
link.click()
URL.revokeObjectURL(url)
}
}
const toggleRow = (id: string) => {
const next = new Set(expandedRows)
if (next.has(id)) next.delete(id)
@@ -166,13 +202,27 @@ export default function FilteringResultsPage({
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Filtering Results
</h1>
<p className="text-muted-foreground">
Review and override filtering outcomes
</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Filtering Results
</h1>
<p className="text-muted-foreground">
Review and override filtering outcomes
</p>
</div>
<Button
variant="outline"
onClick={handleExport}
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>
{/* Outcome Filter Tabs */}

View File

@@ -50,6 +50,7 @@ import {
AlertTriangle,
ListChecks,
ClipboardCheck,
Sparkles,
} from 'lucide-react'
import { toast } from 'sonner'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
@@ -125,6 +126,22 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const startJob = trpc.filtering.startJob.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
// 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')) {
@@ -764,6 +781,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Jury Assignments
</Link>
</Button>
<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>
</div>
</div>
</CardContent>