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>
)
}