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:
parent
3fb0d128a1
commit
09049d2911
|
|
@ -71,7 +71,6 @@ import {
|
||||||
FileText,
|
FileText,
|
||||||
Trophy,
|
Trophy,
|
||||||
Clock,
|
Clock,
|
||||||
Upload,
|
|
||||||
Send,
|
Send,
|
||||||
Download,
|
Download,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -99,13 +98,14 @@ import {
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
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 { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||||
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
|
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
|
|
@ -178,6 +178,7 @@ export default function RoundDetailPage() {
|
||||||
const [createJuryOpen, setCreateJuryOpen] = useState(false)
|
const [createJuryOpen, setCreateJuryOpen] = useState(false)
|
||||||
const [newJuryName, setNewJuryName] = useState('')
|
const [newJuryName, setNewJuryName] = useState('')
|
||||||
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
||||||
|
const [closeAndAdvance, setCloseAndAdvance] = useState(false)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
|
@ -244,8 +245,16 @@ export default function RoundDetailPage() {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
toast.success('Round closed')
|
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({
|
const reopenMutation = trpc.roundEngine.reopen.useMutation({
|
||||||
|
|
@ -367,6 +376,8 @@ export default function RoundDetailPage() {
|
||||||
|
|
||||||
const isFiltering = round?.roundType === 'FILTERING'
|
const isFiltering = round?.roundType === 'FILTERING'
|
||||||
const isEvaluation = round?.roundType === 'EVALUATION'
|
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
|
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
||||||
|
|
||||||
|
|
@ -424,7 +435,7 @@ export default function RoundDetailPage() {
|
||||||
action: projectCount === 0 ? poolLink : undefined,
|
action: projectCount === 0 ? poolLink : undefined,
|
||||||
actionLabel: 'Assign Projects',
|
actionLabel: 'Assign Projects',
|
||||||
},
|
},
|
||||||
...((isEvaluation || isFiltering)
|
...(hasJury
|
||||||
? [{
|
? [{
|
||||||
label: 'Jury group set',
|
label: 'Jury group set',
|
||||||
ready: !!juryGroup,
|
ready: !!juryGroup,
|
||||||
|
|
@ -712,10 +723,9 @@ export default function RoundDetailPage() {
|
||||||
{ value: 'projects', label: 'Projects', icon: Layers },
|
{ value: 'projects', label: 'Projects', icon: Layers },
|
||||||
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
||||||
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
|
...(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: 'config', label: 'Config', icon: Settings },
|
||||||
{ value: 'windows', label: 'Document Windows', icon: Upload },
|
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
|
||||||
{ value: 'awards', label: 'Awards', icon: Trophy },
|
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
|
|
@ -954,25 +964,56 @@ export default function RoundDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Advance projects (shown when PASSED > 0) */}
|
{/* Advance projects (always visible when projects exist) */}
|
||||||
{passedCount > 0 && (
|
{projectCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setAdvanceDialogOpen(true)}
|
onClick={() => passedCount > 0
|
||||||
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"
|
? 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>
|
<div>
|
||||||
<p className="text-sm font-medium">Advance Projects</p>
|
<p className="text-sm font-medium">Advance Projects</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{passedCount > 0 && (
|
||||||
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
|
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Jury assignment for evaluation/filtering */}
|
{/* Close & Advance (active rounds with passed projects) */}
|
||||||
{(isEvaluation || isFiltering) && !juryGroup && (
|
{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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const el = document.querySelector('[data-jury-select]')
|
const el = document.querySelector('[data-jury-select]')
|
||||||
|
|
@ -1058,6 +1099,13 @@ export default function RoundDetailPage() {
|
||||||
projectStates={projectStates}
|
projectStates={projectStates}
|
||||||
config={config}
|
config={config}
|
||||||
advanceMutation={advanceMutation}
|
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 */}
|
{/* AI Shortlist Confirmation Dialog */}
|
||||||
|
|
@ -1189,6 +1237,7 @@ export default function RoundDetailPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ═══════════ JURY TAB ═══════════ */}
|
{/* ═══════════ JURY TAB ═══════════ */}
|
||||||
|
{hasJury && (
|
||||||
<TabsContent value="jury" className="space-y-6">
|
<TabsContent value="jury" className="space-y-6">
|
||||||
{/* Jury Group Selector + Create */}
|
{/* Jury Group Selector + Create */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -1429,6 +1478,7 @@ export default function RoundDetailPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
|
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
|
||||||
{isEvaluation && (
|
{isEvaluation && (
|
||||||
|
|
@ -1513,6 +1563,38 @@ export default function RoundDetailPage() {
|
||||||
|
|
||||||
{/* ═══════════ CONFIG TAB ═══════════ */}
|
{/* ═══════════ CONFIG TAB ═══════════ */}
|
||||||
<TabsContent value="config" className="space-y-6">
|
<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 */}
|
{/* General Round Settings */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
|
|
@ -1651,18 +1733,8 @@ export default function RoundDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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's active status.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* ═══════════ AWARDS TAB ═══════════ */}
|
{/* ═══════════ AWARDS TAB ═══════════ */}
|
||||||
|
{hasAwards && (
|
||||||
<TabsContent value="awards" className="space-y-4">
|
<TabsContent value="awards" className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
|
|
@ -1731,6 +1803,7 @@ export default function RoundDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -2430,14 +2503,31 @@ function AdvanceProjectsDialog({
|
||||||
projectStates,
|
projectStates,
|
||||||
config,
|
config,
|
||||||
advanceMutation,
|
advanceMutation,
|
||||||
|
competitionRounds,
|
||||||
|
currentSortOrder,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
roundId: string
|
roundId: string
|
||||||
projectStates: any[] | undefined
|
projectStates: any[] | undefined
|
||||||
config: Record<string, unknown>
|
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(() =>
|
const passedProjects = useMemo(() =>
|
||||||
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
|
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
|
||||||
[projectStates])
|
[projectStates])
|
||||||
|
|
@ -2496,14 +2586,20 @@ function AdvanceProjectsDialog({
|
||||||
const handleAdvance = () => {
|
const handleAdvance = () => {
|
||||||
const ids = Array.from(selected)
|
const ids = Array.from(selected)
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
advanceMutation.mutate({ roundId, projectIds: ids })
|
advanceMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
projectIds: ids,
|
||||||
|
...(targetRoundId ? { targetRoundId } : {}),
|
||||||
|
})
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
|
setTargetRoundId('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
|
setTargetRoundId('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderCategorySection = (
|
const renderCategorySection = (
|
||||||
|
|
@ -2565,11 +2661,35 @@ function AdvanceProjectsDialog({
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Advance Projects</DialogTitle>
|
<DialogTitle>Advance Projects</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select which passed projects to advance to the next round.
|
Select which passed projects to advance.
|
||||||
{selected.size} of {passedProjects.length} selected.
|
{selected.size} of {passedProjects.length} selected.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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">
|
<div className="flex-1 overflow-y-auto space-y-4 py-2">
|
||||||
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
|
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
|
||||||
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
|
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue