feat: award round reordering, assign-to-first-round, and applicant timeline for award tracks
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m22s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 23:42:21 +01:00
parent 1d4e31ddd1
commit daf50831f1
3 changed files with 440 additions and 102 deletions

View File

@@ -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,

View File

@@ -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 }
}),
})