290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState, useMemo } from 'react'
|
||
|
|
import { cn } from '@/lib/utils'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
||
|
|
import { Label } from '@/components/ui/label'
|
||
|
|
import { Badge } from '@/components/ui/badge'
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from '@/components/ui/dialog'
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from '@/components/ui/select'
|
||
|
|
import { Loader2 } from 'lucide-react'
|
||
|
|
|
||
|
|
export type AdvanceProjectsDialogProps = {
|
||
|
|
open: boolean
|
||
|
|
onOpenChange: (open: boolean) => void
|
||
|
|
roundId: string
|
||
|
|
roundType?: string
|
||
|
|
projectStates: any[] | undefined
|
||
|
|
config: Record<string, unknown>
|
||
|
|
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
|
||
|
|
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
|
||
|
|
currentSortOrder?: number
|
||
|
|
}
|
||
|
|
|
||
|
|
export function AdvanceProjectsDialog({
|
||
|
|
open,
|
||
|
|
onOpenChange,
|
||
|
|
roundId,
|
||
|
|
roundType,
|
||
|
|
projectStates,
|
||
|
|
config,
|
||
|
|
advanceMutation,
|
||
|
|
competitionRounds,
|
||
|
|
currentSortOrder,
|
||
|
|
}: AdvanceProjectsDialogProps) {
|
||
|
|
// For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
|
||
|
|
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
|
||
|
|
// Target round selector
|
||
|
|
const availableTargets = useMemo(() =>
|
||
|
|
(competitionRounds ?? [])
|
||
|
|
.filter((r) => r.sortOrder > (currentSortOrder ?? -1) && r.id !== roundId)
|
||
|
|
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||
|
|
[competitionRounds, currentSortOrder, roundId])
|
||
|
|
|
||
|
|
const [targetRoundId, setTargetRoundId] = useState<string>('')
|
||
|
|
|
||
|
|
// Default to first available target when dialog opens
|
||
|
|
if (open && !targetRoundId && availableTargets.length > 0) {
|
||
|
|
setTargetRoundId(availableTargets[0].id)
|
||
|
|
}
|
||
|
|
const allProjects = projectStates ?? []
|
||
|
|
const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
|
||
|
|
const passedProjects = useMemo(() =>
|
||
|
|
allProjects.filter((ps: any) => ps.state === 'PASSED'),
|
||
|
|
[allProjects])
|
||
|
|
|
||
|
|
const startups = useMemo(() =>
|
||
|
|
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
|
||
|
|
[passedProjects])
|
||
|
|
|
||
|
|
const concepts = useMemo(() =>
|
||
|
|
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'),
|
||
|
|
[passedProjects])
|
||
|
|
|
||
|
|
const other = useMemo(() =>
|
||
|
|
passedProjects.filter((ps: any) =>
|
||
|
|
ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT',
|
||
|
|
),
|
||
|
|
[passedProjects])
|
||
|
|
|
||
|
|
const startupCap = (config.startupAdvanceCount as number) || 0
|
||
|
|
const conceptCap = (config.conceptAdvanceCount as number) || 0
|
||
|
|
|
||
|
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||
|
|
|
||
|
|
// Reset selection when dialog opens
|
||
|
|
if (open && selected.size === 0 && passedProjects.length > 0) {
|
||
|
|
const initial = new Set<string>()
|
||
|
|
// Auto-select all (or up to cap if configured)
|
||
|
|
const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups
|
||
|
|
const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts
|
||
|
|
for (const ps of startupSlice) initial.add(ps.project?.id)
|
||
|
|
for (const ps of conceptSlice) initial.add(ps.project?.id)
|
||
|
|
for (const ps of other) initial.add(ps.project?.id)
|
||
|
|
setSelected(initial)
|
||
|
|
}
|
||
|
|
|
||
|
|
const toggleProject = (projectId: string) => {
|
||
|
|
setSelected((prev) => {
|
||
|
|
const next = new Set(prev)
|
||
|
|
if (next.has(projectId)) next.delete(projectId)
|
||
|
|
else next.add(projectId)
|
||
|
|
return next
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const toggleAll = (projects: any[], on: boolean) => {
|
||
|
|
setSelected((prev) => {
|
||
|
|
const next = new Set(prev)
|
||
|
|
for (const ps of projects) {
|
||
|
|
if (on) next.add(ps.project?.id)
|
||
|
|
else next.delete(ps.project?.id)
|
||
|
|
}
|
||
|
|
return next
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleAdvance = (autoPass?: boolean) => {
|
||
|
|
if (autoPass) {
|
||
|
|
// Auto-pass all pending then advance all
|
||
|
|
advanceMutation.mutate({
|
||
|
|
roundId,
|
||
|
|
autoPassPending: true,
|
||
|
|
...(targetRoundId ? { targetRoundId } : {}),
|
||
|
|
})
|
||
|
|
} else {
|
||
|
|
const ids = Array.from(selected)
|
||
|
|
if (ids.length === 0) return
|
||
|
|
advanceMutation.mutate({
|
||
|
|
roundId,
|
||
|
|
projectIds: ids,
|
||
|
|
...(targetRoundId ? { targetRoundId } : {}),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
onOpenChange(false)
|
||
|
|
setSelected(new Set())
|
||
|
|
setTargetRoundId('')
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleClose = () => {
|
||
|
|
onOpenChange(false)
|
||
|
|
setSelected(new Set())
|
||
|
|
setTargetRoundId('')
|
||
|
|
}
|
||
|
|
|
||
|
|
const renderCategorySection = (
|
||
|
|
label: string,
|
||
|
|
projects: any[],
|
||
|
|
cap: number,
|
||
|
|
badgeColor: string,
|
||
|
|
) => {
|
||
|
|
const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length
|
||
|
|
const overCap = cap > 0 && selectedInCategory > cap
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Checkbox
|
||
|
|
checked={projects.length > 0 && projects.every((ps: any) => selected.has(ps.project?.id))}
|
||
|
|
onCheckedChange={(checked) => toggleAll(projects, !!checked)}
|
||
|
|
/>
|
||
|
|
<span className="text-sm font-medium">{label}</span>
|
||
|
|
<Badge variant="secondary" className={cn('text-[10px]', badgeColor)}>
|
||
|
|
{selectedInCategory}/{projects.length}
|
||
|
|
</Badge>
|
||
|
|
{cap > 0 && (
|
||
|
|
<span className={cn('text-[10px]', overCap ? 'text-red-500 font-medium' : 'text-muted-foreground')}>
|
||
|
|
(target: {cap})
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{projects.length === 0 ? (
|
||
|
|
<p className="text-xs text-muted-foreground pl-7">No passed projects in this category</p>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-1 pl-7">
|
||
|
|
{projects.map((ps: any) => (
|
||
|
|
<label
|
||
|
|
key={ps.project?.id}
|
||
|
|
className="flex items-center gap-2 p-2 rounded hover:bg-muted/30 cursor-pointer"
|
||
|
|
>
|
||
|
|
<Checkbox
|
||
|
|
checked={selected.has(ps.project?.id)}
|
||
|
|
onCheckedChange={() => toggleProject(ps.project?.id)}
|
||
|
|
/>
|
||
|
|
<span className="text-sm truncate flex-1">{ps.project?.title || 'Untitled'}</span>
|
||
|
|
{ps.project?.teamName && (
|
||
|
|
<span className="text-xs text-muted-foreground shrink-0">{ps.project.teamName}</span>
|
||
|
|
)}
|
||
|
|
</label>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const totalProjectCount = allProjects.length
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
||
|
|
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Advance Projects</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
{isSimpleAdvance
|
||
|
|
? `Move all ${totalProjectCount} projects to the next round.`
|
||
|
|
: `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
|
||
|
|
}
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
{/* Target round selector */}
|
||
|
|
{availableTargets.length > 0 && (
|
||
|
|
<div className="space-y-2 pb-2 border-b">
|
||
|
|
<Label className="text-sm">Advance to</Label>
|
||
|
|
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="Select target round" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{availableTargets.map((r) => (
|
||
|
|
<SelectItem key={r.id} value={r.id}>
|
||
|
|
{r.name} ({r.roundType.replace('_', ' ').toLowerCase()})
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{availableTargets.length === 0 && (
|
||
|
|
<div className="text-sm text-amber-600 bg-amber-50 rounded-md p-3">
|
||
|
|
No subsequent rounds found. Projects will advance to the next round by sort order.
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{isSimpleAdvance ? (
|
||
|
|
/* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
|
||
|
|
<div className="py-4 space-y-3">
|
||
|
|
<div className="rounded-lg border bg-muted/30 p-4 text-center space-y-1">
|
||
|
|
<p className="text-3xl font-bold">{totalProjectCount}</p>
|
||
|
|
<p className="text-sm text-muted-foreground">projects will be advanced</p>
|
||
|
|
</div>
|
||
|
|
{pendingCount > 0 && (
|
||
|
|
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
|
||
|
|
<p className="text-xs text-blue-700">
|
||
|
|
{pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
|
||
|
|
{passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
/* Detailed mode for jury/evaluation rounds — per-project selection */
|
||
|
|
<div className="flex-1 overflow-y-auto space-y-4 py-2">
|
||
|
|
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
|
||
|
|
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
|
||
|
|
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||
|
|
{isSimpleAdvance ? (
|
||
|
|
<Button
|
||
|
|
onClick={() => handleAdvance(true)}
|
||
|
|
disabled={totalProjectCount === 0 || advanceMutation.isPending || availableTargets.length === 0}
|
||
|
|
>
|
||
|
|
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||
|
|
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
|
||
|
|
</Button>
|
||
|
|
) : (
|
||
|
|
<Button
|
||
|
|
onClick={() => handleAdvance()}
|
||
|
|
disabled={selected.size === 0 || advanceMutation.isPending || availableTargets.length === 0}
|
||
|
|
>
|
||
|
|
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||
|
|
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
)
|
||
|
|
}
|