From d3af826e3a23e66bd132905c91e946dd5e2b6a5a Mon Sep 17 00:00:00 2001 From: Bonna Moon Date: Mon, 29 Jun 2026 18:25:07 -0500 Subject: [PATCH] =?UTF-8?q?v0.20.0=20=E2=80=94=20Pantry:=20multi-tag=20ite?= =?UTF-8?q?ms=20(e.g.=20Pre-cooked=20+=20Poultry)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses existing Label/LabelOnItem tables — no schema change. Tags field in the add/edit dialog: type + Enter/comma to add, backspace to remove, autocomplete from previously-used labels. Tags display as chips on each item row. API: POST/PATCH accept tags[], upsert Label rows, sync LabelOnItem. Catalog endpoint also returns all label names for autocomplete. Co-Authored-By: Claude Sonnet 4.6 --- src/app/(dashboard)/kitchen/pantry/page.tsx | 2 + src/app/api/pantry/[id]/route.ts | 44 ++++++++-- src/app/api/pantry/catalog/route.ts | 16 ++-- src/app/api/pantry/route.ts | 24 +++++- src/components/pantry/pantry-client.tsx | 8 ++ src/components/pantry/pantry-item-dialog.tsx | 90 ++++++++++++++++++++ src/lib/changelog.ts | 7 ++ src/lib/version.ts | 2 +- 8 files changed, 176 insertions(+), 17 deletions(-) diff --git a/src/app/(dashboard)/kitchen/pantry/page.tsx b/src/app/(dashboard)/kitchen/pantry/page.tsx index 71d21c3..50df554 100644 --- a/src/app/(dashboard)/kitchen/pantry/page.tsx +++ b/src/app/(dashboard)/kitchen/pantry/page.tsx @@ -15,6 +15,7 @@ export default async function PantryPage() { pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true, location: { select: { id: true, name: true } }, plant: { select: { id: true, commonName: true, variety: true } }, + labels: { select: { label: { select: { name: true } } } }, }, }), db.location.findMany({ @@ -50,6 +51,7 @@ export default async function PantryPage() { locationName: i.location?.name ?? null, plantId: i.plant?.id ?? null, plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null, + tags: i.labels.map((l: { label: { name: string } }) => l.label.name), })); return ; diff --git a/src/app/api/pantry/[id]/route.ts b/src/app/api/pantry/[id]/route.ts index 9c595de..c56ab36 100644 --- a/src/app/api/pantry/[id]/route.ts +++ b/src/app/api/pantry/[id]/route.ts @@ -4,6 +4,29 @@ import { ItemSource } from "@prisma/client"; import { db } from "@/lib/db"; import { requireAuth, isAdmin } from "@/lib/auth"; +const PANTRY_SELECT = { + id: true, name: true, description: true, quantity: true, unit: true, + storedAt: true, bestBefore: true, source: true, plantId: true, notes: true, + pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true, + location: { select: { id: true, name: true } }, + plant: { select: { id: true, commonName: true, variety: true } }, + labels: { select: { label: { select: { id: true, name: true } } } }, +} as const; + +async function syncTags(itemId: string, tags: string[]) { + const names = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean))); + const labels = await Promise.all( + names.map(name => db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } })) + ); + await db.labelOnItem.deleteMany({ where: { itemId } }); + if (labels.length) { + await db.labelOnItem.createMany({ + data: labels.map(l => ({ itemId, labelId: l.id })), + skipDuplicates: true, + }); + } +} + const updateSchema = z.object({ name: z.string().min(1).optional(), description: z.string().nullable().optional(), @@ -20,6 +43,7 @@ const updateSchema = z.object({ pricePaid: z.number().nullable().optional(), priceUnit: z.string().nullable().optional(), notes: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), }); export async function PATCH(req: Request, { params }: { params: { id: string } }) { @@ -27,7 +51,7 @@ export async function PATCH(req: Request, { params }: { params: { id: string } } const session = await requireAuth(); const data = updateSchema.parse(await req.json()); - const item = await db.item.update({ + await db.item.update({ where: { id: params.id }, data: { ...(data.name !== undefined && { name: data.name }), @@ -45,20 +69,22 @@ export async function PATCH(req: Request, { params }: { params: { id: string } } ...(data.priceUnit !== undefined && { priceUnit: data.priceUnit ?? null }), ...(data.notes !== undefined && { notes: data.notes ?? null }), }, - select: { - id: true, name: true, description: true, quantity: true, unit: true, - storedAt: true, bestBefore: true, source: true, plantId: true, notes: true, - pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true, - location: { select: { id: true, name: true } }, - plant: { select: { id: true, commonName: true, variety: true } }, - }, }); + if (data.tags !== undefined) await syncTags(params.id, data.tags); + + const item = await db.item.findUniqueOrThrow({ where: { id: params.id }, select: PANTRY_SELECT }); + await db.auditEvent.create({ data: { action: "pantry.update", entityId: item.id, actorId: session.user.id, payload: { name: item.name } }, }); - return NextResponse.json(item); + return NextResponse.json({ + ...item, + quantity: Number(item.quantity), + pricePaid: item.pricePaid != null ? Number(item.pricePaid) : null, + tags: item.labels.map(l => l.label.name), + }); } catch (err) { if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 }); if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/src/app/api/pantry/catalog/route.ts b/src/app/api/pantry/catalog/route.ts index 3e0399c..e0427ee 100644 --- a/src/app/api/pantry/catalog/route.ts +++ b/src/app/api/pantry/catalog/route.ts @@ -32,12 +32,15 @@ export async function GET(req: Request) { if (!seen.has(key)) seen.set(key, item); } - // Also return distinct categories/subcategories for autocomplete dropdowns. - const allCats = await db.item.findMany({ - where: { kind: "CONSUMABLE", pantryCategory: { not: null } }, - select: { pantryCategory: true, pantrySubcategory: true }, - distinct: ["pantryCategory", "pantrySubcategory"], - }); + // Distinct categories/subcategories and all label names for autocomplete. + const [allCats, allLabels] = await Promise.all([ + db.item.findMany({ + where: { kind: "CONSUMABLE", pantryCategory: { not: null } }, + select: { pantryCategory: true, pantrySubcategory: true }, + distinct: ["pantryCategory", "pantrySubcategory"], + }), + db.label.findMany({ select: { name: true }, orderBy: { name: "asc" } }), + ]); const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[]; const subcategories = allCats @@ -60,5 +63,6 @@ export async function GET(req: Request) { })), categories, subcategories, + labels: allLabels.map(l => l.name), }); } diff --git a/src/app/api/pantry/route.ts b/src/app/api/pantry/route.ts index 93456c4..54a60df 100644 --- a/src/app/api/pantry/route.ts +++ b/src/app/api/pantry/route.ts @@ -10,6 +10,7 @@ const PANTRY_SELECT = { pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true, location: { select: { id: true, name: true } }, plant: { select: { id: true, commonName: true, variety: true } }, + labels: { select: { label: { select: { id: true, name: true } } } }, } as const; function serialize(i: any) { @@ -27,9 +28,25 @@ function serialize(i: any) { locationName: i.location?.name ?? null, plantId: i.plant?.id ?? null, plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null, + tags: (i.labels ?? []).map((l: any) => l.label.name) as string[], }; } +async function syncTags(itemId: string, tags: string[]) { + const names = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean))); + // Upsert each label, then replace all LabelOnItem rows for this item. + const labels = await Promise.all( + names.map(name => db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } })) + ); + await db.labelOnItem.deleteMany({ where: { itemId } }); + if (labels.length) { + await db.labelOnItem.createMany({ + data: labels.map(l => ({ itemId, labelId: l.id })), + skipDuplicates: true, + }); + } +} + export async function GET() { await requireAuth(); const items = await db.item.findMany({ @@ -56,6 +73,7 @@ const createSchema = z.object({ pricePaid: z.number().nullable().optional(), priceUnit: z.string().nullable().optional(), notes: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), }); export async function POST(req: Request) { @@ -84,11 +102,15 @@ export async function POST(req: Request) { select: PANTRY_SELECT, }); + if (data.tags?.length) await syncTags(item.id, data.tags); + await db.auditEvent.create({ data: { action: "pantry.create", entityId: item.id, actorId: session.user.id, payload: { name: item.name } }, }); - return NextResponse.json(serialize(item), { status: 201 }); + // Re-fetch with labels + const full = await db.item.findUniqueOrThrow({ where: { id: item.id }, select: PANTRY_SELECT }); + return NextResponse.json(serialize(full), { status: 201 }); } catch (err) { if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 }); if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/src/components/pantry/pantry-client.tsx b/src/components/pantry/pantry-client.tsx index 0daec43..79000d6 100644 --- a/src/components/pantry/pantry-client.tsx +++ b/src/components/pantry/pantry-client.tsx @@ -24,6 +24,7 @@ export type PantryItem = { pricePaid: number | null; priceUnit: string | null; notes: string | null; + tags: string[]; }; export type LocationOption = { id: string; name: string; path: string | null }; @@ -71,6 +72,13 @@ function ItemRow({ item, locations, plants, onRefresh }: { {item.storedAt && Stored {new Date(item.storedAt).toLocaleDateString()}} {item.bestBefore && } + {item.tags?.length > 0 && ( +
+ {item.tags.map(tag => ( + {tag} + ))} +
+ )} {item.notes &&

{item.notes}

}
diff --git a/src/components/pantry/pantry-item-dialog.tsx b/src/components/pantry/pantry-item-dialog.tsx index a1bd898..781f8eb 100644 --- a/src/components/pantry/pantry-item-dialog.tsx +++ b/src/components/pantry/pantry-item-dialog.tsx @@ -40,6 +40,7 @@ type CatalogData = { suggestions: Suggestion[]; categories: string[]; subcategories: { category: string; subcategory: string }[]; + labels: string[]; }; type Form = { @@ -74,7 +75,12 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) const [nameSuggestions, setNameSuggestions] = useState([]); const [showDropdown, setShowDropdown] = useState(false); const [autofillBanner, setAutofillBanner] = useState(null); + const [tags, setTags] = useState(item?.tags ?? []); + const [tagInput, setTagInput] = useState(""); + const [tagSuggestions, setTagSuggestions] = useState([]); + const [showTagDropdown, setShowTagDropdown] = useState(false); const nameRef = useRef(null); + const tagInputRef = useRef(null); const debounceRef = useRef>(); const blankForm = (): Form => ({ @@ -100,6 +106,35 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) setForm(f => ({ ...f, [key]: val })); } + function addTag(name: string) { + const t = name.trim(); + if (!t || tags.includes(t)) return; + setTags(prev => [...prev, t]); + setTagInput(""); + setShowTagDropdown(false); + } + + function removeTag(name: string) { + setTags(prev => prev.filter(t => t !== name)); + } + + function handleTagKeyDown(e: React.KeyboardEvent) { + if ((e.key === "Enter" || e.key === ",") && tagInput.trim()) { + e.preventDefault(); + addTag(tagInput); + } else if (e.key === "Backspace" && !tagInput && tags.length) { + removeTag(tags[tags.length - 1]); + } + } + + // Reset tag state when dialog opens/closes + useEffect(() => { + if (open) { + setTags(item?.tags ?? []); + setTagInput(""); + } + }, [open]); + // Load catalog on open (for name autocomplete suggestions) useEffect(() => { if (!open) return; @@ -109,6 +144,15 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) .catch(() => {}); }, [open]); + // Tag autocomplete from catalog labels + useEffect(() => { + const q = tagInput.trim().toLowerCase(); + if (!q || !catalog?.labels) { setTagSuggestions([]); setShowTagDropdown(false); return; } + const matches = catalog.labels.filter(l => l.toLowerCase().includes(q) && !tags.includes(l)); + setTagSuggestions(matches.slice(0, 6)); + setShowTagDropdown(matches.length > 0 || q.length > 0); + }, [tagInput, catalog, tags]); + // Debounced name search for autocomplete function handleNameChange(val: string) { set("name", val); @@ -186,6 +230,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) priceUnit: form.priceUnit || null, notes: form.notes.trim() || null, kind: "CONSUMABLE", + tags, }; const res = await fetch(item ? `/api/pantry/${item.id}` : "/api/pantry", { method: item ? "PATCH" : "POST", @@ -196,6 +241,8 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) setOpen(false); setForm(blankForm()); setAutofillBanner(null); + setTags([]); + setTagInput(""); onSuccess(); } catch (err) { setError(String(err).replace("Error: ", "")); @@ -383,6 +430,49 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)