Decouple projects from rounds with RoundProject join table

Projects now exist at the program level instead of being locked to a
single round. A new RoundProject join table enables many-to-many
relationships with per-round status tracking. Rounds have sortOrder
for configurable progression paths.

- Add RoundProject model, programId on Project, sortOrder on Round
- Migration preserves existing data (roundId -> RoundProject entries)
- Update all routers to query through RoundProject join
- Add assign/remove/advance/reorder round endpoints
- Add Assign, Advance, Remove Projects dialogs on round detail page
- Add round reorder controls (up/down arrows) on rounds list
- Show all rounds on project detail page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 22:33:55 +01:00
parent 0d2bc4db7e
commit fd5e5222da
52 changed files with 1892 additions and 326 deletions

View File

@@ -0,0 +1,279 @@
'use client'
import { useState, useCallback, useEffect, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowRightCircle, Loader2, Info } from 'lucide-react'
interface AdvanceProjectsDialogProps {
roundId: string
programId: string
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}
export function AdvanceProjectsDialog({
roundId,
programId,
open,
onOpenChange,
onSuccess,
}: AdvanceProjectsDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [targetRoundId, setTargetRoundId] = useState<string>('')
const utils = trpc.useUtils()
// Reset state when dialog opens
useEffect(() => {
if (open) {
setSelectedIds(new Set())
setTargetRoundId('')
}
}, [open])
// Fetch rounds in program
const { data: roundsData } = trpc.round.list.useQuery(
{ programId },
{ enabled: open }
)
// Fetch projects in current round
const { data: projectsData, isLoading } = trpc.project.list.useQuery(
{ roundId, page: 1, perPage: 200 },
{ enabled: open }
)
// Auto-select next round by sortOrder
const otherRounds = useMemo(() => {
if (!roundsData) return []
return roundsData
.filter((r) => r.id !== roundId)
.sort((a, b) => a.sortOrder - b.sortOrder)
}, [roundsData, roundId])
const currentRound = useMemo(() => {
return roundsData?.find((r) => r.id === roundId)
}, [roundsData, roundId])
// Auto-select next round in sort order
useEffect(() => {
if (open && otherRounds.length > 0 && !targetRoundId && currentRound) {
const nextRound = otherRounds.find(
(r) => r.sortOrder > currentRound.sortOrder
)
setTargetRoundId(nextRound?.id || otherRounds[0].id)
}
}, [open, otherRounds, targetRoundId, currentRound])
const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (result) => {
const targetName = otherRounds.find((r) => r.id === targetRoundId)?.name
toast.success(
`${result.advanced} project${result.advanced !== 1 ? 's' : ''} advanced to ${targetName}`
)
utils.round.get.invalidate()
utils.project.list.invalidate()
onSuccess?.()
onOpenChange(false)
},
onError: (error) => {
toast.error(error.message)
},
})
const projects = projectsData?.projects ?? []
const toggleProject = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const toggleAll = useCallback(() => {
if (selectedIds.size === projects.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(projects.map((p) => p.id)))
}
}, [selectedIds.size, projects])
const handleAdvance = () => {
if (selectedIds.size === 0 || !targetRoundId) return
advanceMutation.mutate({
fromRoundId: roundId,
toRoundId: targetRoundId,
projectIds: Array.from(selectedIds),
})
}
const targetRoundName = otherRounds.find((r) => r.id === targetRoundId)?.name
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ArrowRightCircle className="h-5 w-5" />
Advance Projects
</DialogTitle>
<DialogDescription>
Select projects to advance to the next round. Projects will remain
visible in the current round.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>Target Round</Label>
{otherRounds.length === 0 ? (
<p className="text-sm text-muted-foreground">
No other rounds available in this program. Create another round first.
</p>
) : (
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select target round" />
</SelectTrigger>
<SelectContent>
{otherRounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} (Order: {r.sortOrder})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="flex items-start gap-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-800 dark:bg-blue-950/50 dark:text-blue-200">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Projects will be copied to the target round with &quot;Submitted&quot; status.
They will remain in the current round with their existing status.
</span>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="font-medium">No projects in this round</p>
<p className="text-sm text-muted-foreground">
Assign projects to this round first.
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.size === projects.length && projects.length > 0}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {projects.length} selected
</span>
</div>
<div className="rounded-lg border max-h-[300px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow
key={project.id}
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'}
onClick={() => toggleProject(project.id)}
>
<TableCell>
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">
{project.title}
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || '—'}
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleAdvance}
disabled={
selectedIds.size === 0 ||
!targetRoundId ||
advanceMutation.isPending
}
>
{advanceMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ArrowRightCircle className="mr-2 h-4 w-4" />
)}
Advance Selected ({selectedIds.size})
{targetRoundName ? ` to ${targetRoundName}` : ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,230 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Search, Loader2, Plus, Package } from 'lucide-react'
interface AssignProjectsDialogProps {
roundId: string
programId: string
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}
export function AssignProjectsDialog({
roundId,
programId,
open,
onOpenChange,
onSuccess,
}: AssignProjectsDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const utils = trpc.useUtils()
// Debounce search
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300)
return () => clearTimeout(timer)
}, [search])
// Reset state when dialog opens
useEffect(() => {
if (open) {
setSelectedIds(new Set())
setSearch('')
setDebouncedSearch('')
}
}, [open])
const { data, isLoading } = trpc.project.list.useQuery(
{
programId,
notInRoundId: roundId,
search: debouncedSearch || undefined,
page: 1,
perPage: 100,
},
{ enabled: open }
)
const assignMutation = trpc.round.assignProjects.useMutation({
onSuccess: (result) => {
toast.success(`${result.assigned} project${result.assigned !== 1 ? 's' : ''} assigned to round`)
utils.round.get.invalidate({ id: roundId })
utils.project.list.invalidate()
onSuccess?.()
onOpenChange(false)
},
onError: (error) => {
toast.error(error.message)
},
})
const projects = data?.projects ?? []
const toggleProject = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const toggleAll = useCallback(() => {
if (selectedIds.size === projects.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(projects.map((p) => p.id)))
}
}, [selectedIds.size, projects])
const handleAssign = () => {
if (selectedIds.size === 0) return
assignMutation.mutate({
roundId,
projectIds: Array.from(selectedIds),
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Assign Projects to Round
</DialogTitle>
<DialogDescription>
Select projects from the program to add to this round.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No available projects</p>
<p className="text-sm text-muted-foreground">
All program projects are already in this round.
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.size === projects.length && projects.length > 0}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {projects.length} selected
</span>
</div>
</div>
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Country</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow
key={project.id}
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'}
onClick={() => toggleProject(project.id)}
>
<TableCell>
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">
{project.title}
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || '—'}
</TableCell>
<TableCell>
{project.country ? (
<Badge variant="outline" className="text-xs">
{project.country}
</Badge>
) : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleAssign}
disabled={selectedIds.size === 0 || assignMutation.isPending}
>
{assignMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Assign Selected ({selectedIds.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,246 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Minus, Loader2, AlertTriangle } from 'lucide-react'
interface RemoveProjectsDialogProps {
roundId: string
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}
export function RemoveProjectsDialog({
roundId,
open,
onOpenChange,
onSuccess,
}: RemoveProjectsDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [confirmOpen, setConfirmOpen] = useState(false)
const utils = trpc.useUtils()
// Reset state when dialog opens
useEffect(() => {
if (open) {
setSelectedIds(new Set())
setConfirmOpen(false)
}
}, [open])
const { data, isLoading } = trpc.project.list.useQuery(
{ roundId, page: 1, perPage: 200 },
{ enabled: open }
)
const removeMutation = trpc.round.removeProjects.useMutation({
onSuccess: (result) => {
toast.success(
`${result.removed} project${result.removed !== 1 ? 's' : ''} removed from round`
)
utils.round.get.invalidate({ id: roundId })
utils.project.list.invalidate()
onSuccess?.()
onOpenChange(false)
},
onError: (error) => {
toast.error(error.message)
},
})
const projects = data?.projects ?? []
const toggleProject = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const toggleAll = useCallback(() => {
if (selectedIds.size === projects.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(projects.map((p) => p.id)))
}
}, [selectedIds.size, projects])
const handleRemove = () => {
if (selectedIds.size === 0) return
removeMutation.mutate({
roundId,
projectIds: Array.from(selectedIds),
})
setConfirmOpen(false)
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Minus className="h-5 w-5" />
Remove Projects from Round
</DialogTitle>
<DialogDescription>
Select projects to remove from this round. The projects will remain
in the program and can be re-assigned later.
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/50 dark:text-amber-200">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Removing projects from a round will also delete their jury
assignments and evaluations in this round.
</span>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="font-medium">No projects in this round</p>
<p className="text-sm text-muted-foreground">
There are no projects to remove.
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.size === projects.length && projects.length > 0}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {projects.length} selected
</span>
</div>
<div className="rounded-lg border max-h-[350px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow
key={project.id}
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'}
onClick={() => toggleProject(project.id)}
>
<TableCell>
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">
{project.title}
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || '—'}
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => setConfirmOpen(true)}
disabled={selectedIds.size === 0 || removeMutation.isPending}
>
{removeMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Minus className="mr-2 h-4 w-4" />
)}
Remove Selected ({selectedIds.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Removal</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove {selectedIds.size} project
{selectedIds.size !== 1 ? 's' : ''} from this round? Their
assignments and evaluations in this round will be deleted. The
projects will remain in the program.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemove}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove Projects
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}