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>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
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'
|
||||
@@ -14,6 +15,19 @@ import {
|
||||
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'
|
||||
@@ -52,17 +66,52 @@ function relativeFromNow(d: Date): string {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -124,7 +173,7 @@ export function ConfirmationsTab({ programId }: Props) {
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground py-12 text-center text-sm">
|
||||
{statusFilter === 'all'
|
||||
? 'No finalists have been selected yet. Use the grand-finale round page to send confirmations.'
|
||||
? '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>
|
||||
) : (
|
||||
@@ -218,6 +267,43 @@ export function ConfirmationsTab({ programId }: Props) {
|
||||
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>
|
||||
)}
|
||||
@@ -241,6 +327,60 @@ export function ConfirmationsTab({ programId }: Props) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user