From 051dea4d0e3205c396fcf2accdba10b915b65c8a Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 02:42:49 +0200 Subject: [PATCH] feat: lunch manifest card with filters + CSV export Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/logistics/lunch-manifest.tsx | 218 ++++++++++++++++++ src/components/admin/logistics/lunch-tab.tsx | 4 +- 2 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/components/admin/logistics/lunch-manifest.tsx diff --git a/src/components/admin/logistics/lunch-manifest.tsx b/src/components/admin/logistics/lunch-manifest.tsx new file mode 100644 index 0000000..7f1d339 --- /dev/null +++ b/src/components/admin/logistics/lunch-manifest.tsx @@ -0,0 +1,218 @@ +'use client' + +import { useState, useMemo } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Pencil, Download } from 'lucide-react' +import { toast } from 'sonner' + +function formatAllergens(allergens: string[], other: string | null): string { + return [...allergens.map((a) => a.replace('_', ' ').toLowerCase()), other] + .filter(Boolean) + .join(', ') +} + +function DownloadCsvButton({ programId }: { programId: string }) { + const utils = trpc.useUtils() + const [pending, setPending] = useState(false) + return ( + + ) +} + +export function LunchManifest({ + programId, + onEditExternal, + onEditMember, +}: { + programId: string + onEditExternal?: (externalId: string) => void + onEditMember?: (attendingMemberId: string) => void +}) { + const { data } = trpc.lunch.getManifest.useQuery({ programId }) + const [search, setSearch] = useState('') + const [missingOnly, setMissingOnly] = useState(false) + + type Row = + | (NonNullable['members'][number] & { sortKey: string }) + | (NonNullable['externals'][number] & { sortKey: string }) + + const rows: Row[] = useMemo(() => { + if (!data) return [] + const all: Row[] = [ + ...data.members.map((m) => ({ + ...m, + sortKey: `0-${m.project?.name ?? ''}-${m.name}`, + })), + ...data.externals.map((e) => ({ + ...e, + sortKey: `1-${e.project?.name ?? ''}-${e.name}`, + })), + ] + return all + .filter( + (r) => + !search || + (r.project?.name ?? '').toLowerCase().includes(search.toLowerCase()) || + r.name.toLowerCase().includes(search.toLowerCase()), + ) + .filter((r) => !missingOnly || !r.dish) + .sort((a, b) => a.sortKey.localeCompare(b.sortKey)) + }, [data, search, missingOnly]) + + if (!data) return null + + // Aggregate dietary + allergen counts client-side for the summary chip + const dietaryCounts: Record = {} + const allergenCounts: Record = {} + const allRows = [...data.members, ...data.externals] + for (const r of allRows) { + if (r.dish) { + for (const t of r.dish.dietaryTags) { + dietaryCounts[t] = (dietaryCounts[t] ?? 0) + 1 + } + } + for (const a of r.allergens) { + allergenCounts[a] = (allergenCounts[a] ?? 0) + 1 + } + } + + return ( + + + + Manifest + + {data.summary.picked}/{data.summary.total} picked + {data.summary.missing > 0 ? ` · ${data.summary.missing} missing` : ''} + + {Object.entries(dietaryCounts).map(([tag, n]) => ( + + {n} {tag.replace('_', ' ').toLowerCase()} + + ))} + {Object.entries(allergenCounts).map(([a, n]) => ( + + {n} {a.replace('_', ' ').toLowerCase()} + + ))} + + + +
+ setSearch(e.target.value)} + className="max-w-xs" + /> +
+ + +
+
+ +
+
+ +
+ + + + + + + + + + + + + {rows.map((r) => { + const id = + r.kind === 'MEMBER' ? r.attendingMemberId : r.externalId + return ( + + + + + + + + + ) + })} + {rows.length === 0 && ( + + + + )} + +
TeamAttendeeTypeDishAllergens
{r.project?.name ?? '—'}{r.name} + + {r.kind === 'MEMBER' ? 'Member' : 'External'} + + + {r.dish ? ( + r.dish.name + ) : ( + not picked + )} + + {formatAllergens(r.allergens, r.allergenOther)} + + +
+ No rows match the current filter. +
+
+
+
+ ) +} diff --git a/src/components/admin/logistics/lunch-tab.tsx b/src/components/admin/logistics/lunch-tab.tsx index f55dbab..6317cb5 100644 --- a/src/components/admin/logistics/lunch-tab.tsx +++ b/src/components/admin/logistics/lunch-tab.tsx @@ -4,6 +4,7 @@ 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' export function LunchTab({ programId }: { programId: string }) { const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId }) @@ -16,7 +17,8 @@ export function LunchTab({ programId }: { programId: string }) { {event.enabled && ( <> - {/* Manifest, externals, recap actions mount in Tasks 16-18. */} + + {/* Externals + recap actions mount in Tasks 17-18. */} )}