2026-04-29 02:44:38 +02:00
|
|
|
'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'
|
2026-06-05 12:04:13 +02:00
|
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
|
|
import { Plus, Pencil, Trash2, Mail, MailCheck, Utensils } from 'lucide-react'
|
2026-04-29 02:44:38 +02:00
|
|
|
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),
|
|
|
|
|
})
|
2026-06-05 12:04:13 +02:00
|
|
|
const sendInvite = trpc.lunch.sendExternalInvite.useMutation({
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
invalidateAll()
|
|
|
|
|
toast.success('Dish invite sent')
|
|
|
|
|
},
|
|
|
|
|
onError: (e) => toast.error(e.message),
|
|
|
|
|
})
|
2026-04-29 02:44:38 +02:00
|
|
|
|
|
|
|
|
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>
|
2026-06-05 12:04:13 +02:00
|
|
|
<td>
|
|
|
|
|
{e.dishId ? (
|
|
|
|
|
<Badge variant="secondary" className="gap-1">
|
|
|
|
|
<Utensils className="h-3 w-3" /> Picked
|
|
|
|
|
</Badge>
|
|
|
|
|
) : !e.email ? (
|
|
|
|
|
<Badge variant="outline" className="text-muted-foreground">
|
|
|
|
|
No email
|
|
|
|
|
</Badge>
|
|
|
|
|
) : e.inviteSentAt ? (
|
|
|
|
|
<Badge variant="outline" className="gap-1">
|
|
|
|
|
<MailCheck className="h-3 w-3" /> Invited
|
|
|
|
|
</Badge>
|
|
|
|
|
) : (
|
|
|
|
|
<Badge variant="outline" className="text-muted-foreground">
|
|
|
|
|
Not invited
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
2026-04-29 02:44:38 +02:00
|
|
|
<td className="text-right">
|
2026-06-05 12:04:13 +02:00
|
|
|
{e.email && !e.dishId && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
title={e.inviteSentAt ? 'Resend dish invite' : 'Send dish invite'}
|
|
|
|
|
disabled={
|
|
|
|
|
sendInvite.isPending &&
|
|
|
|
|
sendInvite.variables?.externalId === e.id
|
|
|
|
|
}
|
|
|
|
|
onClick={() => sendInvite.mutate({ externalId: e.id })}
|
|
|
|
|
>
|
|
|
|
|
<Mail className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-04-29 02:44:38 +02:00
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
}
|