diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 2e50c97..d3efe23 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -76,6 +76,7 @@ import { Plus, Trash2, ArrowRight, + RotateCcw, X, } from 'lucide-react' import { RoundConfigForm } from '@/components/admin/competition/round-config-form' @@ -159,22 +160,22 @@ export default function RoundDetailPage() { // ── Core data queries ────────────────────────────────────────────────── const { data: round, isLoading } = trpc.round.getById.useQuery( { id: roundId }, - { refetchInterval: 30_000 }, + { refetchInterval: 15_000, refetchOnWindowFocus: true }, ) const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery( { roundId }, - { refetchInterval: 15_000 }, + { refetchInterval: 10_000, refetchOnWindowFocus: true }, ) const competitionId = round?.competitionId ?? '' const { data: juryGroups } = trpc.juryGroup.list.useQuery( { competitionId }, - { enabled: !!competitionId, refetchInterval: 30_000 }, + { enabled: !!competitionId, refetchInterval: 30_000, refetchOnWindowFocus: true }, ) const { data: fileRequirements } = trpc.file.listRequirements.useQuery( { roundId }, - { refetchInterval: 30_000 }, + { refetchInterval: 15_000, refetchOnWindowFocus: true }, ) // Fetch awards linked to this round @@ -223,6 +224,18 @@ export default function RoundDetailPage() { onError: (err) => toast.error(err.message), }) + const reopenMutation = trpc.roundEngine.reopen.useMutation({ + onSuccess: (data) => { + utils.round.getById.invalidate({ id: roundId }) + utils.roundEngine.getProjectStates.invalidate({ roundId }) + const msg = data.pausedRounds?.length + ? `Round reopened. Paused: ${data.pausedRounds.join(', ')}` + : 'Round reopened' + toast.success(msg) + }, + onError: (err) => toast.error(err.message), + }) + const archiveMutation = trpc.roundEngine.archive.useMutation({ onSuccess: () => { utils.round.getById.invalidate({ id: roundId }) @@ -268,7 +281,7 @@ export default function RoundDetailPage() { }, }) - const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending + const isTransitioning = activateMutation.isPending || closeMutation.isPending || reopenMutation.isPending || archiveMutation.isPending const handleConfigChange = useCallback((newConfig: Record) => { setConfig(newConfig) @@ -437,11 +450,11 @@ export default function RoundDetailPage() { {status === 'ROUND_CLOSED' && ( <> activateMutation.mutate({ roundId })} + onClick={() => reopenMutation.mutate({ roundId })} disabled={isTransitioning} > - Reactivate Round + Reopen Round )} + {status === 'ROUND_CLOSED' && ( + + + + + + + Reopen this round? + + The round will become active again. Any rounds after this one that are currently active will be paused (closed) automatically. + + + + Cancel + reopenMutation.mutate({ roundId })}> + Reopen + + + + + )} + {/* Assign projects */} + {opt.label} + ))} +

+ Select one or more file types. Leave empty to accept any file type. +

diff --git a/src/server/routers/roundEngine.ts b/src/server/routers/roundEngine.ts index 4787bef..5851cf2 100644 --- a/src/server/routers/roundEngine.ts +++ b/src/server/routers/roundEngine.ts @@ -5,6 +5,7 @@ import { activateRound, closeRound, archiveRound, + reopenRound, transitionProject, batchTransitionProjects, getProjectRoundStates, @@ -53,6 +54,23 @@ export const roundEngineRouter = router({ return result }), + /** + * Reopen a round: ROUND_CLOSED → ROUND_ACTIVE + * Pauses any subsequent active rounds in the same competition. + */ + reopen: adminProcedure + .input(z.object({ roundId: z.string() })) + .mutation(async ({ ctx, input }) => { + const result = await reopenRound(input.roundId, ctx.user.id, ctx.prisma) + if (!result.success) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: result.errors?.join('; ') ?? 'Failed to reopen round', + }) + } + return result + }), + /** * Archive a round: ROUND_CLOSED → ROUND_ARCHIVED */ diff --git a/src/server/services/round-engine.ts b/src/server/services/round-engine.ts index 63a6609..7809c7c 100644 --- a/src/server/services/round-engine.ts +++ b/src/server/services/round-engine.ts @@ -50,7 +50,7 @@ const BATCH_SIZE = 50 const VALID_ROUND_TRANSITIONS: Record = { ROUND_DRAFT: ['ROUND_ACTIVE'], ROUND_ACTIVE: ['ROUND_CLOSED'], - ROUND_CLOSED: ['ROUND_ARCHIVED'], + ROUND_CLOSED: ['ROUND_ACTIVE', 'ROUND_ARCHIVED'], ROUND_ARCHIVED: [], } @@ -321,6 +321,129 @@ export async function archiveRound( } } +/** + * Reopen a round: ROUND_CLOSED → ROUND_ACTIVE + * Side effects: any subsequent rounds in the same competition that are + * ROUND_ACTIVE will be paused (set to ROUND_CLOSED) to prevent two + * active rounds overlapping. + */ +export async function reopenRound( + roundId: string, + actorId: string, + prisma: PrismaClient | any, +): Promise { + try { + const round = await prisma.round.findUnique({ + where: { id: roundId }, + include: { competition: true }, + }) + + if (!round) { + return { success: false, errors: [`Round ${roundId} not found`] } + } + + if (round.status !== 'ROUND_CLOSED') { + return { + success: false, + errors: [`Cannot reopen round: current status is ${round.status}, expected ROUND_CLOSED`], + } + } + + const result = await prisma.$transaction(async (tx: any) => { + // Pause any subsequent active rounds in the same competition + const subsequentActiveRounds = await tx.round.findMany({ + where: { + competitionId: round.competitionId, + sortOrder: { gt: round.sortOrder }, + status: 'ROUND_ACTIVE', + }, + select: { id: true, name: true }, + }) + + if (subsequentActiveRounds.length > 0) { + await tx.round.updateMany({ + where: { id: { in: subsequentActiveRounds.map((r: any) => r.id) } }, + data: { status: 'ROUND_CLOSED' }, + }) + + // Audit each paused round + for (const paused of subsequentActiveRounds) { + await tx.decisionAuditLog.create({ + data: { + eventType: 'round.paused', + entityType: 'Round', + entityId: paused.id, + actorId, + detailsJson: { + roundName: paused.name, + reason: `Paused because prior round "${round.name}" was reopened`, + previousStatus: 'ROUND_ACTIVE', + }, + snapshotJson: { + timestamp: new Date().toISOString(), + emittedBy: 'round-engine', + }, + }, + }) + } + } + + // Reopen this round + const updated = await tx.round.update({ + where: { id: roundId }, + data: { status: 'ROUND_ACTIVE' }, + }) + + await tx.decisionAuditLog.create({ + data: { + eventType: 'round.reopened', + entityType: 'Round', + entityId: roundId, + actorId, + detailsJson: { + roundName: round.name, + previousStatus: 'ROUND_CLOSED', + pausedRounds: subsequentActiveRounds.map((r: any) => r.name), + }, + snapshotJson: { + timestamp: new Date().toISOString(), + emittedBy: 'round-engine', + }, + }, + }) + + await logAudit({ + prisma: tx, + userId: actorId, + action: 'ROUND_REOPEN', + entityType: 'Round', + entityId: roundId, + detailsJson: { + name: round.name, + pausedRounds: subsequentActiveRounds.map((r: any) => r.name), + }, + }) + + return { + updated, + pausedRounds: subsequentActiveRounds.map((r: any) => r.name), + } + }) + + return { + success: true, + round: { id: result.updated.id, status: result.updated.status }, + pausedRounds: result.pausedRounds, + } + } catch (error) { + console.error('[RoundEngine] reopenRound failed:', error) + return { + success: false, + errors: [error instanceof Error ? error.message : 'Unknown error during round reopen'], + } + } +} + // ─── Project-Level Transitions ────────────────────────────────────────────── /**