Round management: tab cleanup, date pickers, advancement workflow

- Remove Document Windows tab (round dates + file requirements in
  Config are sufficient, separate SubmissionWindow was redundant)
- Restrict Jury and Awards tabs to round types that use them
  (EVALUATION, LIVE_FINAL, DELIBERATION only)
- Add Round Dates card in Config tab with DateTimePicker for
  start/end dates (supports past and future dates)
- Make Advance Projects button always visible when projects exist
  (dimmed with guidance when no projects are PASSED yet)
- Add Close & Advance combined quick action to streamline round
  progression workflow
- Add target round selector to Advance Projects dialog so admin
  can pick which round to advance projects into

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-16 16:43:23 +01:00
parent 3fb0d128a1
commit 09049d2911
1 changed files with 150 additions and 30 deletions

View File

@ -71,7 +71,6 @@ import {
FileText,
Trophy,
Clock,
Upload,
Send,
Download,
Plus,
@ -99,13 +98,14 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { AnimatedCard } from '@/components/shared/animated-container'
import { DateTimePicker } from '@/components/ui/datetime-picker'
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
import { motion } from 'motion/react'
@ -178,6 +178,7 @@ export default function RoundDetailPage() {
const [createJuryOpen, setCreateJuryOpen] = useState(false)
const [newJuryName, setNewJuryName] = useState('')
const [addMemberOpen, setAddMemberOpen] = useState(false)
const [closeAndAdvance, setCloseAndAdvance] = useState(false)
const utils = trpc.useUtils()
@ -244,8 +245,16 @@ export default function RoundDetailPage() {
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round closed')
if (closeAndAdvance) {
setCloseAndAdvance(false)
// Small delay to let cache invalidation complete before opening dialog
setTimeout(() => setAdvanceDialogOpen(true), 300)
}
},
onError: (err) => {
setCloseAndAdvance(false)
toast.error(err.message)
},
onError: (err) => toast.error(err.message),
})
const reopenMutation = trpc.roundEngine.reopen.useMutation({
@ -367,6 +376,8 @@ export default function RoundDetailPage() {
const isFiltering = round?.roundType === 'FILTERING'
const isEvaluation = round?.roundType === 'EVALUATION'
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = hasJury
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
@ -424,7 +435,7 @@ export default function RoundDetailPage() {
action: projectCount === 0 ? poolLink : undefined,
actionLabel: 'Assign Projects',
},
...((isEvaluation || isFiltering)
...(hasJury
? [{
label: 'Jury group set',
ready: !!juryGroup,
@ -712,10 +723,9 @@ export default function RoundDetailPage() {
{ value: 'projects', label: 'Projects', icon: Layers },
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
{ value: 'jury', label: 'Jury', icon: Users },
...(hasJury ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
{ value: 'config', label: 'Config', icon: Settings },
{ value: 'windows', label: 'Document Windows', icon: Upload },
{ value: 'awards', label: 'Awards', icon: Trophy },
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
].map((tab) => (
<TabsTrigger
key={tab.value}
@ -954,25 +964,56 @@ export default function RoundDetailPage() {
</div>
</button>
{/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && (
{/* Advance projects (always visible when projects exist) */}
{projectCount > 0 && (
<button
onClick={() => setAdvanceDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 bg-emerald-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
onClick={() => passedCount > 0
? setAdvanceDialogOpen(true)
: toast.info('Mark projects as "Passed" first in the Projects tab')}
className={cn(
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
passedCount > 0
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
: 'border-dashed opacity-60',
)}
>
<ArrowRight className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', passedCount > 0 ? 'text-emerald-600' : 'text-muted-foreground')} />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Move {passedCount} passed project(s) to the next round
{passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
</p>
</div>
{passedCount > 0 && (
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
)}
</button>
)}
{/* Jury assignment for evaluation/filtering */}
{(isEvaluation || isFiltering) && !juryGroup && (
{/* Close & Advance (active rounds with passed projects) */}
{status === 'ROUND_ACTIVE' && passedCount > 0 && (
<button
onClick={() => {
setCloseAndAdvance(true)
closeMutation.mutate({ roundId })
}}
disabled={isTransitioning}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Close & Advance</p>
<p className="text-xs text-muted-foreground mt-0.5">
Close this round and advance {passedCount} passed project(s) to the next round
</p>
</div>
</button>
)}
{/* Jury assignment for rounds that use jury */}
{hasJury && !juryGroup && (
<button
onClick={() => {
const el = document.querySelector('[data-jury-select]')
@ -1058,6 +1099,13 @@ export default function RoundDetailPage() {
projectStates={projectStates}
config={config}
advanceMutation={advanceMutation}
competitionRounds={competition?.rounds?.map((r: any) => ({
id: r.id,
name: r.name,
sortOrder: r.sortOrder,
roundType: r.roundType,
}))}
currentSortOrder={round?.sortOrder}
/>
{/* AI Shortlist Confirmation Dialog */}
@ -1189,6 +1237,7 @@ export default function RoundDetailPage() {
)}
{/* ═══════════ JURY TAB ═══════════ */}
{hasJury && (
<TabsContent value="jury" className="space-y-6">
{/* Jury Group Selector + Create */}
<Card>
@ -1429,6 +1478,7 @@ export default function RoundDetailPage() {
/>
)}
</TabsContent>
)}
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
{isEvaluation && (
@ -1513,6 +1563,38 @@ export default function RoundDetailPage() {
{/* ═══════════ CONFIG TAB ═══════════ */}
<TabsContent value="config" className="space-y-6">
{/* Round Dates */}
<Card>
<CardHeader className="border-b">
<CardTitle className="text-base">Round Dates</CardTitle>
<CardDescription>
When this round starts and ends. Defines the active period for document uploads and evaluations.
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Start Date</Label>
<DateTimePicker
value={round.windowOpenAt ? new Date(round.windowOpenAt) : null}
onChange={(date) => updateMutation.mutate({ id: roundId, windowOpenAt: date })}
placeholder="Select start date & time"
clearable
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<DateTimePicker
value={round.windowCloseAt ? new Date(round.windowCloseAt) : null}
onChange={(date) => updateMutation.mutate({ id: roundId, windowCloseAt: date })}
placeholder="Select end date & time"
clearable
/>
</div>
</div>
</CardContent>
</Card>
{/* General Round Settings */}
<Card>
<CardHeader className="border-b">
@ -1651,18 +1733,8 @@ export default function RoundDetailPage() {
</Card>
</TabsContent>
{/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
<TabsContent value="windows" className="space-y-4">
<div className="space-y-1 mb-4">
<h3 className="text-lg font-semibold">Document Upload Windows</h3>
<p className="text-sm text-muted-foreground">
Configure when applicants can upload documents for each phase of this round. These windows control the submission periods independently of the round&apos;s active status.
</p>
</div>
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
</TabsContent>
{/* ═══════════ AWARDS TAB ═══════════ */}
{hasAwards && (
<TabsContent value="awards" className="space-y-4">
<Card>
<CardContent className="p-6">
@ -1731,6 +1803,7 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</div>
)
@ -2430,14 +2503,31 @@ function AdvanceProjectsDialog({
projectStates,
config,
advanceMutation,
competitionRounds,
currentSortOrder,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
projectStates: any[] | undefined
config: Record<string, unknown>
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[] }) => void; isPending: boolean }
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string }) => void; isPending: boolean }
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
currentSortOrder?: number
}) {
// Target round selector
const availableTargets = useMemo(() =>
(competitionRounds ?? [])
.filter((r) => r.sortOrder > (currentSortOrder ?? -1) && r.id !== roundId)
.sort((a, b) => a.sortOrder - b.sortOrder),
[competitionRounds, currentSortOrder, roundId])
const [targetRoundId, setTargetRoundId] = useState<string>('')
// Default to first available target when dialog opens
if (open && !targetRoundId && availableTargets.length > 0) {
setTargetRoundId(availableTargets[0].id)
}
const passedProjects = useMemo(() =>
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
[projectStates])
@ -2496,14 +2586,20 @@ function AdvanceProjectsDialog({
const handleAdvance = () => {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({ roundId, projectIds: ids })
advanceMutation.mutate({
roundId,
projectIds: ids,
...(targetRoundId ? { targetRoundId } : {}),
})
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
}
const handleClose = () => {
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
}
const renderCategorySection = (
@ -2565,11 +2661,35 @@ function AdvanceProjectsDialog({
<DialogHeader>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Select which passed projects to advance to the next round.
Select which passed projects to advance.
{selected.size} of {passedProjects.length} selected.
</DialogDescription>
</DialogHeader>
{/* Target round selector */}
{availableTargets.length > 0 && (
<div className="space-y-2 pb-2 border-b">
<Label className="text-sm">Advance to</Label>
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select target round" />
</SelectTrigger>
<SelectContent>
{availableTargets.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} ({r.roundType.replace('_', ' ').toLowerCase()})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{availableTargets.length === 0 && (
<div className="text-sm text-amber-600 bg-amber-50 rounded-md p-3">
No subsequent rounds found. Projects will advance to the next round by sort order.
</div>
)}
<div className="flex-1 overflow-y-auto space-y-4 py-2">
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}