Files
MOPC-Portal/src/components/admin/grand-finale/waitlist-card.tsx

317 lines
11 KiB
TypeScript
Raw Normal View History

'use client'
import { useState } from 'react'
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ListOrdered, Loader2, PlusCircle } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
import type { CompetitionCategory } from '@prisma/client'
interface Props {
programId: string
}
function AddToWaitlistForm({ programId }: { programId: string }) {
const utils = trpc.useUtils()
const [category, setCategory] = useState<string>('')
const [projectId, setProjectId] = useState<string>('')
const { data: candidatesData, isLoading: loadingCandidates } =
trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
const { data: waitlistData } = trpc.finalist.listWaitlist.useQuery({ programId })
const addMutation = trpc.finalist.addToWaitlist.useMutation({
onSuccess: () => {
toast.success('Project added to waitlist')
utils.finalist.listWaitlist.invalidate({ programId })
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
setProjectId('')
},
onError: (err) => toast.error(err.message),
})
// Build set of project IDs already on the waitlist
const waitlistedProjectIds = new Set(
(waitlistData ?? [])
.filter((e) => e.status === 'WAITING' || e.status === 'PROMOTED')
.map((e) => e.projectId),
)
// Candidates per selected category — exclude confirmed/waitlisted
const categoryData = candidatesData?.categories.find((c) => c.category === category)
const availableCandidates = (categoryData?.candidates ?? []).filter(
(c) =>
!waitlistedProjectIds.has(c.projectId) &&
c.confirmationStatus !== 'CONFIRMED',
)
// Category options (only categories that have candidates)
const categoryOptions = (candidatesData?.categories ?? []).filter(
(c) =>
c.candidates.some(
(p) =>
!waitlistedProjectIds.has(p.projectId) &&
p.confirmationStatus !== 'CONFIRMED',
),
)
// Derive the next rank for the selected category
const currentMaxRank = Math.max(
0,
...(waitlistData ?? [])
.filter((e) => e.category === category)
.map((e) => e.rank),
)
const nextRank = currentMaxRank + 1
const canSubmit = !!category && !!projectId && !addMutation.isPending
if (loadingCandidates) return <Skeleton className="h-10 w-full" />
return (
<div className="border-t pt-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<PlusCircle className="text-muted-foreground h-4 w-4" />
Add to waitlist
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-[160px]">
<Select
value={category}
onValueChange={(v) => {
setCategory(v)
setProjectId('')
}}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{categoryOptions.length === 0 ? (
<SelectItem value="__none__" disabled>
No eligible categories
</SelectItem>
) : (
categoryOptions.map((c) => (
<SelectItem key={c.category} value={c.category}>
{formatEnumLabel(c.category)}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="min-w-[220px] flex-1">
<Select
value={projectId}
onValueChange={setProjectId}
disabled={!category || availableCandidates.length === 0}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={category ? 'Select project' : 'Choose category first'} />
</SelectTrigger>
<SelectContent>
{availableCandidates.length === 0 ? (
<SelectItem value="__none__" disabled>
No eligible projects
</SelectItem>
) : (
availableCandidates.map((c) => (
<SelectItem key={c.projectId} value={c.projectId}>
{c.title}
{c.country ? ` · ${c.country}` : ''}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<Button
size="sm"
disabled={!canSubmit}
onClick={() =>
addMutation.mutate({
programId,
category: category as CompetitionCategory,
projectId,
rank: nextRank,
})
}
>
{addMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Add at end'
)}
</Button>
</div>
</div>
)
}
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 className="space-y-4">
<p className="text-muted-foreground py-4 text-center text-sm">
No waitlist entries yet.
</p>
<AddToWaitlistForm programId={programId} />
</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>
))}
<AddToWaitlistForm programId={programId} />
</CardContent>
</Card>
)
}