From 6e5f607425c4cf49261fbdf47e47777913fd0858 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 19:03:01 +0200 Subject: [PATCH] feat: admin can confirm/decline attendance on team behalf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../logistics/admin-attendance-dialog.tsx | 235 +++++++++++++++++ .../admin/logistics/confirmations-tab.tsx | 53 ++++ src/server/routers/finalist.ts | 165 ++++++++++++ tests/unit/finalist-admin-confirm.test.ts | 236 ++++++++++++++++++ 4 files changed, 689 insertions(+) create mode 100644 src/components/admin/logistics/admin-attendance-dialog.tsx create mode 100644 tests/unit/finalist-admin-confirm.test.ts diff --git a/src/components/admin/logistics/admin-attendance-dialog.tsx b/src/components/admin/logistics/admin-attendance-dialog.tsx new file mode 100644 index 0000000..235a84a --- /dev/null +++ b/src/components/admin/logistics/admin-attendance-dialog.tsx @@ -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>(new Set()) + const [visa, setVisa] = useState>({}) + 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 ( + { + if (!isPending) onOpenChange(next) + }} + > + + + + {mode === 'confirm' ? 'Confirm attendance on team behalf' : 'Decline on team behalf'} + + + {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.'} + + + + {isLoading || !detail ? ( +
+ + +
+ ) : mode === 'confirm' ? ( + <> +
+ Project:{' '} + {detail.project.title} +
+
    + {detail.project.teamMembers.map((tm) => { + const checked = selected.has(tm.userId) + return ( +
  • + + {checked && ( + + )} +
  • + ) + })} +
+ {overCap && ( +

+ Please select no more than {cap} member{cap === 1 ? '' : 's'}. +

+ )} + + ) : ( + <> +
+ Project:{' '} + {detail.project.title} +
+
+ +