From 8e5fc18da669ea0954d88574e59ff71449b516cb Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 16 Feb 2026 09:20:02 +0100 Subject: [PATCH] Consolidated round management, AI filtering enhancements, MinIO storage restructure - Fix STAGE_ACTIVE bug in assignment router (now ROUND_ACTIVE) - Add evaluation form CRUD (getForm + upsertForm endpoints) - Add advanceProjects mutation for manual project advancement - Rewrite round detail page: 7-tab consolidated interface - Add filtering rules UI with full CRUD (field-based, document check, AI screening) - Add pageCount field to ProjectFile for document page limit filtering - Enhance AI filtering: per-file page limits, category/region-aware guidelines - Restructure MinIO paths: {ProjectName}/{RoundName}/{timestamp}-{file} - Update dashboard and pool page links from /admin/competitions to /admin/rounds Co-Authored-By: Claude Opus 4.6 --- prisma/schema.prisma | 9 +- src/app/(admin)/admin/dashboard-content.tsx | 16 +- src/app/(admin)/admin/projects/pool/page.tsx | 2 +- .../(admin)/admin/rounds/[roundId]/page.tsx | 1715 ++++++++++++++--- .../admin/round/filtering-dashboard.tsx | 752 ++++++++ src/lib/minio.ts | 41 +- src/server/routers/applicant.ts | 16 +- src/server/routers/assignment.ts | 2 +- src/server/routers/evaluation.ts | 123 ++ src/server/routers/file.ts | 41 +- src/server/routers/filtering.ts | 4 +- src/server/routers/round.ts | 131 ++ src/server/services/ai-filtering.ts | 35 +- src/server/services/anonymization.ts | 22 +- 14 files changed, 2606 insertions(+), 303 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 98e8e86..46d266b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -683,10 +683,11 @@ model ProjectFile { requirementId String? // FK to FileRequirement (if uploaded against a requirement) // File info - fileType FileType - fileName String - mimeType String - size Int // bytes + fileType FileType + fileName String + mimeType String + size Int // bytes + pageCount Int? // Number of pages (PDFs, presentations, etc.) // MinIO location bucket String diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx index 2341328..26278d8 100644 --- a/src/app/(admin)/admin/dashboard-content.tsx +++ b/src/app/(admin)/admin/dashboard-content.tsx @@ -467,13 +467,13 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro {/* Quick Actions */}
- +
-

Competitions

-

Manage rounds & competitions

+

Rounds

+

Manage competition rounds

@@ -517,7 +517,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
View all @@ -532,10 +532,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro No rounds created yet

- Set up your competition + Set up your rounds ) : ( @@ -682,7 +682,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{pendingCOIs > 0 && ( - +
COI declarations to review @@ -700,7 +700,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro )} {draftRounds > 0 && ( - +
Draft rounds to activate diff --git a/src/app/(admin)/admin/projects/pool/page.tsx b/src/app/(admin)/admin/projects/pool/page.tsx index dc2364c..5f30726 100644 --- a/src/app/(admin)/admin/projects/pool/page.tsx +++ b/src/app/(admin)/admin/projects/pool/page.tsx @@ -228,7 +228,7 @@ export default function ProjectPoolPage() {

Round Not Found

-

The requested round does not exist

+

This round does not exist.

) } - const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT - const canActivate = round.status === 'ROUND_DRAFT' - const canClose = round.status === 'ROUND_ACTIVE' - const canArchive = round.status === 'ROUND_CLOSED' + const status = round.status as keyof typeof roundStatusConfig + const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT + const typeCfg = roundTypeConfig[round.roundType] || roundTypeConfig.INTAKE + + // ── Readiness checklist ──────────────────────────────────────────────── + const readinessItems = [ + { + label: 'Projects assigned', + ready: projectCount > 0, + detail: projectCount > 0 ? `${projectCount} projects` : 'No projects yet', + action: projectCount === 0 ? poolLink : undefined, + actionLabel: 'Assign Projects', + }, + ...((isEvaluation || isFiltering) + ? [{ + label: 'Jury group set', + ready: !!juryGroup, + detail: juryGroup ? `${juryGroup.name} (${juryMemberCount} members)` : 'No jury group assigned', + action: undefined as Route | undefined, + actionLabel: undefined as string | undefined, + }] + : []), + { + label: 'Dates configured', + ready: !!round.windowOpenAt && !!round.windowCloseAt, + detail: + round.windowOpenAt && round.windowCloseAt + ? `${new Date(round.windowOpenAt).toLocaleDateString()} \u2014 ${new Date(round.windowCloseAt).toLocaleDateString()}` + : 'No dates set \u2014 configure in Config tab', + action: undefined as Route | undefined, + actionLabel: undefined as string | undefined, + }, + { + label: 'File requirements set', + ready: (fileRequirements?.length ?? 0) > 0, + detail: + (fileRequirements?.length ?? 0) > 0 + ? `${fileRequirements?.length} requirement(s)` + : 'No file requirements \u2014 configure in Config tab', + action: undefined as Route | undefined, + actionLabel: undefined as string | undefined, + }, + ] + const readyCount = readinessItems.filter((i) => i.ready).length + + // ═════════════════════════════════════════════════════════════════════════ + // Render + // ═════════════════════════════════════════════════════════════════════════ return (
- {/* Header */} -
-
- - -
-

Back to Rounds

-
-
- -
-
-

{round.name}

- - {round.roundType.replace('_', ' ')} +
+

{round.name}

+ + {typeCfg.label} - {/* Status Dropdown */} + {/* Status dropdown */} - - {canActivate && ( - setConfirmAction('activate')}> + {status === 'ROUND_DRAFT' && ( + activateMutation.mutate({ roundId })} + disabled={isTransitioning} + > Activate Round )} - {canClose && ( - setConfirmAction('close')}> + {status === 'ROUND_ACTIVE' && ( + closeMutation.mutate({ roundId })} + disabled={isTransitioning} + > Close Round )} - {canArchive && ( + {status === 'ROUND_CLOSED' && ( <> + activateMutation.mutate({ roundId })} + disabled={isTransitioning} + > + + Reactivate Round + - setConfirmAction('archive')}> + archiveMutation.mutate({ roundId })} + disabled={isTransitioning} + > Archive Round )} - {!canActivate && !canClose && !canArchive && ( - No actions available + {isTransitioning && ( +
+ + Updating... +
)}
-

{round.slug}

+

{typeCfg.description}

+
-
- {hasChanges && ( - - )} -
+ {/* Action buttons */} +
+ {hasChanges && ( + + )} + + +
- {/* Summary Stats */} -
- - -
-
- -
-
-

{round._count?.projectRoundStates ?? 0}

-

Projects

+ {/* ===== STATS BAR ===== */} +
+ {/* Projects */} + + +
+
+ + Projects
+

{projectCount}

+
+ {Object.entries(stateCounts).map(([state, count]) => ( + + {String(count)} {state.toLowerCase().replace('_', ' ')} + + ))} +
- - -
-
- -
-
-

{round.juryGroup?.members?.length ?? 0}

-

Jury Members

-
+ {/* Jury (with inline group selector) */} + + +
+ + Jury
+ {juryGroups && juryGroups.length > 0 ? ( + + ) : juryGroup ? ( + <> +

{juryMemberCount}

+

{juryGroup.name}

+ + ) : ( + <> +

+

No jury groups yet

+ + )}
- - -
-
- -
-
- {round.juryGroup ? ( - <> -

{round.juryGroup.name}

-

Jury Group

- - ) : ( - <> -

No jury assigned

-

Jury Group

- - )} -
+ {/* Window */} + + +
+ + Window
+ {round.windowOpenAt || round.windowCloseAt ? ( + <> +

+ {round.windowOpenAt + ? new Date(round.windowOpenAt).toLocaleDateString() + : 'No start'} +

+

+ {round.windowCloseAt + ? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}` + : 'No deadline'} +

+ + ) : ( + <> +

+

No dates set

+ + )}
- - -
-
- -
-
- {round.windowOpenAt || round.windowCloseAt ? ( - <> -

- {round.windowOpenAt && new Date(round.windowOpenAt).toLocaleDateString()} - {round.windowOpenAt && round.windowCloseAt && ' - '} - {round.windowCloseAt && new Date(round.windowCloseAt).toLocaleDateString()} -

-

Schedule

- - ) : ( - <> -

Not scheduled

-

Schedule

- - )} -
+ {/* Advancement */} + + +
+ + Advancement
+ {round.advancementRules && round.advancementRules.length > 0 ? ( + <> +

{round.advancementRules.length}

+

+ {round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')} +

+ + ) : ( + <> +

+

Admin selection

+ + )}
- {/* Schedule Editor */} - - -
- -

Round Schedule

-
-
-
- - { setWindowOpenAt(e.target.value); setHasChanges(true) }} - className="h-10" - /> -
-
- - { setWindowCloseAt(e.target.value); setHasChanges(true) }} - className="h-10" - /> -
-
-
-
- - {/* Tabs */} - - - - Configuration + {/* ===== TABS ===== */} + + + + + Overview - + + Projects - - Submission Windows + {isFiltering && ( + + + Filtering + + )} + {isEvaluation && ( + + + Assignments + + )} + + + Config - - Documents + + + Submissions - + + Awards {roundAwards.length > 0 && ( - + {roundAwards.length} )} - + {/* ═══════════ OVERVIEW TAB ═══════════ */} + + {/* Readiness Checklist */} + + +
+
+ Readiness Checklist + + {readyCount}/{readinessItems.length} items ready + +
+ + {readyCount === readinessItems.length ? 'Ready' : 'Incomplete'} + +
+
+ +
+ {readinessItems.map((item) => ( +
+ {item.ready ? ( + + ) : ( + + )} +
+

+ {item.label} +

+

{item.detail}

+
+ {item.action && ( + + + + )} +
+ ))} +
+
+
+ + {/* Quick Actions */} + + + Quick Actions + Common operations for this round + + +
+ {/* Status transitions */} + {status === 'ROUND_DRAFT' && ( + + + + + + + Activate this round? + + The round will go live. Projects can be processed and jury members will be able to see their assignments. + + + + Cancel + activateMutation.mutate({ roundId })}> + Activate + + + + + )} + + {status === 'ROUND_ACTIVE' && ( + + + + + + + Close this round? + + No further changes will be accepted. You can reactivate later if needed. + {projectCount > 0 && ( + + {projectCount} projects are currently in this round. + + )} + + + + Cancel + closeMutation.mutate({ roundId })}> + Close Round + + + + + )} + + {/* Assign projects */} + + + + + {/* Filtering specific */} + {isFiltering && ( + + )} + + {/* Jury assignment for evaluation/filtering */} + {(isEvaluation || isFiltering) && !juryGroup && ( + + )} + + {/* Evaluation: manage assignments */} + {isEvaluation && ( + + )} + + {/* View projects */} + + + {/* Advance projects (shown when PASSED > 0) */} + {passedCount > 0 && ( + + )} +
+
+
+ + {/* Advance Projects Confirmation Dialog */} + + + + Advance {passedCount} project(s)? + + All projects with PASSED status in this round will be moved to the next round. + This action creates new entries in the next round and marks current entries as completed. + + + + Cancel + advanceMutation.mutate({ roundId })} + disabled={advanceMutation.isPending} + > + {advanceMutation.isPending && } + Advance Projects + + + + + + {/* Round Info + Project Breakdown */} +
+ + + Round Details + + +
+ Type + {typeCfg.label} +
+
+ Status + {statusCfg.label} +
+
+ Sort Order + {round.sortOrder} +
+ {round.purposeKey && ( +
+ Purpose + {round.purposeKey} +
+ )} +
+ Jury Group + + {juryGroup ? juryGroup.name : '\u2014'} + +
+
+ Opens + + {round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'} + +
+
+ Closes + + {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'} + +
+
+
+ + + + Project Breakdown + + + {projectCount === 0 ? ( +

+ No projects assigned yet +

+ ) : ( +
+ {['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].map((state) => { + const count = stateCounts[state] || 0 + if (count === 0) return null + const pct = ((count / projectCount) * 100).toFixed(0) + return ( +
+
+ {state.toLowerCase().replace('_', ' ')} + {count} ({pct}%) +
+
+
+
+
+ ) + })} +
+ )} + + +
+ + + {/* ═══════════ PROJECTS TAB ═══════════ */} + + + + + {/* ═══════════ FILTERING TAB ═══════════ */} + {isFiltering && ( + + + + )} + + {/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */} + {isEvaluation && ( + + {/* Coverage Report */} + + + {/* Generate Assignments */} + + +
+
+ Assignment Generation + + AI-suggested jury-to-project assignments based on expertise and workload + +
+ +
+
+ + {!juryGroup && ( +
+ + Assign a jury group first before generating assignments. +
+ )} + {projectCount === 0 && ( +
+ + Add projects to this round first. +
+ )} + {juryGroup && projectCount > 0 && ( +

+ Click "Generate Assignments" to preview AI-suggested assignments. + You can review and execute them from the preview sheet. +

+ )} +
+
+ + {/* Jury Progress + Score Distribution */} +
+ + +
+ + {/* Actions: Send Reminders + Export */} +
+ + +
+ + {/* Individual Assignments Table */} + + + {/* Unassigned Queue */} + + + {/* Assignment Preview Sheet */} + + + {/* CSV Export Dialog */} + +
+ )} + + {/* ═══════════ CONFIG TAB ═══════════ */} + + {/* General Round Settings */} + + + General Settings + Settings that apply to this round regardless of type + + +
+
+ +

+ Send an automated email to project applicants when their project enters this round +

+
+ { + handleConfigChange({ ...config, notifyOnEntry: checked }) + }} + /> +
+
+
+ + {/* Round-type-specific config */} ({ id: jg.id, name: jg.name }))} /> + + {/* Evaluation Criteria Editor (EVALUATION rounds only) */} + {isEvaluation && } + + {/* Document Requirements */} + + + Document Requirements + + Files applicants must submit for this round + {round.windowCloseAt && ( + <> — due by {new Date(round.windowCloseAt).toLocaleDateString()} + )} + + + + + +
- - - - + {/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */} - - - - - + + {/* ═══════════ AWARDS TAB ═══════════ */} {roundAwards.length === 0 ? (
+

No awards linked to this round

- Create an award and set this round as its source round to see it here + Create an award and set this round as its evaluation round to see it here

) : ( @@ -497,17 +1153,11 @@ export default function RoundDetailPage() {
-
- {ruleCount} -
-
- {ruleCount === 1 ? 'rule' : 'rules'} -
+
{ruleCount}
+
{ruleCount === 1 ? 'rule' : 'rules'}
-
- {eligibleCount} -
+
{eligibleCount}
eligible
@@ -521,31 +1171,576 @@ export default function RoundDetailPage() {
+
+ ) +} - {/* Lifecycle Confirmation Dialog */} - setConfirmAction(null)}> +// ═══════════════════════════════════════════════════════════════════════════ +// Sub-components +// ═══════════════════════════════════════════════════════════════════════════ + +// ── Unassigned projects queue ──────────────────────────────────────────── + +function RoundUnassignedQueue({ roundId }: { roundId: string }) { + const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery( + { roundId, requiredReviews: 3 }, + ) + + return ( + + + Unassigned Projects + Projects with fewer than 3 jury assignments + + + {isLoading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : unassigned && unassigned.length > 0 ? ( +
+ {unassigned.map((project: any) => ( +
+
+

{project.title}

+

+ {project.competitionCategory || 'No category'} + {project.teamName && ` \u00b7 ${project.teamName}`} +

+
+ + {project.assignmentCount || 0} / 3 + +
+ ))} +
+ ) : ( +

+ All projects have sufficient assignments +

+ )} +
+
+ ) +} + +// ── Jury Progress Table ────────────────────────────────────────────────── + +function JuryProgressTable({ roundId }: { roundId: string }) { + const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery({ roundId }) + + return ( + + + Jury Progress + Evaluation completion per juror + + + {isLoading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : !workload || workload.length === 0 ? ( +

+ No assignments yet +

+ ) : ( +
+ {workload.map((juror) => { + const pct = juror.completionRate + const barColor = pct === 100 + ? 'bg-emerald-500' + : pct >= 50 + ? 'bg-blue-500' + : pct > 0 + ? 'bg-amber-500' + : 'bg-gray-300' + + return ( +
+
+ {juror.name} + + {juror.completed}/{juror.assigned} ({pct}%) + +
+
+
+
+
+ ) + })} +
+ )} + + + ) +} + +// ── Score Distribution ─────────────────────────────────────────────────── + +function ScoreDistribution({ roundId }: { roundId: string }) { + const { data: dist, isLoading } = trpc.analytics.getRoundScoreDistribution.useQuery({ roundId }) + + const maxCount = useMemo(() => + dist ? Math.max(...dist.globalDistribution.map((b) => b.count), 1) : 1, + [dist]) + + return ( + + + Score Distribution + + {dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'} + + + + {isLoading ? ( +
+ {Array.from({ length: 10 }).map((_, i) => )} +
+ ) : !dist || dist.totalEvaluations === 0 ? ( +

+ No evaluations submitted yet +

+ ) : ( +
+ {dist.globalDistribution.map((bucket) => { + const heightPct = (bucket.count / maxCount) * 100 + return ( +
+ {bucket.count || ''} +
+
+
+ {bucket.score} +
+ ) + })} +
+ )} + + + ) +} + +// ── Send Reminders Button ──────────────────────────────────────────────── + +function SendRemindersButton({ roundId }: { roundId: string }) { + const [open, setOpen] = useState(false) + const mutation = trpc.evaluation.triggerReminders.useMutation({ + onSuccess: (data) => { + toast.success(`Sent ${data.sent} reminder(s)`) + setOpen(false) + }, + onError: (err) => toast.error(err.message), + }) + + return ( + <> + + + + + Send evaluation reminders? + + This will send reminder emails to all jurors who have incomplete evaluations for this round. + + + + Cancel + mutation.mutate({ roundId })} + disabled={mutation.isPending} + > + {mutation.isPending && } + Send Reminders + + + + + + ) +} + +// ── Export Evaluations Dialog ───────────────────────────────────────────── + +function ExportEvaluationsDialog({ + roundId, + open, + onOpenChange, +}: { + roundId: string + open: boolean + onOpenChange: (open: boolean) => void +}) { + const [exportData, setExportData] = useState(undefined) + const [isLoadingExport, setIsLoadingExport] = useState(false) + const utils = trpc.useUtils() + + const handleRequestData = async () => { + setIsLoadingExport(true) + try { + const data = await utils.export.evaluations.fetch({ roundId, includeDetails: true }) + setExportData(data) + return data + } finally { + setIsLoadingExport(false) + } + } + + return ( + + ) +} + +// ── Individual Assignments Table ───────────────────────────────────────── + +function IndividualAssignmentsTable({ roundId }: { roundId: string }) { + const [addDialogOpen, setAddDialogOpen] = useState(false) + const [newUserId, setNewUserId] = useState('') + const [newProjectId, setNewProjectId] = useState('') + + const utils = trpc.useUtils() + const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery({ roundId }) + + const deleteMutation = trpc.assignment.delete.useMutation({ + onSuccess: () => { + utils.assignment.listByStage.invalidate({ roundId }) + toast.success('Assignment removed') + }, + onError: (err) => toast.error(err.message), + }) + + const createMutation = trpc.assignment.create.useMutation({ + onSuccess: () => { + utils.assignment.listByStage.invalidate({ roundId }) + toast.success('Assignment created') + setAddDialogOpen(false) + setNewUserId('') + setNewProjectId('') + }, + onError: (err) => toast.error(err.message), + }) + + return ( + + +
+
+ All Assignments + + {assignments?.length ?? 0} individual jury-project assignments + +
+ +
+
+ + {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map((i) => )} +
+ ) : !assignments || assignments.length === 0 ? ( +

+ No assignments yet. Generate assignments or add one manually. +

+ ) : ( +
+
+ Juror + Project + Status + +
+ {assignments.map((a: any) => ( +
+ {a.user?.name || a.user?.email || 'Unknown'} + {a.project?.title || 'Unknown'} + + {a.evaluation?.status || 'PENDING'} + + +
+ ))} +
+ )} +
+ + {/* Add Assignment Dialog */} + - - {confirmAction === 'activate' && 'Activate Round'} - {confirmAction === 'close' && 'Close Round'} - {confirmAction === 'archive' && 'Archive Round'} - + Add Assignment - {confirmAction === 'activate' && 'This will open the round for submissions and evaluations. Projects will be able to enter this round.'} - {confirmAction === 'close' && 'This will close the round. No more submissions or evaluations will be accepted.'} - {confirmAction === 'archive' && 'This will archive the round. It will no longer appear in active views.'} + Manually assign a juror to evaluate a project +
+
+ + setNewUserId(e.target.value)} + /> +
+
+ + setNewProjectId(e.target.value)} + /> +
+
- - +
-
+
+ ) +} + +// ── Evaluation Criteria Editor ─────────────────────────────────────────── + +function EvaluationCriteriaEditor({ roundId }: { roundId: string }) { + const [editing, setEditing] = useState(false) + const [criteria, setCriteria] = useState>([]) + + const utils = trpc.useUtils() + const { data: form, isLoading } = trpc.evaluation.getForm.useQuery({ roundId }) + + const upsertMutation = trpc.evaluation.upsertForm.useMutation({ + onSuccess: () => { + utils.evaluation.getForm.invalidate({ roundId }) + toast.success('Evaluation criteria saved') + setEditing(false) + }, + onError: (err) => toast.error(err.message), + }) + + // Sync from server + if (form && !editing) { + const serverCriteria = form.criteriaJson ?? [] + if (JSON.stringify(serverCriteria) !== JSON.stringify(criteria)) { + setCriteria(serverCriteria) + } + } + + const handleAdd = () => { + setCriteria([...criteria, { + id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + label: '', + description: '', + weight: 1, + minScore: 0, + maxScore: 10, + }]) + setEditing(true) + } + + const handleRemove = (id: string) => { + setCriteria(criteria.filter((c) => c.id !== id)) + } + + const handleChange = (id: string, field: string, value: string | number) => { + setCriteria(criteria.map((c) => + c.id === id ? { ...c, [field]: value } : c, + )) + setEditing(true) + } + + const handleSave = () => { + const validCriteria = criteria.filter((c) => c.label.trim()) + if (validCriteria.length === 0) { + toast.error('Add at least one criterion') + return + } + upsertMutation.mutate({ roundId, criteria: validCriteria }) + } + + return ( + + +
+
+ Evaluation Criteria + + {form ? `Version ${form.version} \u2014 ${form.criteriaJson.length} criteria` : 'No criteria defined yet'} + +
+
+ {editing && ( + + )} + {editing ? ( + + ) : ( + + )} +
+
+
+ + {isLoading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : criteria.length === 0 ? ( +
+ +

No evaluation criteria defined

+

Add criteria that jurors will use to score projects

+
+ ) : ( +
+ {criteria.map((c, idx) => ( +
+ + {idx + 1} + +
+ handleChange(c.id, 'label', e.target.value)} + className="h-8 text-sm" + /> + handleChange(c.id, 'description', e.target.value)} + className="h-7 text-xs" + /> +
+
+ + handleChange(c.id, 'weight', Number(e.target.value))} + className="h-7 text-xs" + /> +
+
+ + handleChange(c.id, 'minScore', Number(e.target.value))} + className="h-7 text-xs" + /> +
+
+ + handleChange(c.id, 'maxScore', Number(e.target.value))} + className="h-7 text-xs" + /> +
+
+
+ +
+ ))} + {!editing && ( + + )} +
+ )} +
+
) } diff --git a/src/components/admin/round/filtering-dashboard.tsx b/src/components/admin/round/filtering-dashboard.tsx index 1a37b90..0c42dd2 100644 --- a/src/components/admin/round/filtering-dashboard.tsx +++ b/src/components/admin/round/filtering-dashboard.tsx @@ -37,6 +37,13 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' import { Play, Loader2, @@ -55,6 +62,13 @@ import { RotateCcw, Search, ExternalLink, + Plus, + Pencil, + Trash2, + FileText, + Brain, + ListFilter, + GripVertical, } from 'lucide-react' import Link from 'next/link' import type { Route } from 'next' @@ -385,6 +399,9 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar )} + {/* Filtering Rules */} + + {/* Stats Cards */} {statsLoading ? (
@@ -922,3 +939,738 @@ function ConfidenceIndicator({ value }: { value: number }) { ) } + +// ─── Filtering Rules Section ──────────────────────────────────────────────── + +type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING' + +const RULE_TYPE_META: Record = { + FIELD_BASED: { label: 'Field-Based', icon: ListFilter, color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'Evaluate project fields (category, founding date, location, etc.)' }, + DOCUMENT_CHECK: { label: 'Document Check', icon: FileText, color: 'bg-teal-100 text-teal-800 border-teal-200', description: 'Validate file uploads (min count, formats, page limits)' }, + AI_SCREENING: { label: 'AI Screening', icon: Brain, color: 'bg-purple-100 text-purple-800 border-purple-200', description: 'GPT evaluates projects against natural language criteria' }, +} + +const FIELD_OPTIONS = [ + { value: 'competitionCategory', label: 'Competition Category', operators: ['equals', 'not_equals'] }, + { value: 'foundedAt', label: 'Founded Date', operators: ['older_than_years', 'newer_than_years'] }, + { value: 'country', label: 'Country', operators: ['equals', 'not_equals', 'in', 'not_in'] }, + { value: 'geographicZone', label: 'Geographic Zone', operators: ['equals', 'not_equals', 'contains'] }, + { value: 'tags', label: 'Tags', operators: ['contains', 'in'] }, + { value: 'oceanIssue', label: 'Ocean Issue', operators: ['equals', 'not_equals', 'in'] }, +] + +const FILE_TYPES = [ + { value: 'EXEC_SUMMARY', label: 'Executive Summary' }, + { value: 'PRESENTATION', label: 'Presentation' }, + { value: 'BUSINESS_PLAN', label: 'Business Plan' }, + { value: 'VIDEO', label: 'Video' }, + { value: 'VIDEO_PITCH', label: 'Video Pitch' }, + { value: 'SUPPORTING_DOC', label: 'Supporting Doc' }, + { value: 'OTHER', label: 'Other' }, +] + +type FieldCondition = { + field: string + operator: string + value: string | number | string[] +} + +type RuleFormData = { + name: string + ruleType: RuleType + priority: number + // FIELD_BASED + conditions: FieldCondition[] + logic: 'AND' | 'OR' + fieldAction: 'PASS' | 'REJECT' | 'FLAG' + // DOCUMENT_CHECK + requiredFileTypes: string[] + minFileCount: number | '' + maxPages: number | '' + maxPagesByFileType: Record + docAction: 'PASS' | 'REJECT' | 'FLAG' + // AI_SCREENING + criteriaText: string + aiAction: 'PASS' | 'REJECT' | 'FLAG' + batchSize: number + parallelBatches: number +} + +const DEFAULT_FORM: RuleFormData = { + name: '', + ruleType: 'FIELD_BASED', + priority: 0, + conditions: [{ field: 'competitionCategory', operator: 'equals', value: '' }], + logic: 'AND', + fieldAction: 'REJECT', + requiredFileTypes: [], + minFileCount: '', + maxPages: '', + maxPagesByFileType: {}, + docAction: 'REJECT', + criteriaText: '', + aiAction: 'FLAG', + batchSize: 20, + parallelBatches: 1, +} + +function buildConfigJson(form: RuleFormData): Record { + switch (form.ruleType) { + case 'FIELD_BASED': + return { + conditions: form.conditions.map((c) => ({ + field: c.field, + operator: c.operator, + value: c.value, + })), + logic: form.logic, + action: form.fieldAction, + } + case 'DOCUMENT_CHECK': { + const config: Record = { + action: form.docAction, + } + if (form.requiredFileTypes.length > 0) config.requiredFileTypes = form.requiredFileTypes + if (form.minFileCount !== '' && form.minFileCount > 0) config.minFileCount = form.minFileCount + if (form.maxPages !== '' && form.maxPages > 0) config.maxPages = form.maxPages + if (Object.keys(form.maxPagesByFileType).length > 0) config.maxPagesByFileType = form.maxPagesByFileType + return config + } + case 'AI_SCREENING': + return { + criteriaText: form.criteriaText, + action: form.aiAction, + batchSize: form.batchSize, + parallelBatches: form.parallelBatches, + } + } +} + +function parseConfigToForm(rule: { name: string; ruleType: string; configJson: unknown; priority: number }): RuleFormData { + const config = (rule.configJson || {}) as Record + const base = { ...DEFAULT_FORM, name: rule.name, ruleType: rule.ruleType as RuleType, priority: rule.priority } + + switch (rule.ruleType) { + case 'FIELD_BASED': + return { + ...base, + conditions: (config.conditions as FieldCondition[]) || [{ field: 'competitionCategory', operator: 'equals', value: '' }], + logic: (config.logic as 'AND' | 'OR') || 'AND', + fieldAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT', + } + case 'DOCUMENT_CHECK': + return { + ...base, + requiredFileTypes: (config.requiredFileTypes as string[]) || [], + minFileCount: (config.minFileCount as number) || '', + maxPages: (config.maxPages as number) || '', + maxPagesByFileType: (config.maxPagesByFileType as Record) || {}, + docAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT', + } + case 'AI_SCREENING': + return { + ...base, + criteriaText: (config.criteriaText as string) || '', + aiAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG', + batchSize: (config.batchSize as number) || 20, + parallelBatches: (config.parallelBatches as number) || 1, + } + default: + return base + } +} + +function FilteringRulesSection({ roundId }: { roundId: string }) { + const [isOpen, setIsOpen] = useState(true) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingRule, setEditingRule] = useState(null) + const [form, setForm] = useState({ ...DEFAULT_FORM }) + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + + const utils = trpc.useUtils() + + const { data: rules, isLoading } = trpc.filtering.getRules.useQuery({ roundId }) + + const createMutation = trpc.filtering.createRule.useMutation({ + onSuccess: () => { + utils.filtering.getRules.invalidate({ roundId }) + setDialogOpen(false) + setForm({ ...DEFAULT_FORM }) + toast.success('Rule created') + }, + onError: (err) => toast.error(err.message), + }) + + const updateMutation = trpc.filtering.updateRule.useMutation({ + onSuccess: () => { + utils.filtering.getRules.invalidate({ roundId }) + setDialogOpen(false) + setEditingRule(null) + setForm({ ...DEFAULT_FORM }) + toast.success('Rule updated') + }, + onError: (err) => toast.error(err.message), + }) + + const deleteMutation = trpc.filtering.deleteRule.useMutation({ + onSuccess: () => { + utils.filtering.getRules.invalidate({ roundId }) + setDeleteConfirmId(null) + toast.success('Rule deleted') + }, + onError: (err) => toast.error(err.message), + }) + + const toggleActiveMutation = trpc.filtering.updateRule.useMutation({ + onSuccess: () => { + utils.filtering.getRules.invalidate({ roundId }) + }, + onError: (err) => toast.error(err.message), + }) + + const handleSave = () => { + const configJson = buildConfigJson(form) + if (editingRule) { + updateMutation.mutate({ id: editingRule, name: form.name, ruleType: form.ruleType, configJson, priority: form.priority }) + } else { + createMutation.mutate({ roundId, name: form.name, ruleType: form.ruleType, configJson, priority: form.priority }) + } + } + + const openEdit = (rule: any) => { + setEditingRule(rule.id) + setForm(parseConfigToForm(rule)) + setDialogOpen(true) + } + + const openCreate = () => { + setEditingRule(null) + setForm({ ...DEFAULT_FORM, priority: (rules?.length ?? 0) }) + setDialogOpen(true) + } + + const meta = RULE_TYPE_META[form.ruleType] + + return ( + <> + + + + +
+
+ +
+ Filtering Rules + + {rules?.length ?? 0} active rule{(rules?.length ?? 0) !== 1 ? 's' : ''} — executed in priority order + +
+
+
+ + {isOpen ? : } +
+
+
+
+ + + + {isLoading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : rules && rules.length > 0 ? ( +
+ {rules.map((rule: any, idx: number) => { + const typeMeta = RULE_TYPE_META[rule.ruleType as RuleType] || RULE_TYPE_META.FIELD_BASED + const Icon = typeMeta.icon + const config = (rule.configJson || {}) as Record + + return ( +
+
+ + {idx + 1} +
+ + + + {typeMeta.label} + + +
+

{rule.name}

+

+ {rule.ruleType === 'FIELD_BASED' && ( + <> + {((config.conditions as any[]) || []).length} condition{((config.conditions as any[]) || []).length !== 1 ? 's' : ''} ({config.logic as string || 'AND'}) → {config.action as string} + + )} + {rule.ruleType === 'DOCUMENT_CHECK' && ( + <> + {config.minFileCount ? `Min ${config.minFileCount} files` : ''} + {config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''} + {config.maxPages ? ` \u00b7 Max ${config.maxPages} pages` : ''} + {config.maxPagesByFileType && Object.keys(config.maxPagesByFileType as object).length > 0 + ? ` \u00b7 Page limits per type` + : ''} + {' \u2192 '}{config.action as string} + + )} + {rule.ruleType === 'AI_SCREENING' && ( + <> + {((config.criteriaText as string) || '').substring(0, 80)}{((config.criteriaText as string) || '').length > 80 ? '...' : ''} → {config.action as string} + + )} +

+
+ +
+ { + toggleActiveMutation.mutate({ id: rule.id, isActive: checked }) + }} + /> + + +
+
+ ) + })} +
+ ) : ( +
+ +

No filtering rules configured

+

+ Add rules to define how projects are screened +

+ +
+ )} +
+
+
+
+ + {/* Create/Edit Rule Dialog */} + { + setDialogOpen(open) + if (!open) { setEditingRule(null); setForm({ ...DEFAULT_FORM }) } + }}> + + + {editingRule ? 'Edit Rule' : 'Create Filtering Rule'} + + {editingRule ? 'Update this filtering rule configuration' : 'Define a new rule for screening projects'} + + + +
+ {/* Rule Name + Priority */} +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, priority: parseInt(e.target.value) || 0 }))} + /> +
+
+ + {/* Rule Type Selector */} +
+ +
+ {(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([type, m]) => { + const Icon = m.icon + const selected = form.ruleType === type + return ( + + ) + })} +
+
+ + {/* Type-Specific Config */} + {form.ruleType === 'FIELD_BASED' && ( +
+
+ +
+ +
+
+ + {form.conditions.map((cond, i) => { + const fieldMeta = FIELD_OPTIONS.find((f) => f.value === cond.field) + return ( +
+
+ {i === 0 && } + +
+
+ {i === 0 && } + +
+
+ {i === 0 && } + { + const newConds = [...form.conditions] + const val = ['in', 'not_in'].includes(cond.operator) + ? e.target.value.split(',').map((s) => s.trim()) + : ['older_than_years', 'newer_than_years'].includes(cond.operator) + ? parseInt(e.target.value) || 0 + : e.target.value + newConds[i] = { ...newConds[i], value: val } + setForm((f) => ({ ...f, conditions: newConds })) + }} + /> +
+ +
+ ) + })} + + + +
+ + +
+
+ )} + + {form.ruleType === 'DOCUMENT_CHECK' && ( +
+
+
+ + setForm((f) => ({ ...f, minFileCount: e.target.value ? parseInt(e.target.value) : '' }))} + /> +
+
+ + setForm((f) => ({ ...f, maxPages: e.target.value ? parseInt(e.target.value) : '' }))} + /> +
+
+ +
+ +
+ {['pdf', 'docx', 'pptx', 'mp4', 'xlsx'].map((ext) => ( + + ))} +
+
+ +
+ +
+ {FILE_TYPES.map((ft) => { + const limit = form.maxPagesByFileType[ft.value] + const hasLimit = limit !== undefined + return ( +
+ { + setForm((f) => { + const next = { ...f.maxPagesByFileType } + if (checked) next[ft.value] = 10 + else delete next[ft.value] + return { ...f, maxPagesByFileType: next } + }) + }} + /> + {ft.label} + {hasLimit && ( + { + setForm((f) => ({ + ...f, + maxPagesByFileType: { ...f.maxPagesByFileType, [ft.value]: parseInt(e.target.value) || 1 }, + })) + }} + /> + )} + {hasLimit && pages max} +
+ ) + })} +
+
+ +
+ + +
+
+ )} + + {form.ruleType === 'AI_SCREENING' && ( +
+
+ +