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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user