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:
218
src/components/admin/logistics/lunch-manifest.tsx
Normal file
218
src/components/admin/logistics/lunch-manifest.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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'
|
||||||
|
|
||||||
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 })
|
||||||
@@ -16,7 +17,8 @@ export function LunchTab({ programId }: { programId: string }) {
|
|||||||
{event.enabled && (
|
{event.enabled && (
|
||||||
<>
|
<>
|
||||||
<LunchDishes programId={programId} lunchEventId={event.id} />
|
<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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user