2026-02-02 22:33:55 +01:00
|
|
|
'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(
|
2026-02-02 23:11:43 +01:00
|
|
|
{ roundId, page: 1, perPage: 5000 },
|
2026-02-02 22:33:55 +01:00
|
|
|
{ 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">
|
2026-02-04 14:15:06 +01:00
|
|
|
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
|
2026-02-02 22:33:55 +01:00
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|