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:
@@ -180,7 +180,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
||||
if (storedOrder.length > 0) {
|
||||
setProjectOrder(storedOrder)
|
||||
} else {
|
||||
setProjectOrder(sessionData.round.projects.map((p) => p.id))
|
||||
setProjectOrder(sessionData.round.roundProjects.map((rp) => rp.project.id))
|
||||
}
|
||||
}
|
||||
}, [sessionData])
|
||||
@@ -253,7 +253,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const projects = sessionData.round.projects
|
||||
const projects = sessionData.round.roundProjects.map((rp) => rp.project)
|
||||
const sortedProjects = projectOrder
|
||||
.map((id) => projects.find((p) => p.id === id))
|
||||
.filter((p): p is Project => !!p)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use } from 'react'
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
@@ -44,8 +44,14 @@ import {
|
||||
Filter,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Plus,
|
||||
ArrowRightCircle,
|
||||
Minus,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
|
||||
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
|
||||
|
||||
interface PageProps {
|
||||
@@ -54,6 +60,9 @@ interface PageProps {
|
||||
|
||||
function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const router = useRouter()
|
||||
const [assignOpen, setAssignOpen] = useState(false)
|
||||
const [advanceOpen, setAdvanceOpen] = useState(false)
|
||||
const [removeOpen, setRemoveOpen] = useState(false)
|
||||
|
||||
const { data: round, isLoading } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
||||
@@ -235,7 +244,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{round._count.projects}</div>
|
||||
<div className="text-2xl font-bold">{round._count.roundProjects}</div>
|
||||
<Button variant="link" size="sm" className="px-0" asChild>
|
||||
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
|
||||
</Button>
|
||||
@@ -423,9 +432,43 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
View Projects
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAssignOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Assign Projects
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAdvanceOpen(true)}>
|
||||
<ArrowRightCircle className="mr-2 h-4 w-4" />
|
||||
Advance Projects
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setRemoveOpen(true)}>
|
||||
<Minus className="mr-2 h-4 w-4" />
|
||||
Remove Projects
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialogs */}
|
||||
<AssignProjectsDialog
|
||||
roundId={roundId}
|
||||
programId={round.program.id}
|
||||
open={assignOpen}
|
||||
onOpenChange={setAssignOpen}
|
||||
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
||||
/>
|
||||
<AdvanceProjectsDialog
|
||||
roundId={roundId}
|
||||
programId={round.program.id}
|
||||
open={advanceOpen}
|
||||
onOpenChange={setAdvanceOpen}
|
||||
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
||||
/>
|
||||
<RemoveProjectsDialog
|
||||
roundId={roundId}
|
||||
open={removeOpen}
|
||||
onOpenChange={setRemoveOpen}
|
||||
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
|
||||
|
||||
const createRoundSchema = z.object({
|
||||
@@ -58,6 +58,8 @@ function CreateRoundContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const programIdParam = searchParams.get('program')
|
||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
||||
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
|
||||
|
||||
@@ -82,7 +84,9 @@ function CreateRoundContent() {
|
||||
await createRound.mutateAsync({
|
||||
programId: data.programId,
|
||||
name: data.name,
|
||||
roundType,
|
||||
requiredReviews: data.requiredReviews,
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined,
|
||||
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined,
|
||||
})
|
||||
@@ -218,6 +222,14 @@ function CreateRoundContent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Round Type & Settings */}
|
||||
<RoundTypeSettings
|
||||
roundType={roundType}
|
||||
onRoundTypeChange={setRoundType}
|
||||
settings={roundSettings}
|
||||
onSettingsChange={setRoundSettings}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Voting Window</CardTitle>
|
||||
|
||||
@@ -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 "{round.name}"? 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>
|
||||
|
||||
Reference in New Issue
Block a user