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'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -17,7 +18,14 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} 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 type { CompetitionCategory } from '@prisma/client'
|
||||
|
||||
@@ -25,6 +33,145 @@ 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' },
|
||||
@@ -65,10 +212,11 @@ export function WaitlistCard({ programId }: Props) {
|
||||
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">
|
||||
<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>
|
||||
)
|
||||
@@ -161,6 +309,7 @@ export function WaitlistCard({ programId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<AddToWaitlistForm programId={programId} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user