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,
|
||||
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>
|
||||
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
|
||||
{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'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')}
|
||||
|
|
|
|||
Loading…
Reference in New Issue