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:
341
src/components/admin/logistics/lunch-externals.tsx
Normal file
341
src/components/admin/logistics/lunch-externals.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -153,7 +153,7 @@ export const lunchRouter = router({
|
||||
roleNote: z.string().max(500).optional(),
|
||||
dishId: z.string().nullable().optional(),
|
||||
allergens: allergens.optional(),
|
||||
allergenOther: z.string().max(500).optional(),
|
||||
allergenOther: z.string().max(500).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -391,4 +391,21 @@ export const programRouter = router({
|
||||
|
||||
return { ok: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List CONFIRMED finalist projects for a program — used by the lunch
|
||||
* externals dialog to attach an external attendee to a team.
|
||||
*/
|
||||
listFinalistProjects: adminProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(({ ctx, input }) =>
|
||||
ctx.prisma.project.findMany({
|
||||
where: {
|
||||
programId: input.programId,
|
||||
finalistConfirmation: { status: 'CONFIRMED' },
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
orderBy: { title: 'asc' },
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user