feat: admin UI for finalist slot quotas + waitlist on grand-finale round

- New components/admin/grand-finale/finalist-slots-card: per-category
  quota editor with confirmed/pending counts, dirty-tracking, save button.
  Renders an empty editor for both Startup and Business Concept categories
  even when no quota exists yet.
- New components/admin/grand-finale/waitlist-card: per-category ranked
  waitlist display with status badges + manual-promote AlertDialog
  (audit-logged via FINALIST_MANUAL_PROMOTE).
- Round detail page: embeds both cards conditionally when
  roundType === 'LIVE_FINAL'.
- New finalist router queries: listQuotas, listCategoryCounts (groupBy
  on category+status), listWaitlist (rank-ordered with project relation).

Smoke-tested: setting Startup quota to 3 persists to DB; UI renders
quota editor and waitlist card cleanly with empty state.
This commit is contained in:
Matt
2026-04-28 18:07:55 +02:00
parent 437bed2326
commit 95055e0dae
4 changed files with 390 additions and 0 deletions

View File

@@ -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<string, { confirmed: number; pending: number }>()
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