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

@@ -112,11 +112,11 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
where: { programId: editionId },
}),
prisma.project.count({
where: { round: { programId: editionId } },
where: { programId: editionId },
}),
prisma.project.count({
where: {
round: { programId: editionId },
programId: editionId,
createdAt: { gte: sevenDaysAgo },
},
}),
@@ -149,7 +149,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
include: {
_count: {
select: {
projects: true,
roundProjects: true,
assignments: true,
},
},
@@ -161,31 +161,33 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
},
}),
prisma.project.findMany({
where: { round: { programId: editionId } },
where: { programId: editionId },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
id: true,
title: true,
teamName: true,
status: true,
country: true,
competitionCategory: true,
oceanIssue: true,
logoKey: true,
createdAt: true,
submittedAt: true,
round: { select: { name: true } },
roundProjects: {
select: { status: true, round: { select: { name: true } } },
take: 1,
},
},
}),
prisma.project.groupBy({
by: ['competitionCategory'],
where: { round: { programId: editionId } },
where: { programId: editionId },
_count: true,
}),
prisma.project.groupBy({
by: ['oceanIssue'],
where: { round: { programId: editionId } },
where: { programId: editionId },
_count: true,
}),
])
@@ -392,7 +394,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{round._count.projects} projects &middot; {round._count.assignments} assignments
{round._count.roundProjects} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
)}
@@ -459,10 +461,10 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
{truncate(project.title, 45)}
</p>
<Badge
variant={statusColors[project.status] || 'secondary'}
variant={statusColors[project.roundProjects[0]?.status ?? 'SUBMITTED'] || 'secondary'}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{project.status.replace('_', ' ')}
{(project.roundProjects[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">