feat: lunch dishes card with create/edit/delete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
160
src/components/admin/logistics/lunch-dishes.tsx
Normal file
160
src/components/admin/logistics/lunch-dishes.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } 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 { Badge } from '@/components/ui/badge'
|
||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const DIETARY_TAGS = ['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN'] as const
|
||||||
|
type DietaryTag = (typeof DIETARY_TAGS)[number]
|
||||||
|
|
||||||
|
function formatTag(tag: string): string {
|
||||||
|
return tag.replace('_', ' ').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LunchDishes({
|
||||||
|
programId,
|
||||||
|
lunchEventId,
|
||||||
|
}: {
|
||||||
|
programId: string
|
||||||
|
lunchEventId: string
|
||||||
|
}) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
|
||||||
|
|
||||||
|
const invalidateAll = () => {
|
||||||
|
utils.lunch.listDishes.invalidate({ lunchEventId })
|
||||||
|
utils.lunch.getManifest.invalidate({ programId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = trpc.lunch.createDish.useMutation({
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
const update = trpc.lunch.updateDish.useMutation({
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
const del = trpc.lunch.deleteDish.useMutation({
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newTags, setNewTags] = useState<DietaryTag[]>([])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dishes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{dishes && dishes.length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Add at least one dish to open picks.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{dishes?.map((d) => (
|
||||||
|
<li
|
||||||
|
key={d.id}
|
||||||
|
className="flex items-center gap-3 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{d.name}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{d.dietaryTags.map((t) => (
|
||||||
|
<Badge key={t} variant="outline">
|
||||||
|
{formatTag(t)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const name = prompt('Edit dish name', d.name)
|
||||||
|
if (name && name !== d.name) {
|
||||||
|
update.mutate({ dishId: d.id, name })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
`Delete "${d.name}"? Existing picks will go back to "not picked".`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
del.mutate({ dishId: d.id })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
|
||||||
|
<Input
|
||||||
|
placeholder="New dish name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{DIETARY_TAGS.map((t) => (
|
||||||
|
<Button
|
||||||
|
key={t}
|
||||||
|
size="sm"
|
||||||
|
variant={newTags.includes(t) ? 'default' : 'outline'}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setNewTags(
|
||||||
|
newTags.includes(t)
|
||||||
|
? newTags.filter((x) => x !== t)
|
||||||
|
: [...newTags, t],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatTag(t)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={!newName.trim() || create.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!newName.trim()) return
|
||||||
|
create.mutate(
|
||||||
|
{
|
||||||
|
lunchEventId,
|
||||||
|
name: newName.trim(),
|
||||||
|
dietaryTags: newTags,
|
||||||
|
sortOrder: dishes?.length ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewName('')
|
||||||
|
setNewTags([])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-4 w-4" /> Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
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'
|
||||||
|
|
||||||
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 })
|
||||||
@@ -12,7 +13,12 @@ export function LunchTab({ programId }: { programId: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<LunchEventConfig programId={programId} event={event} />
|
<LunchEventConfig programId={programId} event={event} />
|
||||||
{/* Other cards mount in Tasks 15-18 once event.enabled. */}
|
{event.enabled && (
|
||||||
|
<>
|
||||||
|
<LunchDishes programId={programId} lunchEventId={event.id} />
|
||||||
|
{/* Manifest, externals, recap actions mount in Tasks 16-18. */}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user