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:
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -17,7 +18,14 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { ListOrdered, Loader2 } from 'lucide-react'
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { ListOrdered, Loader2, PlusCircle } from 'lucide-react'
|
||||||
import { formatEnumLabel } from '@/lib/utils'
|
import { formatEnumLabel } from '@/lib/utils'
|
||||||
import type { CompetitionCategory } from '@prisma/client'
|
import type { CompetitionCategory } from '@prisma/client'
|
||||||
|
|
||||||
@@ -25,6 +33,145 @@ interface Props {
|
|||||||
programId: string
|
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' }> = {
|
const STATUS_LABEL: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
|
||||||
WAITING: { label: 'Waiting', variant: 'outline' },
|
WAITING: { label: 'Waiting', variant: 'outline' },
|
||||||
PROMOTED: { label: 'Promoted', variant: 'default' },
|
PROMOTED: { label: 'Promoted', variant: 'default' },
|
||||||
@@ -65,10 +212,11 @@ export function WaitlistCard({ programId }: Props) {
|
|||||||
Per-category ranked waitlist. Auto-cascades when a finalist declines or expires.
|
Per-category ranked waitlist. Auto-cascades when a finalist declines or expires.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||||
No waitlist entries yet.
|
No waitlist entries yet.
|
||||||
</p>
|
</p>
|
||||||
|
<AddToWaitlistForm programId={programId} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -161,6 +309,7 @@ export function WaitlistCard({ programId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<AddToWaitlistForm programId={programId} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
@@ -14,6 +15,19 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { formatEnumLabel } from '@/lib/utils'
|
||||||
import type { FinalistConfirmationStatus } from '@prisma/client'
|
import type { FinalistConfirmationStatus } from '@prisma/client'
|
||||||
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
|
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
|
||||||
@@ -52,17 +66,52 @@ function relativeFromNow(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmationsTab({ programId }: Props) {
|
export function ConfirmationsTab({ programId }: Props) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
const [dialogState, setDialogState] = useState<{
|
const [dialogState, setDialogState] = useState<{
|
||||||
open: boolean
|
open: boolean
|
||||||
mode: AttendanceMode
|
mode: AttendanceMode
|
||||||
confirmationId: string | null
|
confirmationId: string | null
|
||||||
}>({ open: false, mode: 'confirm', confirmationId: 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(
|
const { data, isLoading } = trpc.logistics.listConfirmations.useQuery(
|
||||||
{ programId },
|
{ programId },
|
||||||
{ refetchInterval: 60_000 },
|
{ 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(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!data) return []
|
if (!data) return []
|
||||||
return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter)
|
return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter)
|
||||||
@@ -124,7 +173,7 @@ export function ConfirmationsTab({ programId }: Props) {
|
|||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<p className="text-muted-foreground py-12 text-center text-sm">
|
<p className="text-muted-foreground py-12 text-center text-sm">
|
||||||
{statusFilter === 'all'
|
{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.'}
|
: 'No confirmations match this filter.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -218,6 +267,43 @@ export function ConfirmationsTab({ programId }: Props) {
|
|||||||
Decline
|
Decline
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
<span className="text-muted-foreground text-xs">—</span>
|
||||||
)}
|
)}
|
||||||
@@ -241,6 +327,60 @@ export function ConfirmationsTab({ programId }: Props) {
|
|||||||
setDialogState((prev) => ({ ...prev, open: 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user