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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 02:44:38 +02:00
parent 051dea4d0e
commit bbfe2d8097
4 changed files with 372 additions and 3 deletions

View File

@@ -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<Editing>(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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>External attendees</span>
<Button size="sm" onClick={() => setEditing({ mode: 'new' })}>
<Plus className="mr-1 h-4 w-4" /> Add external
</Button>
</CardTitle>
</CardHeader>
<CardContent>
{externals?.length === 0 && (
<p className="text-muted-foreground text-sm">
No external attendees yet. Add jurors, dignitaries, or per-team plus-ones.
</p>
)}
{externals && externals.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<tbody>
{externals.map((e) => (
<tr key={e.id} className="border-b last:border-b-0">
<td className="py-2 font-medium">{e.name}</td>
<td className="text-muted-foreground">
{e.project?.title ?? 'Standalone'}
</td>
<td className="text-muted-foreground">{e.roleNote ?? ''}</td>
<td className="text-right">
<Button
size="sm"
variant="ghost"
onClick={() => setEditing({ mode: 'edit', id: e.id })}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
if (confirm(`Delete external attendee "${e.name}"?`)) {
del.mutate({ externalId: e.id })
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
{editing && (
<ExternalDialog
mode={editing.mode}
initial={editingRow}
dishes={dishes ?? []}
projects={projects ?? []}
submitting={create.isPending || update.isPending}
onClose={() => 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) },
)
}
}}
/>
)}
</Card>
)
})
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<Allergen[]>(
(initial?.allergens as Allergen[]) ?? [],
)
const [allergenOther, setAllergenOther] = useState(initial?.allergenOther ?? '')
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose() }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{mode === 'new' ? 'Add external attendee' : 'Edit external attendee'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Name *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<Label>Email (optional)</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<Label>Project (optional)</Label>
<Select
value={projectId === '' ? STANDALONE : projectId}
onValueChange={(v) => setProjectId(v === STANDALONE ? '' : v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={STANDALONE}>Standalone</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Role / note (optional)</Label>
<Input
value={roleNote}
onChange={(e) => setRoleNote(e.target.value)}
placeholder="e.g. Foundation rep, Speaker, Sponsor"
/>
</div>
<div>
<Label>Dish</Label>
<Select
value={dishId === '' ? NO_DISH : dishId}
onValueChange={(v) => setDishId(v === NO_DISH ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="Not picked" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_DISH}>Not picked</SelectItem>
{dishes.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Allergens</Label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{ALLERGENS.map((a) => (
<label key={a} className="flex items-center gap-2 text-sm">
<Checkbox
checked={allergens.includes(a)}
onCheckedChange={(v) =>
setAllergens(
v ? [...allergens, a] : allergens.filter((x) => x !== a),
)
}
/>
{a.replace('_', ' ').toLowerCase()}
</label>
))}
</div>
</div>
<div>
<Label>Other allergens / notes (optional)</Label>
<Textarea
value={allergenOther}
onChange={(e) => setAllergenOther(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
disabled={!name.trim() || submitting}
onClick={() =>
onSubmit({
name: name.trim(),
email: email.trim() || undefined,
projectId: projectId || null,
roleNote: roleNote.trim() || undefined,
dishId: dishId || null,
allergens,
allergenOther: allergenOther.trim() || null,
})
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,13 +1,16 @@
'use client'
import { useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Skeleton } from '@/components/ui/skeleton'
import { LunchEventConfig } from './lunch-event-config'
import { LunchDishes } from './lunch-dishes'
import { LunchManifest } from './lunch-manifest'
import { LunchExternals, type LunchExternalsHandle } from './lunch-externals'
export function LunchTab({ programId }: { programId: string }) {
const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
const externalsRef = useRef<LunchExternalsHandle>(null)
if (isLoading || !event) {
return <Skeleton className="h-48 w-full" />
}
@@ -17,8 +20,16 @@ export function LunchTab({ programId }: { programId: string }) {
{event.enabled && (
<>
<LunchDishes programId={programId} lunchEventId={event.id} />
<LunchManifest programId={programId} />
{/* Externals + recap actions mount in Tasks 17-18. */}
<LunchManifest
programId={programId}
onEditExternal={(id) => externalsRef.current?.openEditDialog(id)}
/>
<LunchExternals
ref={externalsRef}
programId={programId}
lunchEventId={event.id}
/>
{/* Recap actions card mounts in Task 18. */}
</>
)}
</div>