163 lines
5.4 KiB
TypeScript
163 lines
5.4 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react'
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Badge } from '@/components/ui/badge'
|
||
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
||
|
|
import { Loader2, Save, Trophy } from 'lucide-react'
|
||
|
|
import { formatEnumLabel } from '@/lib/utils'
|
||
|
|
import type { CompetitionCategory } from '@prisma/client'
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
programId: string
|
||
|
|
}
|
||
|
|
|
||
|
|
const CATEGORIES: CompetitionCategory[] = ['STARTUP', 'BUSINESS_CONCEPT']
|
||
|
|
|
||
|
|
type Row = {
|
||
|
|
category: CompetitionCategory
|
||
|
|
quota: number
|
||
|
|
confirmed: number
|
||
|
|
pending: number
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FinalistSlotsCard({ programId }: Props) {
|
||
|
|
const utils = trpc.useUtils()
|
||
|
|
const { data: quotas, isLoading: loadingQuotas } = trpc.finalist.listQuotas.useQuery({
|
||
|
|
programId,
|
||
|
|
})
|
||
|
|
const { data: counts, isLoading: loadingCounts } = trpc.finalist.listCategoryCounts.useQuery({
|
||
|
|
programId,
|
||
|
|
})
|
||
|
|
|
||
|
|
const [draft, setDraft] = useState<Record<CompetitionCategory, string>>({
|
||
|
|
STARTUP: '',
|
||
|
|
BUSINESS_CONCEPT: '',
|
||
|
|
})
|
||
|
|
|
||
|
|
// Sync draft from server response on first load / after save
|
||
|
|
useEffect(() => {
|
||
|
|
if (!quotas) return
|
||
|
|
const next: Record<CompetitionCategory, string> = { STARTUP: '', BUSINESS_CONCEPT: '' }
|
||
|
|
for (const cat of CATEGORIES) {
|
||
|
|
const found = quotas.find((q) => q.category === cat)
|
||
|
|
next[cat] = found ? String(found.quota) : ''
|
||
|
|
}
|
||
|
|
setDraft(next)
|
||
|
|
}, [quotas])
|
||
|
|
|
||
|
|
const setQuotaMutation = trpc.finalist.setQuota.useMutation({
|
||
|
|
onSuccess: (_, vars) => {
|
||
|
|
toast.success(`${formatEnumLabel(vars.category)} quota saved`)
|
||
|
|
utils.finalist.listQuotas.invalidate({ programId })
|
||
|
|
utils.finalist.listCategoryCounts.invalidate({ programId })
|
||
|
|
},
|
||
|
|
onError: (err) => toast.error(err.message),
|
||
|
|
})
|
||
|
|
|
||
|
|
if (loadingQuotas || loadingCounts) {
|
||
|
|
return <Skeleton className="h-44 w-full rounded-md" />
|
||
|
|
}
|
||
|
|
|
||
|
|
const rows: Row[] = CATEGORIES.map((cat) => {
|
||
|
|
const q = quotas?.find((x) => x.category === cat)
|
||
|
|
const c = counts?.find((x) => x.category === cat)
|
||
|
|
return {
|
||
|
|
category: cat,
|
||
|
|
quota: q?.quota ?? 0,
|
||
|
|
confirmed: c?.confirmed ?? 0,
|
||
|
|
pending: c?.pending ?? 0,
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
const handleSave = (category: CompetitionCategory) => {
|
||
|
|
const raw = draft[category]
|
||
|
|
const n = Number.parseInt(raw, 10)
|
||
|
|
if (Number.isNaN(n) || n < 0) {
|
||
|
|
toast.error('Quota must be a non-negative integer')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setQuotaMutation.mutate({ programId, category, quota: n })
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Trophy className="text-muted-foreground h-4 w-4" />
|
||
|
|
<CardTitle className="text-base">Finalist slots</CardTitle>
|
||
|
|
</div>
|
||
|
|
<CardDescription>
|
||
|
|
Per-category quotas. Reductions blocked when {`> `}confirmed count — un-confirm a team
|
||
|
|
first if you need to shrink a category.
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="space-y-3">
|
||
|
|
{rows.map((r) => {
|
||
|
|
const isPending =
|
||
|
|
setQuotaMutation.isPending &&
|
||
|
|
setQuotaMutation.variables?.category === r.category
|
||
|
|
const dirty = String(r.quota) !== draft[r.category]
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={r.category}
|
||
|
|
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
||
|
|
>
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="font-medium">{formatEnumLabel(r.category)}</div>
|
||
|
|
<div className="text-muted-foreground mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||
|
|
<span>
|
||
|
|
<Badge variant="default" className="text-xs">
|
||
|
|
{r.confirmed}
|
||
|
|
</Badge>{' '}
|
||
|
|
confirmed
|
||
|
|
</span>
|
||
|
|
<span>
|
||
|
|
<Badge variant="secondary" className="text-xs">
|
||
|
|
{r.pending}
|
||
|
|
</Badge>{' '}
|
||
|
|
pending
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
inputMode="numeric"
|
||
|
|
min={0}
|
||
|
|
className="w-20 tabular-nums"
|
||
|
|
value={draft[r.category]}
|
||
|
|
onChange={(e) =>
|
||
|
|
setDraft((d) => ({ ...d, [r.category]: e.target.value }))
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant={dirty ? 'default' : 'outline'}
|
||
|
|
disabled={!dirty || isPending}
|
||
|
|
onClick={() => handleSave(r.category)}
|
||
|
|
>
|
||
|
|
{isPending ? (
|
||
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<Save className="mr-1 h-3.5 w-3.5" />
|
||
|
|
Save
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)
|
||
|
|
}
|