168 lines
6.7 KiB
TypeScript
168 lines
6.7 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Badge } from '@/components/ui/badge'
|
||
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
||
|
|
import {
|
||
|
|
AlertDialog,
|
||
|
|
AlertDialogAction,
|
||
|
|
AlertDialogCancel,
|
||
|
|
AlertDialogContent,
|
||
|
|
AlertDialogDescription,
|
||
|
|
AlertDialogFooter,
|
||
|
|
AlertDialogHeader,
|
||
|
|
AlertDialogTitle,
|
||
|
|
AlertDialogTrigger,
|
||
|
|
} from '@/components/ui/alert-dialog'
|
||
|
|
import { ListOrdered, Loader2 } from 'lucide-react'
|
||
|
|
import { formatEnumLabel } from '@/lib/utils'
|
||
|
|
import type { CompetitionCategory } from '@prisma/client'
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
programId: string
|
||
|
|
}
|
||
|
|
|
||
|
|
const STATUS_LABEL: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
|
||
|
|
WAITING: { label: 'Waiting', variant: 'outline' },
|
||
|
|
PROMOTED: { label: 'Promoted', variant: 'default' },
|
||
|
|
USED: { label: 'Used', variant: 'secondary' },
|
||
|
|
}
|
||
|
|
|
||
|
|
export function WaitlistCard({ programId }: Props) {
|
||
|
|
const utils = trpc.useUtils()
|
||
|
|
const { data, isLoading } = trpc.finalist.listWaitlist.useQuery({ programId })
|
||
|
|
|
||
|
|
const promoteMutation = trpc.finalist.manualPromote.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
toast.success('Waitlist entry promoted — confirmation email sent')
|
||
|
|
utils.finalist.listWaitlist.invalidate({ programId })
|
||
|
|
utils.finalist.listCategoryCounts.invalidate({ programId })
|
||
|
|
},
|
||
|
|
onError: (err) => toast.error(err.message),
|
||
|
|
})
|
||
|
|
|
||
|
|
if (isLoading) return <Skeleton className="h-44 w-full rounded-md" />
|
||
|
|
|
||
|
|
const byCategory = new Map<CompetitionCategory, typeof data>()
|
||
|
|
for (const entry of data ?? []) {
|
||
|
|
const list = byCategory.get(entry.category) ?? []
|
||
|
|
list.push(entry)
|
||
|
|
byCategory.set(entry.category, list)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!data || data.length === 0) {
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<ListOrdered className="text-muted-foreground h-4 w-4" />
|
||
|
|
<CardTitle className="text-base">Waitlist</CardTitle>
|
||
|
|
</div>
|
||
|
|
<CardDescription>
|
||
|
|
Per-category ranked waitlist. Auto-cascades when a finalist declines or expires.
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||
|
|
No waitlist entries yet.
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<ListOrdered className="text-muted-foreground h-4 w-4" />
|
||
|
|
<CardTitle className="text-base">Waitlist</CardTitle>
|
||
|
|
</div>
|
||
|
|
<CardDescription>
|
||
|
|
Per-category ranked waitlist. Auto-cascades when a finalist declines or expires. You can
|
||
|
|
manually promote out of order — overrides are audit-logged.
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-5">
|
||
|
|
{Array.from(byCategory.entries()).map(([category, entries]) => (
|
||
|
|
<div key={category}>
|
||
|
|
<div className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide">
|
||
|
|
{formatEnumLabel(category)}
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
{(entries ?? []).map((entry) => {
|
||
|
|
const badge = STATUS_LABEL[entry.status] ?? { label: entry.status, variant: 'outline' as const }
|
||
|
|
const canPromote = entry.status === 'WAITING'
|
||
|
|
const isPending =
|
||
|
|
promoteMutation.isPending && promoteMutation.variables?.waitlistEntryId === entry.id
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={entry.id}
|
||
|
|
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<div className="bg-muted flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold tabular-nums">
|
||
|
|
{entry.rank}
|
||
|
|
</div>
|
||
|
|
<div className="min-w-0">
|
||
|
|
<div className="font-medium">{entry.project.title}</div>
|
||
|
|
<div className="text-muted-foreground text-xs">
|
||
|
|
{entry.project.country ?? '—'}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Badge variant={badge.variant} className="text-xs">
|
||
|
|
{badge.label}
|
||
|
|
</Badge>
|
||
|
|
{canPromote && (
|
||
|
|
<AlertDialog>
|
||
|
|
<AlertDialogTrigger asChild>
|
||
|
|
<Button size="sm" variant="outline" disabled={isPending}>
|
||
|
|
{isPending ? (
|
||
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
'Promote'
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</AlertDialogTrigger>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>Promote this team out of order?</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
{entry.project.title} (rank #{entry.rank}) will be promoted into a
|
||
|
|
finalist slot. A confirmation email will be sent to the team lead
|
||
|
|
with a 24-hour window. This override is audit-logged.
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
|
|
<AlertDialogAction
|
||
|
|
onClick={() =>
|
||
|
|
promoteMutation.mutate({
|
||
|
|
waitlistEntryId: entry.id,
|
||
|
|
windowHours: 24,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
>
|
||
|
|
Promote
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)
|
||
|
|
}
|