feat: lunch manifest card with filters + CSV export

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 02:42:49 +02:00
parent 939a13c0e8
commit 051dea4d0e
2 changed files with 221 additions and 1 deletions

View File

@@ -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 (
<Button
variant="outline"
size="sm"
disabled={pending}
onClick={async () => {
setPending(true)
try {
const csv = await utils.client.lunch.exportManifestCsv.query({ programId })
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'lunch-manifest.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e) {
toast.error((e as Error).message)
} finally {
setPending(false)
}
}}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
)
}
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<typeof data>['members'][number] & { sortKey: string })
| (NonNullable<typeof data>['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<string, number> = {}
const allergenCounts: Record<string, number> = {}
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 (
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center gap-2">
<span>Manifest</span>
<Badge variant="outline">
{data.summary.picked}/{data.summary.total} picked
{data.summary.missing > 0 ? ` · ${data.summary.missing} missing` : ''}
</Badge>
{Object.entries(dietaryCounts).map(([tag, n]) => (
<Badge key={tag} variant="secondary">
{n} {tag.replace('_', ' ').toLowerCase()}
</Badge>
))}
{Object.entries(allergenCounts).map(([a, n]) => (
<Badge key={a} variant="destructive">
{n} {a.replace('_', ' ').toLowerCase()}
</Badge>
))}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-4">
<Input
placeholder="Filter by team or name"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
/>
<div className="flex items-center gap-2">
<Switch
id="missing-only"
checked={missingOnly}
onCheckedChange={setMissingOnly}
/>
<Label htmlFor="missing-only">Missing picks only</Label>
</div>
<div className="ml-auto">
<DownloadCsvButton programId={programId} />
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left">
<tr>
<th className="py-2 font-medium">Team</th>
<th className="font-medium">Attendee</th>
<th className="font-medium">Type</th>
<th className="font-medium">Dish</th>
<th className="font-medium">Allergens</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const id =
r.kind === 'MEMBER' ? r.attendingMemberId : r.externalId
return (
<tr key={id} className="border-b">
<td className="py-2">{r.project?.name ?? '—'}</td>
<td>{r.name}</td>
<td>
<Badge variant="outline">
{r.kind === 'MEMBER' ? 'Member' : 'External'}
</Badge>
</td>
<td>
{r.dish ? (
r.dish.name
) : (
<span className="text-muted-foreground">not picked</span>
)}
</td>
<td className="text-muted-foreground">
{formatAllergens(r.allergens, r.allergenOther)}
</td>
<td className="text-right">
<Button
size="sm"
variant="ghost"
onClick={() => {
if (r.kind === 'EXTERNAL') {
onEditExternal?.(r.externalId)
} else {
onEditMember?.(r.attendingMemberId)
}
}}
>
<Pencil className="h-4 w-4" />
</Button>
</td>
</tr>
)
})}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="text-muted-foreground py-6 text-center">
No rows match the current filter.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
)
}

View File

@@ -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 && (
<>
<LunchDishes programId={programId} lunchEventId={event.id} />
{/* Manifest, externals, recap actions mount in Tasks 16-18. */}
<LunchManifest programId={programId} />
{/* Externals + recap actions mount in Tasks 17-18. */}
</>
)}
</div>