Files
MOPC-Portal/src/components/admin/logistics/confirmations-tab.tsx
Matt 8ee517f6ca feat(logistics): waitlist populate UI + confirmations-tab un-confirm/re-invite actions
- waitlist-card: AddToWaitlistForm sub-component wires finalist.addToWaitlist (category + project pickers sourced from listEnrollmentCandidates, filtered to exclude confirmed/waitlisted projects; appends at next rank)
- confirmations-tab: fix dead-end empty-state copy to reference Grand Final round Overview tab
- confirmations-tab: CONFIRMED rows → Un-confirm button (AlertDialog with required reason, calls finalist.unconfirm)
- confirmations-tab: DECLINED/EXPIRED rows → Re-invite button (calls enrollFinalists EMAIL mode via liveFinalRoundId from listEnrollmentCandidates)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:36:15 +02:00

387 lines
15 KiB
TypeScript

'use client'
import { useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Textarea } from '@/components/ui/textarea'
import { Loader2 } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
import type { FinalistConfirmationStatus } from '@prisma/client'
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
interface Props {
programId: string
}
type StatusFilter = 'all' | FinalistConfirmationStatus
const STATUS_BADGE: Record<
FinalistConfirmationStatus,
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
PENDING: { label: 'Pending', variant: 'secondary' },
CONFIRMED: { label: 'Confirmed', variant: 'default' },
DECLINED: { label: 'Declined', variant: 'destructive' },
EXPIRED: { label: 'Expired', variant: 'outline' },
SUPERSEDED: { label: 'Superseded', variant: 'outline' },
}
function formatDeadline(d: Date): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(d)
}
function relativeFromNow(d: Date): string {
const ms = d.getTime() - Date.now()
if (ms <= 0) return 'past deadline'
const hours = Math.floor(ms / 3_600_000)
const days = Math.floor(hours / 24)
if (days >= 1) return `in ${days}d`
return `in ${hours}h`
}
export function ConfirmationsTab({ programId }: Props) {
const utils = trpc.useUtils()
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [dialogState, setDialogState] = useState<{
open: boolean
mode: AttendanceMode
confirmationId: string | null
}>({ open: false, mode: 'confirm', confirmationId: null })
const [unconfirmState, setUnconfirmState] = useState<{
open: boolean
confirmationId: string | null
projectTitle: string
reason: string
}>({ open: false, confirmationId: null, projectTitle: '', reason: '' })
const { data, isLoading } = trpc.logistics.listConfirmations.useQuery(
{ programId },
{ refetchInterval: 60_000 },
)
// Get liveFinalRoundId for re-invite action
const { data: candidatesData } = trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
const liveFinalRoundId = candidatesData?.liveFinalRoundId ?? null
const unconfirmMutation = trpc.finalist.unconfirm.useMutation({
onSuccess: () => {
toast.success('Finalist un-confirmed')
utils.logistics.listConfirmations.invalidate({ programId })
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
setUnconfirmState((prev) => ({ ...prev, open: false, confirmationId: null, reason: '' }))
},
onError: (err) => toast.error(err.message),
})
const reinviteMutation = trpc.finalist.enrollFinalists.useMutation({
onSuccess: (result) => {
if (result.skipped.length > 0) {
toast.info('Re-invite skipped — team is already confirmed')
} else {
toast.success('Re-invite sent')
}
utils.logistics.listConfirmations.invalidate({ programId })
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
const filtered = useMemo(() => {
if (!data) return []
return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter)
}, [data, statusFilter])
const totals = useMemo(() => {
const counts: Record<FinalistConfirmationStatus, number> = {
PENDING: 0,
CONFIRMED: 0,
DECLINED: 0,
EXPIRED: 0,
SUPERSEDED: 0,
}
for (const r of data ?? []) counts[r.status]++
return counts
}, [data])
const StatusPill = ({ value, label, count }: { value: StatusFilter; label: string; count: number }) => (
<button
type="button"
onClick={() => setStatusFilter(value)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
statusFilter === value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-muted'
}`}
>
{label} <span className="tabular-nums opacity-80">({count})</span>
</button>
)
return (
<div className="space-y-4">
<Card>
<CardHeader className="space-y-3">
<div className="flex items-center justify-between gap-4">
<CardTitle className="text-base">All confirmations</CardTitle>
<div className="flex flex-wrap gap-1.5">
<StatusPill
value="all"
label="All"
count={(data ?? []).length}
/>
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
<StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} />
<StatusPill value="DECLINED" label="Declined" count={totals.DECLINED} />
<StatusPill value="EXPIRED" label="Expired" count={totals.EXPIRED} />
<StatusPill value="SUPERSEDED" label="Superseded" count={totals.SUPERSEDED} />
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<p className="text-muted-foreground py-12 text-center text-sm">
{statusFilter === 'all'
? 'No finalists have been selected yet. Enroll finalists from the Grand Final round\'s Overview tab to start confirmations.'
: 'No confirmations match this filter.'}
</p>
) : (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Status</TableHead>
<TableHead>Deadline</TableHead>
<TableHead className="text-right">Attendees</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((r) => {
const badge = STATUS_BADGE[r.status]
const isPending = r.status === 'PENDING'
return (
<TableRow key={r.id}>
<TableCell>
<div className="font-medium">{r.project.title}</div>
<div className="text-muted-foreground text-xs">
{formatEnumLabel(r.category)}
{r.project.country && (
<>
{' · '}
{r.project.country}
</>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={badge.variant} className="text-xs">
{badge.label}
</Badge>
{r.promotedFromWaitlistEntryId && (
<Badge variant="outline" className="ml-1 text-xs">
Waitlist
</Badge>
)}
</TableCell>
<TableCell className="text-sm">
<div>{formatDeadline(new Date(r.deadline))}</div>
{r.status === 'PENDING' && (
<div className="text-muted-foreground text-xs">
{relativeFromNow(new Date(r.deadline))}
</div>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{r.attendeeCount}
</TableCell>
<TableCell className="text-muted-foreground max-w-[20rem] truncate text-xs">
{r.status === 'DECLINED' && r.declineReason
? `Reason: ${r.declineReason}`
: r.status === 'CONFIRMED' && r.confirmedAt
? `Confirmed ${formatDeadline(new Date(r.confirmedAt))}`
: r.status === 'EXPIRED' && r.expiredAt
? `Expired ${formatDeadline(new Date(r.expiredAt))}`
: '—'}
</TableCell>
<TableCell className="text-right">
{isPending ? (
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="default"
onClick={() =>
setDialogState({
open: true,
mode: 'confirm',
confirmationId: r.id,
})
}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setDialogState({
open: true,
mode: 'decline',
confirmationId: r.id,
})
}
>
Decline
</Button>
</div>
) : r.status === 'CONFIRMED' ? (
<Button
size="sm"
variant="outline"
onClick={() =>
setUnconfirmState({
open: true,
confirmationId: r.id,
projectTitle: r.project.title,
reason: '',
})
}
>
Un-confirm
</Button>
) : r.status === 'DECLINED' || r.status === 'EXPIRED' ? (
<Button
size="sm"
variant="outline"
disabled={!liveFinalRoundId || reinviteMutation.isPending}
onClick={() => {
if (!liveFinalRoundId) return
reinviteMutation.mutate({
programId,
roundId: liveFinalRoundId,
enrollments: [{ projectId: r.project.id, mode: 'EMAIL' }],
})
}}
>
{reinviteMutation.isPending &&
reinviteMutation.variables?.enrollments?.[0]?.projectId ===
r.project.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Re-invite'
)}
</Button>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<AdminAttendanceDialog
open={dialogState.open}
mode={dialogState.mode}
confirmationId={dialogState.confirmationId}
programId={programId}
onOpenChange={(next) =>
setDialogState((prev) => ({ ...prev, open: next }))
}
/>
{/* Un-confirm AlertDialog (needs a reason — min 5 chars per the server) */}
<AlertDialog
open={unconfirmState.open}
onOpenChange={(next) => {
if (!unconfirmMutation.isPending)
setUnconfirmState((prev) => ({ ...prev, open: next }))
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Un-confirm this finalist?</AlertDialogTitle>
<AlertDialogDescription>
{unconfirmState.projectTitle} will be moved back to Superseded. Any active mentor
assignment will be dropped and the mentor notified. This action is audit-logged.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="px-1 pb-2">
<label
htmlFor="unconfirm-reason"
className="text-muted-foreground mb-1 block text-sm"
>
Reason <span className="text-destructive">*</span>
</label>
<Textarea
id="unconfirm-reason"
value={unconfirmState.reason}
onChange={(e) =>
setUnconfirmState((prev) => ({ ...prev, reason: e.target.value }))
}
placeholder="e.g. team withdrew, scheduling conflict, administrative correction"
rows={3}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={unconfirmMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={unconfirmState.reason.trim().length < 5 || unconfirmMutation.isPending}
onClick={() => {
if (!unconfirmState.confirmationId) return
unconfirmMutation.mutate({
confirmationId: unconfirmState.confirmationId,
reason: unconfirmState.reason.trim(),
})
}}
>
{unconfirmMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Un-confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}