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'
|
'use client'
|
||||||
|
|
||||||
|
import { useRef } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { LunchEventConfig } from './lunch-event-config'
|
import { LunchEventConfig } from './lunch-event-config'
|
||||||
import { LunchDishes } from './lunch-dishes'
|
import { LunchDishes } from './lunch-dishes'
|
||||||
import { LunchManifest } from './lunch-manifest'
|
import { LunchManifest } from './lunch-manifest'
|
||||||
|
import { LunchExternals, type LunchExternalsHandle } from './lunch-externals'
|
||||||
|
|
||||||
export function LunchTab({ programId }: { programId: string }) {
|
export function LunchTab({ programId }: { programId: string }) {
|
||||||
const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
|
const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
|
||||||
|
const externalsRef = useRef<LunchExternalsHandle>(null)
|
||||||
if (isLoading || !event) {
|
if (isLoading || !event) {
|
||||||
return <Skeleton className="h-48 w-full" />
|
return <Skeleton className="h-48 w-full" />
|
||||||
}
|
}
|
||||||
@@ -17,8 +20,16 @@ export function LunchTab({ programId }: { programId: string }) {
|
|||||||
{event.enabled && (
|
{event.enabled && (
|
||||||
<>
|
<>
|
||||||
<LunchDishes programId={programId} lunchEventId={event.id} />
|
<LunchDishes programId={programId} lunchEventId={event.id} />
|
||||||
<LunchManifest programId={programId} />
|
<LunchManifest
|
||||||
{/* Externals + recap actions mount in Tasks 17-18. */}
|
programId={programId}
|
||||||
|
onEditExternal={(id) => externalsRef.current?.openEditDialog(id)}
|
||||||
|
/>
|
||||||
|
<LunchExternals
|
||||||
|
ref={externalsRef}
|
||||||
|
programId={programId}
|
||||||
|
lunchEventId={event.id}
|
||||||
|
/>
|
||||||
|
{/* Recap actions card mounts in Task 18. */}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export const lunchRouter = router({
|
|||||||
roleNote: z.string().max(500).optional(),
|
roleNote: z.string().max(500).optional(),
|
||||||
dishId: z.string().nullable().optional(),
|
dishId: z.string().nullable().optional(),
|
||||||
allergens: allergens.optional(),
|
allergens: allergens.optional(),
|
||||||
allergenOther: z.string().max(500).optional(),
|
allergenOther: z.string().max(500).nullable().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -391,4 +391,21 @@ export const programRouter = router({
|
|||||||
|
|
||||||
return { ok: true }
|
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