Rounds overhaul: full CRUD submission windows, scheduling UI, analytics, design refresh
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s

- Fix special award FK crash: replace 4x raw auditLog.create with logAudit() helper
- Add updateSubmissionWindow + deleteSubmissionWindow mutations to round router
- Add per-round analytics (_count, juryGroup) to competition.getById
- Remove redundant acceptedCategories from intake config
- Rewrite submission window manager with full CRUD, all fields, date pickers
- Add round scheduling card (open/close dates) to round detail page
- Add project count, assignment count, jury group to round list cards
- Visual redesign: pipeline view, brand colors, progress bars, enhanced cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 07:07:09 +01:00
parent 2fb26d4734
commit f572336781
8 changed files with 1208 additions and 414 deletions

View File

@@ -78,6 +78,15 @@ export const competitionRouter = router({
sortOrder: true,
windowOpenAt: true,
windowCloseAt: true,
juryGroup: {
select: { id: true, name: true },
},
_count: {
select: {
projectRoundStates: true,
assignments: true,
},
},
},
},
juryGroups: {
@@ -102,6 +111,10 @@ export const competitionRouter = router({
windowOpenAt: true,
windowCloseAt: true,
isLocked: true,
deadlinePolicy: true,
graceHours: true,
lockOnClose: true,
sortOrder: true,
_count: { select: { fileRequirements: true, projectFiles: true } },
},
},

View File

@@ -293,6 +293,79 @@ export const roundRouter = router({
return window
}),
/**
* Update an existing submission window
*/
updateSubmissionWindow: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(255).optional(),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
roundNumber: z.number().int().min(1).optional(),
windowOpenAt: z.date().nullable().optional(),
windowCloseAt: z.date().nullable().optional(),
deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).optional(),
graceHours: z.number().int().min(0).nullable().optional(),
lockOnClose: z.boolean().optional(),
sortOrder: z.number().int().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const window = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.submissionWindow.update({
where: { id },
data,
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SubmissionWindow',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
})
return window
}),
/**
* Delete a submission window (only if no files uploaded)
*/
deleteSubmissionWindow: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Check if window has uploaded files
const window = await ctx.prisma.submissionWindow.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, name: true, _count: { select: { projectFiles: true } } },
})
if (window._count.projectFiles > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Cannot delete window "${window.name}" — it has ${window._count.projectFiles} uploaded files. Remove files first.`,
})
}
await ctx.prisma.$transaction(async (tx) => {
await tx.submissionWindow.delete({ where: { id: input.id } })
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SubmissionWindow',
entityId: input.id,
detailsJson: { name: window.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Open a submission window
*/

View File

@@ -125,14 +125,15 @@ export const specialAwardRouter = router({
},
})
await tx.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'SpecialAward',
entityId: created.id,
detailsJson: { name: input.name, scoringMode: input.scoringMode } as Prisma.InputJsonValue,
},
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'SpecialAward',
entityId: created.id,
detailsJson: { name: input.name, scoringMode: input.scoringMode },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
@@ -190,13 +191,14 @@ export const specialAwardRouter = router({
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(async (tx) => {
await tx.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SpecialAward',
entityId: input.id,
},
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SpecialAward',
entityId: input.id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
await tx.specialAward.delete({ where: { id: input.id } })
@@ -249,22 +251,23 @@ export const specialAwardRouter = router({
data: updateData,
})
await tx.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_STATUS',
entityType: 'SpecialAward',
entityId: input.id,
detailsJson: {
previousStatus: current.status,
newStatus: input.status,
...(votingStartAtUpdated && {
votingStartAtUpdated: true,
previousVotingStartAt: current.votingStartAt,
newVotingStartAt: now,
}),
} as Prisma.InputJsonValue,
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE_STATUS',
entityType: 'SpecialAward',
entityId: input.id,
detailsJson: {
previousStatus: current.status,
newStatus: input.status,
...(votingStartAtUpdated && {
votingStartAtUpdated: true,
previousVotingStartAt: current.votingStartAt,
newVotingStartAt: now,
}),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
@@ -750,19 +753,20 @@ export const specialAwardRouter = router({
},
})
await tx.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: {
action: 'SET_AWARD_WINNER',
previousWinner: previous.winnerProjectId,
newWinner: input.projectId,
overridden: input.overridden,
} as Prisma.InputJsonValue,
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: {
action: 'SET_AWARD_WINNER',
previousWinner: previous.winnerProjectId,
newWinner: input.projectId,
overridden: input.overridden,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated