+
+ 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]) => (
+
+
+ {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