Reopen rounds, file type buttons, checklist live-update

- Add reopenRound() to round engine (CLOSED → ACTIVE) with auto-pause of subsequent active rounds
- Add reopen endpoint to roundEngine router and UI button on round detail page
- Replace free-text MIME type input with toggle-only badge buttons in file requirements editor
- Enable refetchOnWindowFocus and shorter polling intervals for readiness checklist queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-16 12:06:07 +01:00
parent de73a6f080
commit 079468d2ca
4 changed files with 242 additions and 53 deletions

View File

@ -76,6 +76,7 @@ import {
Plus, Plus,
Trash2, Trash2,
ArrowRight, ArrowRight,
RotateCcw,
X, X,
} from 'lucide-react' } from 'lucide-react'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form' import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
@ -159,22 +160,22 @@ export default function RoundDetailPage() {
// ── Core data queries ────────────────────────────────────────────────── // ── Core data queries ──────────────────────────────────────────────────
const { data: round, isLoading } = trpc.round.getById.useQuery( const { data: round, isLoading } = trpc.round.getById.useQuery(
{ id: roundId }, { id: roundId },
{ refetchInterval: 30_000 }, { refetchInterval: 15_000, refetchOnWindowFocus: true },
) )
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery( const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 15_000 }, { refetchInterval: 10_000, refetchOnWindowFocus: true },
) )
const competitionId = round?.competitionId ?? '' const competitionId = round?.competitionId ?? ''
const { data: juryGroups } = trpc.juryGroup.list.useQuery( const { data: juryGroups } = trpc.juryGroup.list.useQuery(
{ competitionId }, { competitionId },
{ enabled: !!competitionId, refetchInterval: 30_000 }, { enabled: !!competitionId, refetchInterval: 30_000, refetchOnWindowFocus: true },
) )
const { data: fileRequirements } = trpc.file.listRequirements.useQuery( const { data: fileRequirements } = trpc.file.listRequirements.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 30_000 }, { refetchInterval: 15_000, refetchOnWindowFocus: true },
) )
// Fetch awards linked to this round // Fetch awards linked to this round
@ -223,6 +224,18 @@ export default function RoundDetailPage() {
onError: (err) => toast.error(err.message), 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({ const archiveMutation = trpc.roundEngine.archive.useMutation({
onSuccess: () => { onSuccess: () => {
utils.round.getById.invalidate({ id: roundId }) 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<string, unknown>) => { const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
setConfig(newConfig) setConfig(newConfig)
@ -437,11 +450,11 @@ export default function RoundDetailPage() {
{status === 'ROUND_CLOSED' && ( {status === 'ROUND_CLOSED' && (
<> <>
<DropdownMenuItem <DropdownMenuItem
onClick={() => activateMutation.mutate({ roundId })} onClick={() => reopenMutation.mutate({ roundId })}
disabled={isTransitioning} disabled={isTransitioning}
> >
<Play className="h-4 w-4 mr-2 text-emerald-600" /> <Play className="h-4 w-4 mr-2 text-emerald-600" />
Reactivate Round Reopen Round
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
@ -775,6 +788,36 @@ export default function RoundDetailPage() {
</AlertDialog> </AlertDialog>
)} )}
{status === 'ROUND_CLOSED' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-amber-200 bg-amber-50/50">
<RotateCcw className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Reopen Round</p>
<p className="text-xs text-muted-foreground mt-0.5">
Reactivate this round. Any subsequent active rounds will be paused.
</p>
</div>
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reopen this round?</AlertDialogTitle>
<AlertDialogDescription>
The round will become active again. Any rounds after this one that are currently active will be paused (closed) automatically.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => reopenMutation.mutate({ roundId })}>
Reopen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Assign projects */} {/* Assign projects */}
<Link href={poolLink}> <Link href={poolLink}>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full"> <button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">

View File

@ -49,7 +49,7 @@ type FileRequirementsEditorProps = {
type FormState = { type FormState = {
name: string name: string
description: string description: string
acceptedMimeTypes: string acceptedMimeTypes: string[]
maxSizeMB: string maxSizeMB: string
isRequired: boolean isRequired: boolean
} }
@ -57,21 +57,27 @@ type FormState = {
const emptyForm: FormState = { const emptyForm: FormState = {
name: '', name: '',
description: '', description: '',
acceptedMimeTypes: '', acceptedMimeTypes: [],
maxSizeMB: '', maxSizeMB: '',
isRequired: true, isRequired: true,
} }
const COMMON_MIME_PRESETS: { label: string; value: string }[] = [ const MIME_TYPE_OPTIONS: { label: string; value: string }[] = [
{ label: 'PDF only', value: 'application/pdf' }, { label: 'PDF', value: 'application/pdf' },
{ label: 'Images', value: 'image/png, image/jpeg, image/webp' }, { label: 'Images', value: 'image/*' },
{ label: 'Video', value: 'video/mp4, video/quicktime, video/webm' }, { label: 'Video', value: 'video/*' },
{ label: 'Documents', value: 'application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, { label: 'Word', value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
{ label: 'Spreadsheets', value: 'application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, text/csv' }, { label: 'Excel', value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
{ label: 'Presentations', value: 'application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation' }, { label: 'PowerPoint', value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
{ label: 'Any file', value: '' },
] ]
function getMimeLabel(mime: string): string {
const preset = MIME_TYPE_OPTIONS.find((p) => p.value === mime)
if (preset) return preset.label
if (mime.endsWith('/*')) return mime.replace('/*', '')
return mime
}
export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }: FileRequirementsEditorProps) { export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }: FileRequirementsEditorProps) {
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
@ -123,7 +129,7 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
setForm({ setForm({
name: req.name, name: req.name,
description: req.description || '', description: req.description || '',
acceptedMimeTypes: (req.acceptedMimeTypes || []).join(', '), acceptedMimeTypes: req.acceptedMimeTypes || [],
maxSizeMB: req.maxSizeMB?.toString() || '', maxSizeMB: req.maxSizeMB?.toString() || '',
isRequired: req.isRequired ?? true, isRequired: req.isRequired ?? true,
}) })
@ -131,12 +137,16 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
setDialogOpen(true) setDialogOpen(true)
} }
const handleSubmit = () => { const toggleMimeType = (mime: string) => {
const mimeTypes = form.acceptedMimeTypes setForm((prev) => ({
.split(',') ...prev,
.map((s) => s.trim()) acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime)
.filter(Boolean) ? prev.acceptedMimeTypes.filter((m) => m !== mime)
: [...prev.acceptedMimeTypes, mime],
}))
}
const handleSubmit = () => {
const maxSize = form.maxSizeMB ? parseInt(form.maxSizeMB, 10) : undefined const maxSize = form.maxSizeMB ? parseInt(form.maxSizeMB, 10) : undefined
if (editingId) { if (editingId) {
@ -144,7 +154,7 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
id: editingId, id: editingId,
name: form.name, name: form.name,
description: form.description || null, description: form.description || null,
acceptedMimeTypes: mimeTypes, acceptedMimeTypes: form.acceptedMimeTypes,
maxSizeMB: maxSize ?? null, maxSizeMB: maxSize ?? null,
isRequired: form.isRequired, isRequired: form.isRequired,
}) })
@ -153,7 +163,7 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
roundId, roundId,
name: form.name, name: form.name,
description: form.description || undefined, description: form.description || undefined,
acceptedMimeTypes: mimeTypes, acceptedMimeTypes: form.acceptedMimeTypes,
maxSizeMB: maxSize, maxSizeMB: maxSize,
isRequired: form.isRequired, isRequired: form.isRequired,
sortOrder: (requirements?.length ?? 0), sortOrder: (requirements?.length ?? 0),
@ -258,25 +268,22 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
{req.description && ( {req.description && (
<p className="text-xs text-muted-foreground mt-0.5">{req.description}</p> <p className="text-xs text-muted-foreground mt-0.5">{req.description}</p>
)} )}
<div className="flex flex-wrap gap-2 mt-1.5"> <div className="flex flex-wrap gap-1 mt-1.5">
{req.acceptedMimeTypes?.length > 0 ? ( {req.acceptedMimeTypes?.length > 0 ? (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> req.acceptedMimeTypes.map((t: string) => (
{req.acceptedMimeTypes.map((t: string) => { <Badge key={t} variant="outline" className="text-[10px]">
if (t === 'application/pdf') return 'PDF' {getMimeLabel(t)}
if (t.startsWith('image/')) return t.replace('image/', '').toUpperCase() </Badge>
if (t.startsWith('video/')) return t.replace('video/', '').toUpperCase() ))
return t.split('/').pop()?.toUpperCase() || t
}).join(', ')}
</span>
) : ( ) : (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <Badge variant="outline" className="text-[10px]">
Any file type Any file type
</span> </Badge>
)} )}
{req.maxSizeMB && ( {req.maxSizeMB && (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <Badge variant="outline" className="text-[10px]">
Max {req.maxSizeMB} MB Max {req.maxSizeMB} MB
</span> </Badge>
)} )}
</div> </div>
</div> </div>
@ -347,23 +354,21 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Accepted File Types</label> <label className="text-sm font-medium">Accepted File Types</label>
<Input <div className="flex flex-wrap gap-2">
placeholder="application/pdf, image/png (leave empty for any)" {MIME_TYPE_OPTIONS.map((opt) => (
value={form.acceptedMimeTypes} <Badge
onChange={(e) => setForm((f) => ({ ...f, acceptedMimeTypes: e.target.value }))} key={opt.value}
/> variant={form.acceptedMimeTypes.includes(opt.value) ? 'default' : 'outline'}
<div className="flex flex-wrap gap-1.5"> className="cursor-pointer select-none"
{COMMON_MIME_PRESETS.map((preset) => ( onClick={() => toggleMimeType(opt.value)}
<button
key={preset.label}
type="button"
onClick={() => setForm((f) => ({ ...f, acceptedMimeTypes: preset.value }))}
className="text-[10px] px-2 py-1 rounded-full border hover:bg-muted transition-colors"
> >
{preset.label} {opt.label}
</button> </Badge>
))} ))}
</div> </div>
<p className="text-xs text-muted-foreground">
Select one or more file types. Leave empty to accept any file type.
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Max File Size (MB)</label> <label className="text-sm font-medium">Max File Size (MB)</label>

View File

@ -5,6 +5,7 @@ import {
activateRound, activateRound,
closeRound, closeRound,
archiveRound, archiveRound,
reopenRound,
transitionProject, transitionProject,
batchTransitionProjects, batchTransitionProjects,
getProjectRoundStates, getProjectRoundStates,
@ -53,6 +54,23 @@ export const roundEngineRouter = router({
return result 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 * Archive a round: ROUND_CLOSED ROUND_ARCHIVED
*/ */

View File

@ -50,7 +50,7 @@ const BATCH_SIZE = 50
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = { const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
ROUND_DRAFT: ['ROUND_ACTIVE'], ROUND_DRAFT: ['ROUND_ACTIVE'],
ROUND_ACTIVE: ['ROUND_CLOSED'], ROUND_ACTIVE: ['ROUND_CLOSED'],
ROUND_CLOSED: ['ROUND_ARCHIVED'], ROUND_CLOSED: ['ROUND_ACTIVE', 'ROUND_ARCHIVED'],
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<RoundTransitionResult & { pausedRounds?: string[] }> {
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 ────────────────────────────────────────────── // ─── Project-Level Transitions ──────────────────────────────────────────────
/** /**