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 ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/src/components/pantry/use-item-dialog.tsx b/src/components/pantry/use-item-dialog.tsx
new file mode 100644
index 0000000..932f273
--- /dev/null
+++ b/src/components/pantry/use-item-dialog.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { useState } from "react";
+import { Minus } 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 } from "./pantry-client";
+
+export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess: () => void }) {
+ const [open, setOpen] = useState(false);
+ const [amount, setAmount] = useState("1");
+ const [notes, setNotes] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setSaving(true);
+ setError(null);
+ try {
+ const res = await fetch(`/api/pantry/${item.id}/use`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ amount: parseFloat(amount), notes: notes.trim() || null }),
+ });
+ if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
+ setOpen(false);
+ setAmount("1");
+ setNotes("");
+ 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 (
+ <>
+
+
+
+ >
+ );
+}