diff --git a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx index a3c491e..ab7fe5b 100644 --- a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx +++ b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx @@ -362,7 +362,6 @@ export default function CompetitionDetailPage() {

{round.name}

-

{round.slug}

= { - INTAKE: 'bg-gray-100 text-gray-700', - FILTERING: 'bg-amber-100 text-amber-700', - EVALUATION: 'bg-blue-100 text-blue-700', - SUBMISSION: 'bg-purple-100 text-purple-700', - MENTORING: 'bg-teal-100 text-teal-700', - LIVE_FINAL: 'bg-red-100 text-red-700', - DELIBERATION: 'bg-indigo-100 text-indigo-700', +// -- Status config -- +const roundStatusConfig = { + ROUND_DRAFT: { + label: 'Draft', + bgClass: 'bg-gray-100 text-gray-700', + dotClass: 'bg-gray-500', + description: 'Not yet active. Configure before launching.', + }, + ROUND_ACTIVE: { + label: 'Active', + bgClass: 'bg-emerald-100 text-emerald-700', + dotClass: 'bg-emerald-500 animate-pulse', + description: 'Round is live. Projects can be processed.', + }, + ROUND_CLOSED: { + label: 'Closed', + bgClass: 'bg-blue-100 text-blue-700', + dotClass: 'bg-blue-500', + description: 'No longer accepting changes. Results are final.', + }, + ROUND_ARCHIVED: { + label: 'Archived', + bgClass: 'bg-muted text-muted-foreground', + dotClass: 'bg-muted-foreground', + description: 'Historical record only.', + }, +} as const + +const roundTypeConfig: Record = { + INTAKE: { label: 'Intake', color: 'bg-gray-100 text-gray-700', description: 'Collecting applications' }, + FILTERING: { label: 'Filtering', color: 'bg-amber-100 text-amber-700', description: 'AI + manual screening' }, + EVALUATION: { label: 'Evaluation', color: 'bg-blue-100 text-blue-700', description: 'Jury evaluation & scoring' }, + SUBMISSION: { label: 'Submission', color: 'bg-purple-100 text-purple-700', description: 'Document submission' }, + MENTORING: { label: 'Mentoring', color: 'bg-teal-100 text-teal-700', description: 'Mentor-guided development' }, + LIVE_FINAL: { label: 'Live Final', color: 'bg-red-100 text-red-700', description: 'Live presentations & voting' }, + DELIBERATION: { label: 'Deliberation', color: 'bg-indigo-100 text-indigo-700', description: 'Final jury deliberation' }, } export default function RoundDetailPage() { const params = useParams() + const router = useRouter() const competitionId = params.competitionId as string const roundId = params.roundId as string const [config, setConfig] = useState>({}) const [hasChanges, setHasChanges] = useState(false) + const [activeTab, setActiveTab] = useState('overview') const utils = trpc.useUtils() const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId }) + const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery({ roundId }) + const { data: juryGroups } = trpc.juryGroup.list.useQuery( + { competitionId }, + { enabled: !!competitionId }, + ) - // Update local config when round data changes + // Sync config from server when not dirty if (round && !hasChanges) { const roundConfig = (round.configJson as Record) ?? {} if (JSON.stringify(roundConfig) !== JSON.stringify(config)) { @@ -46,6 +127,7 @@ export default function RoundDetailPage() { } } + // -- Mutations -- const updateMutation = trpc.round.update.useMutation({ onSuccess: () => { utils.round.getById.invalidate({ id: roundId }) @@ -55,30 +137,79 @@ export default function RoundDetailPage() { onError: (err) => toast.error(err.message), }) + const activateMutation = trpc.roundEngine.activate.useMutation({ + onSuccess: () => { + utils.round.getById.invalidate({ id: roundId }) + toast.success('Round activated') + }, + onError: (err) => toast.error(err.message), + }) + + const closeMutation = trpc.roundEngine.close.useMutation({ + onSuccess: () => { + utils.round.getById.invalidate({ id: roundId }) + toast.success('Round closed') + }, + onError: (err) => toast.error(err.message), + }) + + const archiveMutation = trpc.roundEngine.archive.useMutation({ + onSuccess: () => { + utils.round.getById.invalidate({ id: roundId }) + toast.success('Round archived') + }, + onError: (err) => toast.error(err.message), + }) + + const assignJuryMutation = trpc.round.update.useMutation({ + onSuccess: () => { + utils.round.getById.invalidate({ id: roundId }) + toast.success('Jury group updated') + }, + onError: (err) => toast.error(err.message), + }) + + const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending + const handleConfigChange = (newConfig: Record) => { setConfig(newConfig) setHasChanges(true) } const handleSave = () => { - updateMutation.mutate({ - id: roundId, - configJson: config, - }) + updateMutation.mutate({ id: roundId, configJson: config }) } + // -- Computed -- + const projectCount = round?._count?.projectRoundStates ?? 0 + const stateCounts = projectStates?.reduce((acc: Record, ps: any) => { + acc[ps.state] = (acc[ps.state] || 0) + 1 + return acc + }, {} as Record) ?? {} + const juryGroup = round?.juryGroup + const juryMemberCount = juryGroup?.members?.length ?? 0 + + // Determine available tabs based on round type + const isFiltering = round?.roundType === 'FILTERING' + const isEvaluation = round?.roundType === 'EVALUATION' + const hasSubmissionWindows = round?.roundType === 'SUBMISSION' || round?.roundType === 'EVALUATION' || round?.roundType === 'INTAKE' + + // Loading if (isLoading) { return (
-
- - +
+ +
+
+ {[1, 2, 3, 4].map((i) => )} +
- +
) } @@ -88,73 +219,550 @@ export default function RoundDetailPage() {
-

Round Not Found

-

- The requested round does not exist -

+

This round does not exist.

) } + const status = round.status as keyof typeof roundStatusConfig + const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT + const typeCfg = roundTypeConfig[round.roundType] || roundTypeConfig.INTAKE + return (
- {/* Header */} -
+ {/* ===== HEADER ===== */} +
-
-

{round.name}

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

{round.name}

+ + {typeCfg.label} + + {/* Status dropdown */} + + + + + + {status === 'ROUND_DRAFT' && ( + activateMutation.mutate({ roundId })} + disabled={isTransitioning} + > + + Activate Round + + )} + {status === 'ROUND_ACTIVE' && ( + closeMutation.mutate({ roundId })} + disabled={isTransitioning} + > + + Close Round + + )} + {status === 'ROUND_CLOSED' && ( + <> + activateMutation.mutate({ roundId })} + disabled={isTransitioning} + > + + Reactivate Round + + + archiveMutation.mutate({ roundId })} + disabled={isTransitioning} + > + + Archive Round + + + )} + {isTransitioning && ( +
+ + Updating... +
+ )} +
+
-

{round.slug}

+

{typeCfg.description}

-
+ {/* Action buttons */} +
{hasChanges && ( - )} + {(isEvaluation || isFiltering) && ( + + + + )} + + +
- {/* Tabs */} - + {/* ===== STATS BAR ===== */} +
+ + +
+
+ + Projects +
+
+

{projectCount}

+
+ {Object.entries(stateCounts).map(([state, count]) => ( + + {String(count)} {state.toLowerCase().replace('_', ' ')} + + ))} +
+
+
+ + + +
+ + Jury +
+ {juryGroups && juryGroups.length > 0 ? ( + + ) : juryGroup ? ( + <> +

{juryMemberCount}

+

{juryGroup.name}

+ + ) : ( + <> +

+

No jury groups yet

+ + )} +
+
+ + + +
+ + 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

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

{round.advancementRules.length}

+

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

+ + ) : ( + <> +

+

Admin selection

+ + )} +
+
+
+ + {/* ===== TABS ===== */} + - Configuration - Projects - Submission Windows + + + Overview + + + + Projects + + {isFiltering && ( + + + Filtering + + )} + {isEvaluation && ( + + + Assignments + + )} + + + Config + + + + Documents + - {/* Config Tab */} + {/* ===== OVERVIEW TAB ===== */} + + {/* 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 specific */} + {isEvaluation && ( + + + + )} + + {/* View projects */} + +
+
+
+ + {/* Round info */} +
+ + + Round Details + + +
+ Type + {typeCfg.label} +
+
+ Status + {statusCfg.label} +
+
+ Sort Order + {round.sortOrder} +
+ {round.purposeKey && ( +
+ Purpose + {round.purposeKey} +
+ )} +
+ Jury Group + + {juryGroup ? juryGroup.name : '—'} + +
+
+ Opens + + {round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '—'} + +
+
+ Closes + + {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '—'} + +
+
+
+ + + + 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) + const colors: Record = { + PENDING: 'bg-gray-400', + IN_PROGRESS: 'bg-blue-500', + PASSED: 'bg-green-500', + REJECTED: 'bg-red-500', + COMPLETED: 'bg-emerald-500', + WITHDRAWN: 'bg-orange-400', + } + return ( +
+
+ {state.toLowerCase().replace('_', ' ')} + {count} ({pct}%) +
+
+
+
+
+ ) + })} +
+ )} + + +
+ + + {/* ===== PROJECTS TAB ===== */} + + + + + {/* ===== FILTERING TAB ===== */} + {isFiltering && ( + + + + )} + + {/* ===== ASSIGNMENTS TAB (Evaluation rounds) ===== */} + {isEvaluation && ( + + + + )} + + {/* ===== CONFIG TAB ===== */} - {/* Projects Tab */} - - - - - {/* Submission Windows Tab */} + {/* ===== DOCUMENTS TAB ===== */} - +
) } + +// ===== Inline sub-component for evaluation round assignments ===== + +function RoundAssignmentsOverview({ competitionId, roundId }: { competitionId: string; roundId: string }) { + const { data: coverage, isLoading: coverageLoading } = trpc.roundAssignment.coverageReport.useQuery({ + roundId, + requiredReviews: 3, + }) + + const { data: unassigned, isLoading: unassignedLoading } = trpc.roundAssignment.unassignedQueue.useQuery( + { roundId, requiredReviews: 3 }, + ) + + return ( +
+ {/* Coverage stats */} + {coverageLoading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : coverage ? ( +
+ + + Fully Assigned + + + +
{coverage.fullyAssigned || 0}
+

+ of {coverage.totalProjects || 0} projects ({coverage.totalProjects ? ((coverage.fullyAssigned / coverage.totalProjects) * 100).toFixed(0) : 0}%) +

+
+
+ + + Avg Reviews/Project + + + +
{coverage.avgReviewsPerProject?.toFixed(1) || '0'}
+

Target: 3 per project

+
+
+ + + Unassigned + + + +
{coverage.unassigned || 0}
+

Need more assignments

+
+
+
+ ) : null} + + {/* Unassigned queue */} + + +
+
+ Unassigned Projects + Projects with fewer than 3 jury assignments +
+ + + +
+
+ + {unassignedLoading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : unassigned && unassigned.length > 0 ? ( +
+ {unassigned.map((project: any) => ( +
+
+

{project.title}

+

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

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

+ All projects have sufficient assignments +

+ )} +
+
+
+ ) +} diff --git a/src/components/admin/round/file-requirements-editor.tsx b/src/components/admin/round/file-requirements-editor.tsx new file mode 100644 index 0000000..2e08d1a --- /dev/null +++ b/src/components/admin/round/file-requirements-editor.tsx @@ -0,0 +1,399 @@ +'use client' + +import { useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Checkbox } from '@/components/ui/checkbox' +import { Skeleton } from '@/components/ui/skeleton' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { + Loader2, + Plus, + Pencil, + Trash2, + FileText, + GripVertical, + FileCheck, + FileQuestion, +} from 'lucide-react' + +type FileRequirementsEditorProps = { + roundId: string + windowOpenAt?: Date | string | null + windowCloseAt?: Date | string | null +} + +type FormState = { + name: string + description: string + acceptedMimeTypes: string + maxSizeMB: string + isRequired: boolean +} + +const emptyForm: FormState = { + name: '', + description: '', + acceptedMimeTypes: '', + maxSizeMB: '', + isRequired: true, +} + +const COMMON_MIME_PRESETS: { label: string; value: string }[] = [ + { label: 'PDF only', value: 'application/pdf' }, + { label: 'Images', value: 'image/png, image/jpeg, image/webp' }, + { label: 'Video', value: 'video/mp4, video/quicktime, video/webm' }, + { label: 'Documents', value: 'application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + { label: 'Spreadsheets', value: 'application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, text/csv' }, + { label: 'Presentations', value: 'application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation' }, + { label: 'Any file', value: '' }, +] + +export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }: FileRequirementsEditorProps) { + const [dialogOpen, setDialogOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState(emptyForm) + + const utils = trpc.useUtils() + + const { data: requirements, isLoading } = trpc.file.listRequirements.useQuery({ roundId }) + + const createMutation = trpc.file.createRequirement.useMutation({ + onSuccess: () => { + utils.file.listRequirements.invalidate({ roundId }) + toast.success('Requirement added') + closeDialog() + }, + onError: (err) => toast.error(err.message), + }) + + const updateMutation = trpc.file.updateRequirement.useMutation({ + onSuccess: () => { + utils.file.listRequirements.invalidate({ roundId }) + toast.success('Requirement updated') + closeDialog() + }, + onError: (err) => toast.error(err.message), + }) + + const deleteMutation = trpc.file.deleteRequirement.useMutation({ + onSuccess: () => { + utils.file.listRequirements.invalidate({ roundId }) + toast.success('Requirement removed') + }, + onError: (err) => toast.error(err.message), + }) + + const closeDialog = () => { + setDialogOpen(false) + setEditingId(null) + setForm(emptyForm) + } + + const openCreateDialog = () => { + setForm(emptyForm) + setEditingId(null) + setDialogOpen(true) + } + + const openEditDialog = (req: any) => { + setForm({ + name: req.name, + description: req.description || '', + acceptedMimeTypes: (req.acceptedMimeTypes || []).join(', '), + maxSizeMB: req.maxSizeMB?.toString() || '', + isRequired: req.isRequired ?? true, + }) + setEditingId(req.id) + setDialogOpen(true) + } + + const handleSubmit = () => { + const mimeTypes = form.acceptedMimeTypes + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + const maxSize = form.maxSizeMB ? parseInt(form.maxSizeMB, 10) : undefined + + if (editingId) { + updateMutation.mutate({ + id: editingId, + name: form.name, + description: form.description || null, + acceptedMimeTypes: mimeTypes, + maxSizeMB: maxSize ?? null, + isRequired: form.isRequired, + }) + } else { + createMutation.mutate({ + roundId, + name: form.name, + description: form.description || undefined, + acceptedMimeTypes: mimeTypes, + maxSizeMB: maxSize, + isRequired: form.isRequired, + sortOrder: (requirements?.length ?? 0), + }) + } + } + + const isSaving = createMutation.isPending || updateMutation.isPending + + if (isLoading) { + return ( +
+ + {[1, 2, 3].map((i) => )} +
+ ) + } + + return ( +
+ {/* Submission period info */} + + +
+
+

Submission Period

+

+ Applicants can upload documents during the round's active window +

+
+
+ {windowOpenAt || windowCloseAt ? ( + <> +

+ {windowOpenAt ? new Date(windowOpenAt).toLocaleDateString() : 'No start'} —{' '} + {windowCloseAt ? new Date(windowCloseAt).toLocaleDateString() : 'No deadline'} +

+

+ Set in the Config tab under round time windows +

+ + ) : ( +

No dates configured — set in Config tab

+ )} +
+
+
+
+ + {/* Requirements list */} + + +
+
+ Required Documents + + Define what files applicants must submit for this round + +
+ +
+
+ + {!requirements || requirements.length === 0 ? ( +
+
+ +
+

No Document Requirements

+

+ Add requirements to specify what documents applicants must upload during this round. +

+ +
+ ) : ( +
+ {requirements.map((req: any) => ( +
+
+ {req.isRequired ? ( + + ) : ( + + )} +
+
+
+

{req.name}

+ + {req.isRequired ? 'Required' : 'Optional'} + +
+ {req.description && ( +

{req.description}

+ )} +
+ {req.acceptedMimeTypes?.length > 0 ? ( + + {req.acceptedMimeTypes.map((t: string) => { + if (t === 'application/pdf') return 'PDF' + if (t.startsWith('image/')) return t.replace('image/', '').toUpperCase() + if (t.startsWith('video/')) return t.replace('video/', '').toUpperCase() + return t.split('/').pop()?.toUpperCase() || t + }).join(', ')} + + ) : ( + + Any file type + + )} + {req.maxSizeMB && ( + + Max {req.maxSizeMB} MB + + )} +
+
+
+ + + + + + + + Delete requirement? + + This will remove "{req.name}" from the round. Previously uploaded files will not be deleted. + + + + Cancel + deleteMutation.mutate({ id: req.id })} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + +
+
+ ))} +
+ )} +
+
+ + {/* Create / Edit Dialog */} + { if (!open) closeDialog() }}> + + + {editingId ? 'Edit Requirement' : 'Add Document Requirement'} + + {editingId + ? 'Update the document requirement details.' + : 'Define a new document that applicants must submit.'} + + +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+
+ +