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:
279
src/components/admin/advance-projects-dialog.tsx
Normal file
279
src/components/admin/advance-projects-dialog.tsx
Normal 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 "Submitted" 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user