diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index b697727..7b72a2d 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -92,6 +92,8 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor' import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard' import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview' +import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card' +import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card' import { RankingDashboard } from '@/components/admin/round/ranking-dashboard' import { CoverageReport } from '@/components/admin/assignment/coverage-report' import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet' @@ -583,6 +585,7 @@ export default function RoundDetailPage() { const isFiltering = round?.roundType === 'FILTERING' const isEvaluation = round?.roundType === 'EVALUATION' const isMentoring = round?.roundType === 'MENTORING' + const isGrandFinale = round?.roundType === 'LIVE_FINAL' // Mentor pool size — used by Round Details panel below to replace the // always-empty "Jury Group" row on MENTORING rounds. @@ -1481,6 +1484,14 @@ export default function RoundDetailPage() { {/* Mentoring-specific stats \u2014 only on MENTORING rounds */} {isMentoring && } + {/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */} + {isGrandFinale && programId && ( +
+ + +
+ )} + {/* Round Info + Project Breakdown */}
diff --git a/src/components/admin/grand-finale/finalist-slots-card.tsx b/src/components/admin/grand-finale/finalist-slots-card.tsx new file mode 100644 index 0000000..3c0e503 --- /dev/null +++ b/src/components/admin/grand-finale/finalist-slots-card.tsx @@ -0,0 +1,162 @@ +'use client' + +import { useEffect, useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Loader2, Save, Trophy } from 'lucide-react' +import { formatEnumLabel } from '@/lib/utils' +import type { CompetitionCategory } from '@prisma/client' + +interface Props { + programId: string +} + +const CATEGORIES: CompetitionCategory[] = ['STARTUP', 'BUSINESS_CONCEPT'] + +type Row = { + category: CompetitionCategory + quota: number + confirmed: number + pending: number +} + +export function FinalistSlotsCard({ programId }: Props) { + const utils = trpc.useUtils() + const { data: quotas, isLoading: loadingQuotas } = trpc.finalist.listQuotas.useQuery({ + programId, + }) + const { data: counts, isLoading: loadingCounts } = trpc.finalist.listCategoryCounts.useQuery({ + programId, + }) + + const [draft, setDraft] = useState>({ + STARTUP: '', + BUSINESS_CONCEPT: '', + }) + + // Sync draft from server response on first load / after save + useEffect(() => { + if (!quotas) return + const next: Record = { STARTUP: '', BUSINESS_CONCEPT: '' } + for (const cat of CATEGORIES) { + const found = quotas.find((q) => q.category === cat) + next[cat] = found ? String(found.quota) : '' + } + setDraft(next) + }, [quotas]) + + const setQuotaMutation = trpc.finalist.setQuota.useMutation({ + onSuccess: (_, vars) => { + toast.success(`${formatEnumLabel(vars.category)} quota saved`) + utils.finalist.listQuotas.invalidate({ programId }) + utils.finalist.listCategoryCounts.invalidate({ programId }) + }, + onError: (err) => toast.error(err.message), + }) + + if (loadingQuotas || loadingCounts) { + return + } + + const rows: Row[] = CATEGORIES.map((cat) => { + const q = quotas?.find((x) => x.category === cat) + const c = counts?.find((x) => x.category === cat) + return { + category: cat, + quota: q?.quota ?? 0, + confirmed: c?.confirmed ?? 0, + pending: c?.pending ?? 0, + } + }) + + const handleSave = (category: CompetitionCategory) => { + const raw = draft[category] + const n = Number.parseInt(raw, 10) + if (Number.isNaN(n) || n < 0) { + toast.error('Quota must be a non-negative integer') + return + } + setQuotaMutation.mutate({ programId, category, quota: n }) + } + + return ( + + +
+ + Finalist slots +
+ + Per-category quotas. Reductions blocked when {`> `}confirmed count — un-confirm a team + first if you need to shrink a category. + +
+ +
+ {rows.map((r) => { + const isPending = + setQuotaMutation.isPending && + setQuotaMutation.variables?.category === r.category + const dirty = String(r.quota) !== draft[r.category] + return ( +
+
+
{formatEnumLabel(r.category)}
+
+ + + {r.confirmed} + {' '} + confirmed + + + + {r.pending} + {' '} + pending + +
+
+
+ + setDraft((d) => ({ ...d, [r.category]: e.target.value })) + } + /> + +
+
+ ) + })} +
+
+
+ ) +} diff --git a/src/components/admin/grand-finale/waitlist-card.tsx b/src/components/admin/grand-finale/waitlist-card.tsx new file mode 100644 index 0000000..e41eccc --- /dev/null +++ b/src/components/admin/grand-finale/waitlist-card.tsx @@ -0,0 +1,167 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' +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 { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { ListOrdered, Loader2 } from 'lucide-react' +import { formatEnumLabel } from '@/lib/utils' +import type { CompetitionCategory } from '@prisma/client' + +interface Props { + programId: string +} + +const STATUS_LABEL: Record = { + WAITING: { label: 'Waiting', variant: 'outline' }, + PROMOTED: { label: 'Promoted', variant: 'default' }, + USED: { label: 'Used', variant: 'secondary' }, +} + +export function WaitlistCard({ programId }: Props) { + const utils = trpc.useUtils() + const { data, isLoading } = trpc.finalist.listWaitlist.useQuery({ programId }) + + const promoteMutation = trpc.finalist.manualPromote.useMutation({ + onSuccess: () => { + toast.success('Waitlist entry promoted — confirmation email sent') + utils.finalist.listWaitlist.invalidate({ programId }) + utils.finalist.listCategoryCounts.invalidate({ programId }) + }, + onError: (err) => toast.error(err.message), + }) + + if (isLoading) return + + const byCategory = new Map() + for (const entry of data ?? []) { + const list = byCategory.get(entry.category) ?? [] + list.push(entry) + byCategory.set(entry.category, list) + } + + if (!data || data.length === 0) { + return ( + + +
+ + Waitlist +
+ + Per-category ranked waitlist. Auto-cascades when a finalist declines or expires. + +
+ +

+ No waitlist entries yet. +

+
+
+ ) + } + + return ( + + +
+ + Waitlist +
+ + Per-category ranked waitlist. Auto-cascades when a finalist declines or expires. You can + manually promote out of order — overrides are audit-logged. + +
+ + {Array.from(byCategory.entries()).map(([category, entries]) => ( +
+
+ {formatEnumLabel(category)} +
+
+ {(entries ?? []).map((entry) => { + const badge = STATUS_LABEL[entry.status] ?? { label: entry.status, variant: 'outline' as const } + const canPromote = entry.status === 'WAITING' + const isPending = + promoteMutation.isPending && promoteMutation.variables?.waitlistEntryId === entry.id + return ( +
+
+
+ {entry.rank} +
+
+
{entry.project.title}
+
+ {entry.project.country ?? '—'} +
+
+
+
+ + {badge.label} + + {canPromote && ( + + + + + + + Promote this team out of order? + + {entry.project.title} (rank #{entry.rank}) will be promoted into a + finalist slot. A confirmation email will be sent to the team lead + with a 24-hour window. This override is audit-logged. + + + + Cancel + + promoteMutation.mutate({ + waitlistEntryId: entry.id, + windowHours: 24, + }) + } + > + Promote + + + + + )} +
+
+ ) + })} +
+
+ ))} +
+
+ ) +} diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 26f3b4a..02aeae6 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -11,6 +11,56 @@ import { sendFinalistConfirmationEmail } from '@/lib/email' import { verifyFinalistToken } from '@/lib/finalist-token' export const finalistRouter = router({ + /** List all per-category finalist slot quotas for a program. */ + listQuotas: adminProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.finalistSlotQuota.findMany({ + where: { programId: input.programId }, + orderBy: { category: 'asc' }, + }) + }), + + /** + * Aggregate counts of confirmations per category for a program. Used by the + * admin slot card to show "X confirmed / Y pending" alongside the quota + * editor. + */ + listCategoryCounts: adminProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + const grouped = await ctx.prisma.finalistConfirmation.groupBy({ + by: ['category', 'status'], + where: { project: { programId: input.programId } }, + _count: { _all: true }, + }) + const byCategory = new Map() + for (const g of grouped) { + const slot = byCategory.get(g.category) ?? { confirmed: 0, pending: 0 } + if (g.status === 'CONFIRMED') slot.confirmed = g._count._all + if (g.status === 'PENDING') slot.pending = g._count._all + byCategory.set(g.category, slot) + } + return Array.from(byCategory.entries()).map(([category, counts]) => ({ + category: category as CompetitionCategory, + confirmed: counts.confirmed, + pending: counts.pending, + })) + }), + + /** List the per-category waitlist for a program (rank-ordered). */ + listWaitlist: adminProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.waitlistEntry.findMany({ + where: { programId: input.programId }, + orderBy: [{ category: 'asc' }, { rank: 'asc' }], + include: { + project: { select: { id: true, title: true, country: true } }, + }, + }) + }), + /** * Set the finalist slot quota for a category in a program. Mutable mid-flight, * but blocked when reducing below the count of already-CONFIRMED finalists in