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

@@ -52,6 +52,8 @@ import {
Archive,
Trash2,
Loader2,
ChevronUp,
ChevronDown,
} from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns'
@@ -106,6 +108,7 @@ function RoundsContent() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Order</TableHead>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead>Voting Window</TableHead>
@@ -115,8 +118,15 @@ function RoundsContent() {
</TableRow>
</TableHeader>
<TableBody>
{program.rounds.map((round) => (
<RoundRow key={round.id} round={round} />
{program.rounds.map((round, index) => (
<RoundRow
key={round.id}
round={round}
index={index}
totalRounds={program.rounds.length}
allRoundIds={program.rounds.map((r) => r.id)}
programId={program.id}
/>
))}
</TableBody>
</Table>
@@ -133,10 +143,40 @@ function RoundsContent() {
)
}
function RoundRow({ round }: { round: any }) {
function RoundRow({
round,
index,
totalRounds,
allRoundIds,
programId,
}: {
round: any
index: number
totalRounds: number
allRoundIds: string[]
programId: string
}) {
const utils = trpc.useUtils()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const reorder = trpc.round.reorder.useMutation({
onSuccess: () => {
utils.program.list.invalidate()
},
})
const moveUp = () => {
const ids = [...allRoundIds]
;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]]
reorder.mutate({ programId, roundIds: ids })
}
const moveDown = () => {
const ids = [...allRoundIds]
;[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]]
reorder.mutate({ programId, roundIds: ids })
}
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.program.list.invalidate()
@@ -229,6 +269,28 @@ function RoundRow({ round }: { round: any }) {
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={moveUp}
disabled={index === 0 || reorder.isPending}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={moveDown}
disabled={index === totalRounds - 1 || reorder.isPending}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
</TableCell>
<TableCell>
<Link
href={`/admin/rounds/${round.id}`}
@@ -242,7 +304,7 @@ function RoundRow({ round }: { round: any }) {
<TableCell>
<div className="flex items-center gap-1">
<FileText className="h-4 w-4 text-muted-foreground" />
{round._count?.projects || 0}
{round._count?.roundProjects || 0}
</div>
</TableCell>
<TableCell>
@@ -325,9 +387,9 @@ function RoundRow({ round }: { round: any }) {
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{round.name}&quot;? This will
permanently delete all {round._count?.projects || 0} projects,{' '}
{round._count?.assignments || 0} assignments, and all evaluations
in this round. This action cannot be undone.
remove {round._count?.roundProjects || 0} project assignments,{' '}
{round._count?.assignments || 0} reviewer assignments, and all evaluations
in this round. The projects themselves will remain in the program. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>