feat: admin can confirm/decline attendance on team behalf

This edition is being handled manually via email — admins need to
record what each finalist replied. Adds:
  - finalist.adminConfirm — flips PENDING → CONFIRMED with attendees +
    visa flags. Same cap and team-membership checks as the public flow,
    audit-logged as FINALIST_ADMIN_CONFIRM.
  - finalist.adminDecline — flips PENDING → DECLINED with optional
    reason and triggers waitlist promotion. Audit-logged as
    FINALIST_ADMIN_DECLINE.
  - finalist.getConfirmationDetail — feeds the admin attendee picker.
  - Per-row Confirm / Decline actions on the Logistics > Confirmations
    table (PENDING rows only) backed by a shared dialog that switches
    between attendee-picker and reason-input modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 19:03:01 +02:00
parent ff355ee10e
commit 6e5f607425
4 changed files with 689 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export type AttendanceMode = 'confirm' | 'decline'
export function AdminAttendanceDialog({
open,
mode,
confirmationId,
programId,
onOpenChange,
}: {
open: boolean
mode: AttendanceMode
confirmationId: string | null
programId: string
onOpenChange: (next: boolean) => void
}) {
const utils = trpc.useUtils()
const enabled = open && !!confirmationId
const { data: detail, isLoading } = trpc.finalist.getConfirmationDetail.useQuery(
{ confirmationId: confirmationId ?? '' },
{ enabled },
)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [visa, setVisa] = useState<Record<string, boolean>>({})
const [reason, setReason] = useState('')
const invalidate = () => {
utils.logistics.listConfirmations.invalidate({ programId })
}
const confirmMutation = trpc.finalist.adminConfirm.useMutation({
onSuccess: () => {
toast.success('Attendance confirmed')
invalidate()
onOpenChange(false)
},
onError: (e) => toast.error(e.message),
})
const declineMutation = trpc.finalist.adminDecline.useMutation({
onSuccess: () => {
toast.success('Marked as declined')
invalidate()
onOpenChange(false)
},
onError: (e) => toast.error(e.message),
})
// Reset form when the dialog opens for a new row
useEffect(() => {
if (!open) return
setReason('')
if (detail) {
// Default-pre-select the team lead + up to cap members
const cap = detail.project.program.defaultAttendeeCap
const initial = new Set(
detail.project.teamMembers.slice(0, cap).map((tm) => tm.userId),
)
setSelected(initial)
setVisa({})
}
}, [open, detail])
const isPending = confirmMutation.isPending || declineMutation.isPending
const handleConfirm = () => {
if (!confirmationId) return
const ids = Array.from(selected)
confirmMutation.mutate({
confirmationId,
attendingUserIds: ids,
visaFlags: Object.fromEntries(ids.map((id) => [id, !!visa[id]])),
})
}
const handleDecline = () => {
if (!confirmationId) return
declineMutation.mutate({
confirmationId,
reason: reason.trim() || undefined,
})
}
const cap = detail?.project.program.defaultAttendeeCap ?? 3
const overCap = selected.size > cap
const noneSelected = selected.size === 0
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!isPending) onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{mode === 'confirm' ? 'Confirm attendance on team behalf' : 'Decline on team behalf'}
</DialogTitle>
<DialogDescription>
{mode === 'confirm'
? 'Use this when the team replied by email. The selected attendees will be locked in just like a public confirmation.'
: 'Use this when the team has told us they cannot attend. The slot will cascade to the next waitlist entry.'}
</DialogDescription>
</DialogHeader>
{isLoading || !detail ? (
<div className="space-y-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-20 w-full" />
</div>
) : mode === 'confirm' ? (
<>
<div className="text-sm">
<span className="text-muted-foreground">Project:</span>{' '}
<strong>{detail.project.title}</strong>
</div>
<ul className="space-y-2 max-h-[50vh] overflow-y-auto pr-1">
{detail.project.teamMembers.map((tm) => {
const checked = selected.has(tm.userId)
return (
<li
key={tm.userId}
className="flex items-start justify-between gap-4 rounded-md border px-3 py-2"
>
<label className="flex flex-1 items-start gap-3 cursor-pointer">
<Checkbox
checked={checked}
onCheckedChange={(c) => {
setSelected((prev) => {
const next = new Set(prev)
if (c === true) next.add(tm.userId)
else next.delete(tm.userId)
return next
})
}}
className="mt-0.5"
/>
<div>
<div className="text-sm font-medium">
{tm.user.name ?? tm.user.email}
</div>
<div className="text-muted-foreground text-xs">
{tm.user.email}
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
</div>
</div>
</label>
{checked && (
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Visa?</span>
<Switch
checked={!!visa[tm.userId]}
onCheckedChange={(c) =>
setVisa((prev) => ({ ...prev, [tm.userId]: c }))
}
/>
</label>
)}
</li>
)
})}
</ul>
{overCap && (
<p className="text-destructive text-sm">
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
</p>
)}
</>
) : (
<>
<div className="text-sm">
<span className="text-muted-foreground">Project:</span>{' '}
<strong>{detail.project.title}</strong>
</div>
<div className="space-y-2">
<label className="text-muted-foreground text-sm" htmlFor="admin-decline-reason">
Reason (optional)
</label>
<Textarea
id="admin-decline-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. logistics conflict, team disbanded, no longer interested"
rows={3}
/>
</div>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
{mode === 'confirm' ? (
<Button
onClick={handleConfirm}
disabled={!detail || overCap || noneSelected || isPending}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm attendance
</Button>
) : (
<Button
onClick={handleDecline}
disabled={!detail || isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Mark as declined
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -13,8 +13,10 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { formatEnumLabel } from '@/lib/utils'
import type { FinalistConfirmationStatus } from '@prisma/client'
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
interface Props {
programId: string
@@ -51,6 +53,11 @@ function relativeFromNow(d: Date): string {
export function ConfirmationsTab({ programId }: Props) {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [dialogState, setDialogState] = useState<{
open: boolean
mode: AttendanceMode
confirmationId: string | null
}>({ open: false, mode: 'confirm', confirmationId: null })
const { data, isLoading } = trpc.logistics.listConfirmations.useQuery(
{ programId },
{ refetchInterval: 60_000 },
@@ -130,11 +137,13 @@ export function ConfirmationsTab({ programId }: Props) {
<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>
@@ -179,6 +188,40 @@ export function ConfirmationsTab({ programId }: Props) {
? `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>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
</TableRow>
)
})}
@@ -188,6 +231,16 @@ export function ConfirmationsTab({ programId }: Props) {
)}
</CardContent>
</Card>
<AdminAttendanceDialog
open={dialogState.open}
mode={dialogState.mode}
confirmationId={dialogState.confirmationId}
programId={programId}
onOpenChange={(next) =>
setDialogState((prev) => ({ ...prev, open: next }))
}
/>
</div>
)
}