From 0d52ea5ad13b845921739b5c611c878464b80059 Mon Sep 17 00:00:00 2001 From: Bonna Moon Date: Mon, 29 Jun 2026 17:28:28 -0500 Subject: [PATCH] =?UTF-8?q?v0.6.0=20=E2=80=94=20Kitchen:=20Pantry=20&=20Fr?= =?UTF-8?q?eezer=20inventory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260629000001_v0_19_pantry/migration.sql | 15 ++ prisma/schema.prisma | 21 +- src/app/(dashboard)/dashboard/page.tsx | 40 +++- src/app/(dashboard)/kitchen/pantry/page.tsx | 41 ++++ src/app/api/pantry/[id]/route.ts | 75 +++++++ src/app/api/pantry/[id]/use/route.ts | 48 +++++ src/app/api/pantry/route.ts | 85 ++++++++ src/components/layout/nav-items.ts | 8 +- src/components/layout/sidebar.tsx | 20 +- src/components/pantry/pantry-client.tsx | 170 ++++++++++++++++ src/components/pantry/pantry-item-dialog.tsx | 186 ++++++++++++++++++ src/components/pantry/use-item-dialog.tsx | 83 ++++++++ 12 files changed, 784 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260629000001_v0_19_pantry/migration.sql create mode 100644 src/app/(dashboard)/kitchen/pantry/page.tsx create mode 100644 src/app/api/pantry/[id]/route.ts create mode 100644 src/app/api/pantry/[id]/use/route.ts create mode 100644 src/app/api/pantry/route.ts create mode 100644 src/components/pantry/pantry-client.tsx create mode 100644 src/components/pantry/pantry-item-dialog.tsx create mode 100644 src/components/pantry/use-item-dialog.tsx diff --git a/prisma/migrations/20260629000001_v0_19_pantry/migration.sql b/prisma/migrations/20260629000001_v0_19_pantry/migration.sql new file mode 100644 index 0000000..b855ef7 --- /dev/null +++ b/prisma/migrations/20260629000001_v0_19_pantry/migration.sql @@ -0,0 +1,15 @@ +-- v0.19 Pantry: add storedAt, source enum, and plantId to Item + +CREATE TYPE "ItemSource" AS ENUM ('GARDEN', 'HOMEMADE', 'STORE', 'FARMERS_MARKET', 'GIFTED', 'OTHER'); + +ALTER TABLE "Item" + ADD COLUMN "storedAt" TIMESTAMP(3), + ADD COLUMN "source" "ItemSource", + ADD COLUMN "plantId" TEXT; + +ALTER TABLE "Item" + ADD CONSTRAINT "Item_plantId_fkey" + FOREIGN KEY ("plantId") REFERENCES "Plant"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "Item_plantId_idx" ON "Item"("plantId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a6138f2..2ba968c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -165,6 +165,7 @@ model Plant { updatedAt DateTime @updatedAt logs PlantLog[] tasks Task[] + pantryItems Item[] @@index([category]) @@index([zone]) @@ -299,7 +300,16 @@ model PlantLog { enum ItemKind { DURABLE // tools, gear, equipment — "where is it?" - CONSUMABLE // food, feed, supplies — "do I have it?" (Pantry phase) + CONSUMABLE // food, feed, supplies — "do I have it?" +} + +enum ItemSource { + GARDEN // grown/harvested here + HOMEMADE // made/preserved ourselves + STORE // grocery or big-box store + FARMERS_MARKET // local market + GIFTED // given by someone + OTHER } model Item { @@ -338,10 +348,14 @@ model Item { // Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30") customFields Json? - // Consumable attributes (Pantry phase) + // Consumable attributes barcode String? - bestBefore DateTime? + storedAt DateTime? // date frozen / preserved / stored + bestBefore DateTime? // expiry / best-by date minStock Decimal? @db.Decimal(10, 2) + source ItemSource? // where it came from + plantId String? // link to a garden plant (harvest source) + plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull) // Shared qrSlug String? @unique // short slug for a printed QR label @@ -362,6 +376,7 @@ model Item { @@index([active]) @@index([name]) @@index([barcode]) + @@index([plantId]) } model Label { diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 6363388..e0b3776 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -2,7 +2,7 @@ import { getSession } from "@/lib/auth"; import { db } from "@/lib/db"; import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Leaf, CalendarDays, Sprout } from "lucide-react"; +import { Leaf, Sprout, AlertTriangle, UtensilsCrossed } from "lucide-react"; import { formatDate } from "@/lib/utils"; export const metadata = { title: "Dashboard" }; @@ -10,13 +10,21 @@ export const metadata = { title: "Dashboard" }; export default async function DashboardPage() { const session = await getSession(); - const [plantCount, recentLogs] = await Promise.all([ + const thirtyDaysFromNow = new Date(Date.now() + 30 * 86_400_000); + + const [plantCount, recentLogs, expiringItems] = await Promise.all([ db.plant.count({ where: { active: true } }), db.plantLog.findMany({ orderBy: { date: "desc" }, take: 5, include: { plant: { select: { commonName: true, variety: true } } }, }), + db.item.findMany({ + where: { kind: "CONSUMABLE", active: true, bestBefore: { lte: thirtyDaysFromNow } }, + orderBy: { bestBefore: "asc" }, + select: { id: true, name: true, bestBefore: true, quantity: true, unit: true }, + take: 5, + }), ]); const firstName = session?.user.name.split(" ")[0] ?? "there"; @@ -42,6 +50,34 @@ export default async function DashboardPage() { + + + + + + + Kitchen + + + + {expiringItems.length === 0 ? ( +

Nothing expiring soon

+ ) : ( +
+

+ + {expiringItems.length} expiring soon +

+ {expiringItems.slice(0, 3).map(i => ( +

+ {i.name} · {new Date(i.bestBefore!).toLocaleDateString()} +

+ ))} +
+ )} +
+
+ {recentLogs.length > 0 && ( diff --git a/src/app/(dashboard)/kitchen/pantry/page.tsx b/src/app/(dashboard)/kitchen/pantry/page.tsx new file mode 100644 index 0000000..c5cbd76 --- /dev/null +++ b/src/app/(dashboard)/kitchen/pantry/page.tsx @@ -0,0 +1,41 @@ +import { db } from "@/lib/db"; +import { PantryClient } from "@/components/pantry/pantry-client"; + +export const metadata = { title: "Pantry & Freezer" }; +export const dynamic = "force-dynamic"; + +export default async function PantryPage() { + const [items, locations, plants] = await Promise.all([ + db.item.findMany({ + where: { kind: "CONSUMABLE", active: true }, + orderBy: [{ location: { name: "asc" } }, { name: "asc" }], + select: { + id: true, name: true, description: true, quantity: true, unit: true, + storedAt: true, bestBefore: true, source: true, plantId: true, notes: true, + location: { select: { id: true, name: true } }, + plant: { select: { id: true, commonName: true, variety: true } }, + }, + }), + db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), + db.plant.findMany({ where: { active: true }, orderBy: { commonName: "asc" }, select: { id: true, commonName: true, variety: true } }), + ]); + + const serialized = items.map(i => ({ + id: i.id, + name: i.name, + description: i.description, + quantity: Number(i.quantity), + unit: i.unit, + packageSize: i.description, + storedAt: i.storedAt, + bestBefore: i.bestBefore, + source: i.source, + notes: i.notes, + locationId: i.location?.id ?? null, + locationName: i.location?.name ?? null, + plantId: i.plant?.id ?? null, + plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null, + })); + + return ; +} diff --git a/src/app/api/pantry/[id]/route.ts b/src/app/api/pantry/[id]/route.ts new file mode 100644 index 0000000..af0f806 --- /dev/null +++ b/src/app/api/pantry/[id]/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { ItemSource } from "@prisma/client"; +import { db } from "@/lib/db"; +import { requireAuth, isAdmin } from "@/lib/auth"; + +const updateSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().nullable().optional(), + quantity: z.number().min(0).optional(), + unit: z.string().nullable().optional(), + packageSize: z.string().nullable().optional(), + locationId: z.string().nullable().optional(), + storedAt: z.string().nullable().optional(), + bestBefore: z.string().nullable().optional(), + source: z.nativeEnum(ItemSource).nullable().optional(), + plantId: z.string().nullable().optional(), + notes: z.string().nullable().optional(), +}); + +export async function PATCH(req: Request, { params }: { params: { id: string } }) { + try { + const session = await requireAuth(); + const data = updateSchema.parse(await req.json()); + + const item = await db.item.update({ + where: { id: params.id }, + data: { + ...(data.name !== undefined && { name: data.name }), + ...(data.description !== undefined && { description: data.description ?? data.packageSize ?? null }), + ...(data.quantity !== undefined && { quantity: data.quantity }), + ...(data.unit !== undefined && { unit: data.unit ?? null }), + ...(data.locationId !== undefined && { locationId: data.locationId ?? null }), + ...(data.storedAt !== undefined && { storedAt: data.storedAt ? new Date(data.storedAt) : null }), + ...(data.bestBefore !== undefined && { bestBefore: data.bestBefore ? new Date(data.bestBefore) : null }), + ...(data.source !== undefined && { source: data.source ?? null }), + ...(data.plantId !== undefined && { plantId: data.plantId ?? 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, + location: { select: { id: true, name: true } }, + plant: { select: { id: true, commonName: true, variety: true } }, + }, + }); + + await db.auditEvent.create({ + data: { action: "pantry.update", entityId: item.id, actorId: session.user.id, payload: { name: item.name } }, + }); + + return NextResponse.json(item); + } 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 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} + +export async function DELETE(_req: Request, { params }: { params: { id: string } }) { + try { + const session = await requireAuth(); + if (!isAdmin(session.user.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + await db.item.update({ where: { id: params.id }, data: { active: false } }); + await db.auditEvent.create({ + data: { action: "pantry.delete", entityId: params.id, actorId: session.user.id }, + }); + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/api/pantry/[id]/use/route.ts b/src/app/api/pantry/[id]/use/route.ts new file mode 100644 index 0000000..b211b20 --- /dev/null +++ b/src/app/api/pantry/[id]/use/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { requireAuth } from "@/lib/auth"; + +const schema = z.object({ + amount: z.number().positive(), + notes: z.string().nullable().optional(), +}); + +export async function POST(req: Request, { params }: { params: { id: string } }) { + try { + const session = await requireAuth(); + const { amount, notes } = schema.parse(await req.json()); + + const item = await db.item.findUnique({ where: { id: params.id } }); + if (!item || !item.active) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const newQty = Math.max(0, Number(item.quantity) - amount); + + await db.item.update({ where: { id: params.id }, data: { quantity: newQty } }); + + await db.itemLog.create({ + data: { + itemId: params.id, + type: "NOTE", + notes: `Used ${amount} ${item.unit ?? ""}${notes ? ` — ${notes}` : ""}`.trim(), + actorId: session.user.id, + }, + }); + + await db.auditEvent.create({ + data: { + action: "pantry.use", + entityId: params.id, + actorId: session.user.id, + payload: { amount, remaining: newQty, notes }, + }, + }); + + return NextResponse.json({ ok: true, remaining: newQty }); + } 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 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/api/pantry/route.ts b/src/app/api/pantry/route.ts new file mode 100644 index 0000000..07dc029 --- /dev/null +++ b/src/app/api/pantry/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { ItemSource } from "@prisma/client"; +import { db } from "@/lib/db"; +import { requireAuth } 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, + location: { select: { id: true, name: true } }, + plant: { select: { id: true, commonName: true, variety: true } }, +} as const; + +function serialize(i: any) { + return { + id: i.id, name: i.name, description: i.description, + quantity: Number(i.quantity), unit: i.unit, + packageSize: i.description, + storedAt: i.storedAt, bestBefore: i.bestBefore, + source: i.source, notes: i.notes, + locationId: i.location?.id ?? null, + locationName: i.location?.name ?? null, + plantId: i.plant?.id ?? null, + plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null, + }; +} + +export async function GET() { + await requireAuth(); + const items = await db.item.findMany({ + where: { kind: "CONSUMABLE", active: true }, + orderBy: [{ location: { name: "asc" } }, { name: "asc" }], + select: PANTRY_SELECT, + }); + return NextResponse.json(items.map(serialize)); +} + +const createSchema = z.object({ + name: z.string().min(1), + description: z.string().nullable().optional(), + quantity: z.number().min(0).default(1), + unit: z.string().nullable().optional(), + packageSize: z.string().nullable().optional(), + locationId: z.string().nullable().optional(), + storedAt: z.string().nullable().optional(), + bestBefore: z.string().nullable().optional(), + source: z.nativeEnum(ItemSource).nullable().optional(), + plantId: z.string().nullable().optional(), + notes: z.string().nullable().optional(), +}); + +export async function POST(req: Request) { + try { + const session = await requireAuth(); + const data = createSchema.parse(await req.json()); + + const item = await db.item.create({ + data: { + name: data.name, + description: data.description ?? data.packageSize ?? null, + kind: "CONSUMABLE", + quantity: data.quantity, + unit: data.unit ?? null, + locationId: data.locationId ?? null, + storedAt: data.storedAt ? new Date(data.storedAt) : null, + bestBefore: data.bestBefore ? new Date(data.bestBefore) : null, + source: data.source ?? null, + plantId: data.plantId ?? null, + notes: data.notes ?? null, + }, + select: PANTRY_SELECT, + }); + + 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 }); + } 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 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/components/layout/nav-items.ts b/src/components/layout/nav-items.ts index 685bb46..2bc98c8 100644 --- a/src/components/layout/nav-items.ts +++ b/src/components/layout/nav-items.ts @@ -1,9 +1,9 @@ import { LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell, - Lightbulb, KeyRound, HardDrive, RefreshCw, type LucideIcon, + Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, type LucideIcon, } from "lucide-react"; -export type NavItem = { label: string; href: string; icon: LucideIcon }; +export type NavItem = { label: string; href: string; icon: LucideIcon; section?: string }; export const navItems: NavItem[] = [ { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, @@ -15,6 +15,10 @@ export const navItems: NavItem[] = [ { label: "Reminders", href: "/tasks", icon: Bell }, ]; +export const kitchenItems: NavItem[] = [ + { label: "Pantry & Freezer", href: "/kitchen/pantry", icon: UtensilsCrossed }, +]; + export const settingsItems: NavItem[] = [ { label: "Suggestions", href: "/suggestions", icon: Lightbulb }, { label: "API tokens", href: "/settings/tokens", icon: KeyRound }, diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 0ae4704..68d4622 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { Leaf } from "lucide-react"; import { cn } from "@/lib/utils"; -import { navItems, settingsItems, isNavActive } from "./nav-items"; +import { navItems, kitchenItems, settingsItems, isNavActive } from "./nav-items"; export function Sidebar() { const path = usePathname(); @@ -39,6 +39,24 @@ export function Sidebar() { })} +
+

Kitchen

+ {kitchenItems.map(({ label, href, icon: Icon }) => { + const active = path.startsWith(href); + return ( + + + {label} + + ); + })} +
+
{settingsItems.map(({ label, href, icon: Icon }) => { const active = path.startsWith(href); diff --git a/src/components/pantry/pantry-client.tsx b/src/components/pantry/pantry-client.tsx new file mode 100644 index 0000000..5d357a6 --- /dev/null +++ b/src/components/pantry/pantry-client.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState } from "react"; +import { AlertTriangle, Plus, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { PantryItemDialog } from "./pantry-item-dialog"; +import { UseItemDialog } from "./use-item-dialog"; + +export type PantryItem = { + id: string; + name: string; + description: string | null; + quantity: number; + unit: string | null; + packageSize: string | null; + storedAt: Date | null; + bestBefore: Date | null; + source: string | null; + locationId: string | null; + locationName: string | null; + plantId: string | null; + plantName: string | null; + notes: string | null; +}; + +export type LocationOption = { id: string; name: string }; +export type PlantOption = { id: string; commonName: string; variety: string | null }; + +const SOURCE_LABELS: Record = { + GARDEN: "Our garden", + HOMEMADE: "Homemade", + STORE: "Store", + FARMERS_MARKET: "Farmers market", + GIFTED: "Gifted", + OTHER: "Other", +}; + +function daysUntil(date: Date) { + return Math.ceil((new Date(date).getTime() - Date.now()) / 86_400_000); +} + +function ExpiryBadge({ date }: { date: Date }) { + const days = daysUntil(date); + if (days < 0) return Expired; + if (days <= 30) return Expires in {days}d; + return {new Date(date).toLocaleDateString()}; +} + +function ItemRow({ item, locations, plants, onRefresh }: { + item: PantryItem; + locations: LocationOption[]; + plants: PlantOption[]; + onRefresh: () => void; +}) { + return ( +
+
+
+ {item.name} + {item.description && {item.description}} + {item.source && {SOURCE_LABELS[item.source] ?? item.source}} + {item.plantName && 🌱 {item.plantName}} +
+
+ + {item.quantity} {item.unit ?? ""} + {item.packageSize ? ` · ${item.packageSize}` : ""} + + {item.storedAt && Stored {new Date(item.storedAt).toLocaleDateString()}} + {item.bestBefore && } +
+
+
+ + +
+
+ ); +} + +function LocationGroup({ name, items, locations, plants, onRefresh }: { + name: string; + items: PantryItem[]; + locations: LocationOption[]; + plants: PlantOption[]; + onRefresh: () => void; +}) { + const [open, setOpen] = useState(true); + const expiring = items.filter(i => i.bestBefore && daysUntil(i.bestBefore) <= 30); + + return ( +
+ + {open && ( +
+ {items.map(item => ( + + ))} +
+ )} +
+ ); +} + +export function PantryClient({ initialItems, locations, plants }: { + initialItems: PantryItem[]; + locations: LocationOption[]; + plants: PlantOption[]; +}) { + const [items, setItems] = useState(initialItems); + + async function refresh() { + const res = await fetch("/api/pantry"); + if (res.ok) setItems(await res.json()); + } + + const grouped = items.reduce>((acc, item) => { + const key = item.locationName ?? "No location"; + if (!acc[key]) acc[key] = []; + acc[key].push(item); + return acc; + }, {}); + + const expiringCount = items.filter(i => i.bestBefore && daysUntil(i.bestBefore) <= 30).length; + + return ( +
+
+
+

Pantry & Freezer

+ {expiringCount > 0 && ( +

+ + {expiringCount} {expiringCount === 1 ? "item" : "items"} expiring within 30 days +

+ )} +
+ +
+ + {items.length === 0 ? ( +
+ +

Nothing in the pantry yet — add your first item.

+
+ ) : ( +
+ {Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([name, groupItems]) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/pantry/pantry-item-dialog.tsx b/src/components/pantry/pantry-item-dialog.tsx new file mode 100644 index 0000000..a5aa712 --- /dev/null +++ b/src/components/pantry/pantry-item-dialog.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Pencil } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import type { PantryItem, LocationOption, PlantOption } from "./pantry-client"; + +const SOURCES = [ + { value: "GARDEN", label: "Our garden" }, + { value: "HOMEMADE", label: "Homemade" }, + { value: "STORE", label: "Store" }, + { value: "FARMERS_MARKET", label: "Farmers market" }, + { value: "GIFTED", label: "Gifted" }, + { value: "OTHER", label: "Other" }, +]; + +const COMMON_UNITS = ["lbs", "oz", "g", "bags", "jars", "quart jars", "pint jars", "gallon bags", "quart bags", "containers", "cans", "bottles", "bunches", "loaves", "portions"]; + +type Props = { + item?: PantryItem; + locations: LocationOption[]; + plants: PlantOption[]; + onSuccess: () => void; +}; + +export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) { + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const [form, setForm] = useState({ + name: item?.name ?? "", + description: item?.description ?? "", + quantity: item?.quantity?.toString() ?? "1", + unit: item?.unit ?? "", + packageSize: item?.packageSize ?? "", + locationId: item?.locationId ?? "", + storedAt: item?.storedAt ? new Date(item.storedAt).toISOString().slice(0, 10) : "", + bestBefore: item?.bestBefore ? new Date(item.bestBefore).toISOString().slice(0, 10) : "", + source: item?.source ?? "", + plantId: item?.plantId ?? "", + notes: item?.notes ?? "", + }); + + function set(key: string, val: string) { + setForm(f => ({ ...f, [key]: val })); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + try { + const body = { + name: form.name.trim(), + description: form.description.trim() || null, + quantity: parseFloat(form.quantity) || 1, + unit: form.unit.trim() || null, + packageSize: form.packageSize.trim() || null, + locationId: form.locationId || null, + storedAt: form.storedAt || null, + bestBefore: form.bestBefore || null, + source: form.source || null, + plantId: form.plantId || null, + notes: form.notes.trim() || null, + kind: "CONSUMABLE", + }; + const res = await fetch(item ? `/api/pantry/${item.id}` : "/api/pantry", { + method: item ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error((await res.json()).error ?? "Failed"); + setOpen(false); + onSuccess(); + } catch (err) { + setError(String(err).replace("Error: ", "")); + } finally { + setSaving(false); + } + } + + const inputCls = "w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; + + return ( + <> + {item ? ( + + ) : ( + + )} + + + + + {item ? "Edit item" : "Add pantry item"} + +
+
+ + set("name", e.target.value)} required placeholder="e.g. Blueberries, Chicken broth, Sourdough" /> +
+ +
+ + set("description", e.target.value)} placeholder="e.g. quart freezer bag, 1-gallon zip bag" /> +
+ +
+
+ + set("quantity", e.target.value)} required /> +
+
+ + set("unit", e.target.value)} placeholder="bags, lbs, jars…" /> + + {COMMON_UNITS.map(u => +
+
+ +
+ + +
+ +
+
+ + set("storedAt", e.target.value)} /> +
+
+ + set("bestBefore", e.target.value)} /> +
+
+ +
+ + +
+ + {form.source === "GARDEN" && ( +
+ + +
+ )} + +
+ +