From daf50831f13284a8cb6007bed8f27adee99f62f3 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 3 Mar 2026 23:42:21 +0100 Subject: [PATCH] feat: award round reordering, assign-to-first-round, and applicant timeline for award tracks - Add drag-and-drop round reordering on award detail Rounds tab (dnd-kit) - Replace "Open Voting" with "Assign to First Round" for SEPARATE_POOL awards - Add reorderAwardRounds mutation (two-phase transaction for unique constraint) - Add assignToFirstRound mutation (re-runnable, moves/creates ProjectRoundState) - Extend applicant timeline to show award-specific rounds for SEPARATE_POOL projects - Hide irrelevant main competition rounds when project is in award track - Prefix award round labels with award name in timeline Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/awards/[id]/page.tsx | 351 +++++++++++++++------ src/server/routers/applicant.ts | 23 +- src/server/routers/specialAward.ts | 168 ++++++++++ 3 files changed, 440 insertions(+), 102 deletions(-) diff --git a/src/app/(admin)/admin/awards/[id]/page.tsx b/src/app/(admin)/admin/awards/[id]/page.tsx index be4ec05..69a524e 100644 --- a/src/app/(admin)/admin/awards/[id]/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/page.tsx @@ -93,7 +93,26 @@ import { Layers, Info, Mail, + GripVertical, + ArrowRight, } from 'lucide-react' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' const STATUS_COLORS: Record = { DRAFT: 'secondary', @@ -116,6 +135,199 @@ function getStepIndex(status: string): number { return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0) } +const ROUND_TYPE_COLORS: Record = { + EVALUATION: 'bg-violet-100 text-violet-700', + FILTERING: 'bg-amber-100 text-amber-700', + SUBMISSION: 'bg-blue-100 text-blue-700', + MENTORING: 'bg-teal-100 text-teal-700', + LIVE_FINAL: 'bg-rose-100 text-rose-700', + DELIBERATION: 'bg-indigo-100 text-indigo-700', +} +const ROUND_STATUS_COLORS: Record = { + DRAFT: 'bg-gray-100 text-gray-600', + ACTIVE: 'bg-emerald-100 text-emerald-700', + CLOSED: 'bg-blue-100 text-blue-700', + ARCHIVED: 'bg-muted text-muted-foreground', +} + +function SortableRoundCard({ + round, + index, + isFirst, + onDelete, + isDeleting, +}: { + round: any + index: number + isFirst: boolean + onDelete: (roundId: string) => void + isDeleting: boolean +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: round.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + const projectCount = round._count?.projectRoundStates ?? 0 + const assignmentCount = round._count?.assignments ?? 0 + const statusLabel = round.status.replace('ROUND_', '') + + return ( + + +
+ +
+ {index + 1} +
+
+ + {round.name} + +
+ + {round.roundType.replace('_', ' ')} + + + {statusLabel} + + {isFirst && ( + + Entry point + + )} +
+
+
+ +
+
+ + {projectCount} project{projectCount !== 1 ? 's' : ''} +
+ {assignmentCount > 0 && ( +
+ + {assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''} +
+ )} +
+ + {round.status === 'ROUND_DRAFT' && ( +
+ + + + + + + Delete Round + + This will permanently delete "{round.name}". This cannot be undone. + + + + Cancel + onDelete(round.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + +
+ )} +
+
+ ) +} + +function RoundsDndGrid({ + rounds, + awardId, + onReorder, + onDelete, + isDeleting, +}: { + rounds: any[] + awardId: string + onReorder: (roundIds: string[]) => void + onDelete: (roundId: string) => void + isDeleting: boolean +}) { + const [items, setItems] = useState(rounds.map((r: any) => r.id)) + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + // Sync if server data changes + useEffect(() => { + setItems(rounds.map((r: any) => r.id)) + }, [rounds]) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = items.indexOf(active.id as string) + const newIndex = items.indexOf(over.id as string) + const newItems = arrayMove(items, oldIndex, newIndex) + setItems(newItems) + onReorder(newItems) + } + + const roundMap = new Map(rounds.map((r: any) => [r.id, r])) + + return ( + + +
+ {items.map((id, index) => { + const round = roundMap.get(id) + if (!round) return null + return ( + + ) + })} +
+
+
+ ) +} + function ConfidenceBadge({ confidence }: { confidence: number }) { if (confidence > 0.8) { return ( @@ -286,6 +498,18 @@ export default function AwardDetailPage({ }, onError: (err) => toast.error(err.message), }) + const reorderRounds = trpc.specialAward.reorderAwardRounds.useMutation({ + onSuccess: () => refetchRounds(), + onError: (err) => toast.error(err.message), + }) + const assignToFirstRound = trpc.specialAward.assignToFirstRound.useMutation({ + onSuccess: (result) => { + toast.success(`Assigned ${result.totalAssigned} projects to first round (${result.createdCount} new, ${result.movedCount} moved)`) + refetchRounds() + refetch() + }, + onError: (err) => toast.error(err.message), + }) const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery( { awardId, customMessage: notifyCustomMessage }, @@ -502,13 +726,26 @@ export default function AwardDetailPage({ isSending={notifyEligible.isPending} onRefreshPreview={(msg) => setNotifyCustomMessage(msg)} /> - + {award.eligibilityMode === 'SEPARATE_POOL' ? ( + + ) : ( + + )} )} {award.status === 'VOTING_OPEN' && ( @@ -1243,99 +1480,13 @@ export default function AwardDetailPage({ ) : ( -
- {awardRounds.map((round: any, index: number) => { - const projectCount = round._count?.projectRoundStates ?? 0 - const assignmentCount = round._count?.assignments ?? 0 - const statusLabel = round.status.replace('ROUND_', '') - const statusColors: Record = { - DRAFT: 'bg-gray-100 text-gray-600', - ACTIVE: 'bg-emerald-100 text-emerald-700', - CLOSED: 'bg-blue-100 text-blue-700', - ARCHIVED: 'bg-muted text-muted-foreground', - } - const roundTypeColors: Record = { - EVALUATION: 'bg-violet-100 text-violet-700', - FILTERING: 'bg-amber-100 text-amber-700', - SUBMISSION: 'bg-blue-100 text-blue-700', - MENTORING: 'bg-teal-100 text-teal-700', - LIVE_FINAL: 'bg-rose-100 text-rose-700', - DELIBERATION: 'bg-indigo-100 text-indigo-700', - } - return ( - - -
-
- {index + 1} -
-
- - {round.name} - -
- - {round.roundType.replace('_', ' ')} - - - {statusLabel} - - {index === 0 && ( - - Entry point - - )} -
-
-
- -
-
- - {projectCount} project{projectCount !== 1 ? 's' : ''} -
- {assignmentCount > 0 && ( -
- - {assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''} -
- )} -
- - {round.status === 'ROUND_DRAFT' && ( -
- - - - - - - Delete Round - - This will permanently delete "{round.name}". This cannot be undone. - - - - Cancel - deleteRound.mutate({ roundId: round.id })} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Delete - - - - -
- )} -
-
- ) - })} -
+ reorderRounds.mutate({ awardId, roundIds })} + onDelete={(roundId) => deleteRound.mutate({ roundId })} + isDeleting={deleteRound.isPending} + /> )} diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 3ce2850..e73f341 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -1443,7 +1443,7 @@ export const applicantRouter = router({ return { competitionName: null, entries: [] } } - // Get all rounds ordered by sortOrder + // Get all rounds ordered by sortOrder (including award rounds in same competition) const rounds = await ctx.prisma.round.findMany({ where: { competitionId: competition.id }, orderBy: { sortOrder: 'asc' }, @@ -1454,6 +1454,8 @@ export const applicantRouter = router({ status: true, windowOpenAt: true, windowCloseAt: true, + specialAwardId: true, + specialAward: { select: { name: true } }, }, }) @@ -1482,12 +1484,29 @@ export const applicantRouter = router({ const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL') const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION') + // Check if this project is in any SEPARATE_POOL award track + const projectAwardRoundIds = new Set( + rounds.filter((r) => r.specialAwardId && stateMap.has(r.id)).map((r) => r.id) + ) + const projectAwardIds = new Set( + rounds.filter((r) => r.specialAwardId && stateMap.has(r.id)).map((r) => r.specialAwardId!) + ) + const isInAwardTrack = projectAwardRoundIds.size > 0 + // Process visible rounds: hide FILTERING, LIVE_FINAL, DELIBERATION always. // Also hide MENTORING unless the project is actually participating in it. + // For award rounds: only show ones the project is in. For main rounds after + // the split point: hide if project isn't in them and is in an award track. const visibleRounds = rounds.filter( (r) => { if (r.roundType === 'FILTERING' || r.roundType === 'LIVE_FINAL' || r.roundType === 'DELIBERATION') return false if (r.roundType === 'MENTORING' && !stateMap.has(r.id)) return false + // Award round that project is NOT in → hide + if (r.specialAwardId && !stateMap.has(r.id)) return false + // Award round for a different award → hide + if (r.specialAwardId && !projectAwardIds.has(r.specialAwardId)) return false + // Main competition round where project has no state AND project is in award track → hide + if (!r.specialAwardId && isInAwardTrack && !stateMap.has(r.id)) return false return true } ) @@ -1520,7 +1539,7 @@ export const applicantRouter = router({ entries.push({ id: round.id, - label: round.name, + label: round.specialAward ? `${round.specialAward.name}: ${round.name}` : round.name, roundType: round.roundType, status: round.status, windowOpenAt: round.windowOpenAt, diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index 85a6649..75dfb7b 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -1419,4 +1419,172 @@ export const specialAwardRouter = router({ detailsJson: { awardId: round.specialAwardId }, }) }), + + /** + * Reorder award rounds via drag-and-drop. + * Uses a two-phase transaction: first set all to negative temps (avoid unique constraint), + * then set to final values. + */ + reorderAwardRounds: adminProcedure + .input(z.object({ + awardId: z.string(), + roundIds: z.array(z.string()).min(1), + })) + .mutation(async ({ ctx, input }) => { + const existingRounds = await ctx.prisma.round.findMany({ + where: { specialAwardId: input.awardId }, + select: { id: true, competitionId: true, sortOrder: true }, + orderBy: { sortOrder: 'asc' }, + }) + + if (existingRounds.length !== input.roundIds.length) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Round list does not match existing award rounds', + }) + } + + const existingIds = new Set(existingRounds.map((r) => r.id)) + for (const id of input.roundIds) { + if (!existingIds.has(id)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Round ${id} does not belong to this award`, + }) + } + } + + // Collect the existing sortOrder values (in ascending order) and reassign them + // to the new ordering. This keeps the same sortOrder slots, just remapped. + const sortSlots = existingRounds.map((r) => r.sortOrder).sort((a, b) => a - b) + const competitionId = existingRounds[0].competitionId + + await ctx.prisma.$transaction(async (tx) => { + // Phase 1: set all to negative temps to avoid unique constraint + for (let i = 0; i < existingRounds.length; i++) { + await tx.round.update({ + where: { id: existingRounds[i].id }, + data: { sortOrder: -(i + 1000) }, + }) + } + + // Phase 2: assign final sort orders based on new ordering + for (let i = 0; i < input.roundIds.length; i++) { + await tx.round.update({ + where: { id: input.roundIds[i] }, + data: { sortOrder: sortSlots[i] }, + }) + } + }) + + await logAudit({ + userId: ctx.user.id, + action: 'UPDATE', + entityType: 'SpecialAward', + entityId: input.awardId, + detailsJson: { action: 'REORDER_ROUNDS', newOrder: input.roundIds }, + }) + }), + + /** + * Assign (or reassign) eligible projects to the first award round. + * Re-runnable: moves existing ProjectRoundState entries from other award rounds + * to the first, and creates new PENDING entries for unassigned projects. + */ + assignToFirstRound: adminProcedure + .input(z.object({ awardId: z.string() })) + .mutation(async ({ ctx, input }) => { + const award = await ctx.prisma.specialAward.findUniqueOrThrow({ + where: { id: input.awardId }, + select: { eligibilityMode: true, name: true }, + }) + + if (award.eligibilityMode !== 'SEPARATE_POOL') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Assign to first round is only available for Separate Pool awards', + }) + } + + const awardRounds = await ctx.prisma.round.findMany({ + where: { specialAwardId: input.awardId }, + select: { id: true }, + orderBy: { sortOrder: 'asc' }, + }) + + if (awardRounds.length === 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Create at least one round before assigning projects', + }) + } + + const firstRound = awardRounds[0] + const otherRoundIds = awardRounds.slice(1).map((r) => r.id) + + // Get all eligible projects (confirmed or not — any eligible project) + const eligible = await ctx.prisma.awardEligibility.findMany({ + where: { awardId: input.awardId, eligible: true }, + select: { projectId: true }, + }) + + if (eligible.length === 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'No eligible projects to assign', + }) + } + + const projectIds = eligible.map((e) => e.projectId) + + // Move existing entries from other award rounds to the first round + let movedCount = 0 + if (otherRoundIds.length > 0) { + const moved = await ctx.prisma.projectRoundState.updateMany({ + where: { + roundId: { in: otherRoundIds }, + projectId: { in: projectIds }, + }, + data: { roundId: firstRound.id, state: 'PENDING' }, + }) + movedCount = moved.count + } + + // Create PENDING entries for projects not yet in the first round + const existing = await ctx.prisma.projectRoundState.findMany({ + where: { roundId: firstRound.id, projectId: { in: projectIds } }, + select: { projectId: true }, + }) + const existingSet = new Set(existing.map((e) => e.projectId)) + const newProjectIds = projectIds.filter((id) => !existingSet.has(id)) + + let createdCount = 0 + if (newProjectIds.length > 0) { + await ctx.prisma.projectRoundState.createMany({ + data: newProjectIds.map((projectId) => ({ + projectId, + roundId: firstRound.id, + state: 'PENDING' as const, + })), + skipDuplicates: true, + }) + createdCount = newProjectIds.length + } + + await logAudit({ + userId: ctx.user.id, + action: 'UPDATE', + entityType: 'SpecialAward', + entityId: input.awardId, + detailsJson: { + action: 'ASSIGN_TO_FIRST_ROUND', + firstRoundId: firstRound.id, + movedCount, + createdCount, + totalEligible: projectIds.length, + }, + }) + + return { movedCount, createdCount, totalAssigned: existingSet.size + createdCount } + }), })