From bbfe2d8097abb5ddacbe7f45dd9e05b13bae6b63 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 02:44:38 +0200 Subject: [PATCH] feat: external lunch attendees card + dialog Adds program.listFinalistProjects helper. Externals dialog supports both standalone and project-attached entries; manifest's external row edit-pencil opens this dialog via forwardRef. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/logistics/lunch-externals.tsx | 341 ++++++++++++++++++ src/components/admin/logistics/lunch-tab.tsx | 15 +- src/server/routers/lunch.ts | 2 +- src/server/routers/program.ts | 17 + 4 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 src/components/admin/logistics/lunch-externals.tsx diff --git a/src/components/admin/logistics/lunch-externals.tsx b/src/components/admin/logistics/lunch-externals.tsx new file mode 100644 index 0000000..42f2194 --- /dev/null +++ b/src/components/admin/logistics/lunch-externals.tsx @@ -0,0 +1,341 @@ +'use client' + +import { useState, useImperativeHandle, forwardRef } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Plus, Pencil, Trash2 } from 'lucide-react' +import { toast } from 'sonner' + +const ALLERGENS = [ + 'GLUTEN', + 'CRUSTACEANS', + 'EGGS', + 'FISH', + 'PEANUTS', + 'SOYBEANS', + 'MILK', + 'TREE_NUTS', + 'CELERY', + 'MUSTARD', + 'SESAME', + 'SULPHITES', + 'LUPIN', + 'MOLLUSCS', +] as const +type Allergen = (typeof ALLERGENS)[number] + +const STANDALONE = '__standalone__' +const NO_DISH = '__no_dish__' + +type Editing = { mode: 'new' } | { mode: 'edit'; id: string } | null + +export type LunchExternalsHandle = { + openEditDialog: (id: string) => void +} + +export const LunchExternals = forwardRef< + LunchExternalsHandle, + { programId: string; lunchEventId: string } +>(function LunchExternals({ programId, lunchEventId }, ref) { + const utils = trpc.useUtils() + const { data: externals } = trpc.lunch.listExternals.useQuery({ lunchEventId }) + const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId }) + const { data: projects } = trpc.program.listFinalistProjects.useQuery({ + programId, + }) + + const [editing, setEditing] = useState(null) + + useImperativeHandle( + ref, + () => ({ + openEditDialog: (id: string) => setEditing({ mode: 'edit', id }), + }), + [], + ) + + const invalidateAll = () => { + utils.lunch.listExternals.invalidate({ lunchEventId }) + utils.lunch.getManifest.invalidate({ programId }) + } + + const create = trpc.lunch.createExternal.useMutation({ + onSuccess: invalidateAll, + onError: (e) => toast.error(e.message), + }) + const update = trpc.lunch.updateExternal.useMutation({ + onSuccess: invalidateAll, + onError: (e) => toast.error(e.message), + }) + const del = trpc.lunch.deleteExternal.useMutation({ + onSuccess: invalidateAll, + onError: (e) => toast.error(e.message), + }) + + const editingRow = + editing?.mode === 'edit' + ? (externals?.find((e) => e.id === editing.id) ?? null) + : null + + return ( + + + + External attendees + + + + + {externals?.length === 0 && ( +

+ No external attendees yet. Add jurors, dignitaries, or per-team plus-ones. +

+ )} + {externals && externals.length > 0 && ( +
+ + + {externals.map((e) => ( + + + + + + + ))} + +
{e.name} + {e.project?.title ?? 'Standalone'} + {e.roleNote ?? ''} + + +
+
+ )} +
+ + {editing && ( + setEditing(null)} + onSubmit={(values) => { + if (editing.mode === 'new') { + create.mutate( + { lunchEventId, ...values }, + { onSuccess: () => setEditing(null) }, + ) + } else { + update.mutate( + { externalId: editing.id, ...values }, + { onSuccess: () => setEditing(null) }, + ) + } + }} + /> + )} +
+ ) +}) + +function ExternalDialog({ + mode, + initial, + dishes, + projects, + submitting, + onClose, + onSubmit, +}: { + mode: 'new' | 'edit' + initial: { + name: string + email: string | null + projectId: string | null + roleNote: string | null + dishId: string | null + allergens: string[] + allergenOther: string | null + } | null + dishes: Array<{ id: string; name: string }> + projects: Array<{ id: string; title: string }> + submitting: boolean + onClose: () => void + onSubmit: (values: { + name: string + email?: string + projectId?: string | null + roleNote?: string + dishId?: string | null + allergens: Allergen[] + allergenOther?: string | null + }) => void +}) { + const [name, setName] = useState(initial?.name ?? '') + const [email, setEmail] = useState(initial?.email ?? '') + const [projectId, setProjectId] = useState(initial?.projectId ?? '') + const [roleNote, setRoleNote] = useState(initial?.roleNote ?? '') + const [dishId, setDishId] = useState(initial?.dishId ?? '') + const [allergens, setAllergens] = useState( + (initial?.allergens as Allergen[]) ?? [], + ) + const [allergenOther, setAllergenOther] = useState(initial?.allergenOther ?? '') + + return ( + { if (!o) onClose() }}> + + + + {mode === 'new' ? 'Add external attendee' : 'Edit external attendee'} + + +
+
+ + setName(e.target.value)} /> +
+
+ + setEmail(e.target.value)} + /> +
+
+ + +
+
+ + setRoleNote(e.target.value)} + placeholder="e.g. Foundation rep, Speaker, Sponsor" + /> +
+
+ + +
+
+ +
+ {ALLERGENS.map((a) => ( + + ))} +
+
+
+ +