v0.6.0 — Kitchen: Pantry & Freezer inventory

This commit is contained in:
Bonna Moon
2026-06-29 17:28:28 -05:00
parent f483630419
commit 0d52ea5ad1
12 changed files with 784 additions and 8 deletions

View File

@@ -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");

View File

@@ -165,6 +165,7 @@ model Plant {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
logs PlantLog[] logs PlantLog[]
tasks Task[] tasks Task[]
pantryItems Item[]
@@index([category]) @@index([category])
@@index([zone]) @@index([zone])
@@ -299,7 +300,16 @@ model PlantLog {
enum ItemKind { enum ItemKind {
DURABLE // tools, gear, equipment — "where is it?" 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 { model Item {
@@ -338,10 +348,14 @@ model Item {
// Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30") // Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30")
customFields Json? customFields Json?
// Consumable attributes (Pantry phase) // Consumable attributes
barcode String? barcode String?
bestBefore DateTime? storedAt DateTime? // date frozen / preserved / stored
bestBefore DateTime? // expiry / best-by date
minStock Decimal? @db.Decimal(10, 2) 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 // Shared
qrSlug String? @unique // short slug for a printed QR label qrSlug String? @unique // short slug for a printed QR label
@@ -362,6 +376,7 @@ model Item {
@@index([active]) @@index([active])
@@index([name]) @@index([name])
@@index([barcode]) @@index([barcode])
@@index([plantId])
} }
model Label { model Label {

View File

@@ -2,7 +2,7 @@ import { getSession } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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"; import { formatDate } from "@/lib/utils";
export const metadata = { title: "Dashboard" }; export const metadata = { title: "Dashboard" };
@@ -10,13 +10,21 @@ export const metadata = { title: "Dashboard" };
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await getSession(); 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.plant.count({ where: { active: true } }),
db.plantLog.findMany({ db.plantLog.findMany({
orderBy: { date: "desc" }, orderBy: { date: "desc" },
take: 5, take: 5,
include: { plant: { select: { commonName: true, variety: true } } }, 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"; const firstName = session?.user.name.split(" ")[0] ?? "there";
@@ -42,6 +50,34 @@ export default async function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href="/kitchen/pantry">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<UtensilsCrossed className="h-4 w-4 text-amber-600" />
Kitchen
</CardTitle>
</CardHeader>
<CardContent>
{expiringItems.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing expiring soon</p>
) : (
<div className="space-y-1">
<p className="flex items-center gap-1.5 text-sm font-medium text-amber-700">
<AlertTriangle className="h-3.5 w-3.5" />
{expiringItems.length} expiring soon
</p>
{expiringItems.slice(0, 3).map(i => (
<p key={i.id} className="text-xs text-muted-foreground truncate">
{i.name} · {new Date(i.bestBefore!).toLocaleDateString()}
</p>
))}
</div>
)}
</CardContent>
</Card>
</Link>
</div> </div>
{recentLogs.length > 0 && ( {recentLogs.length > 0 && (

View File

@@ -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 <PantryClient initialItems={serialized} locations={locations} plants={plants} />;
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -1,9 +1,9 @@
import { import {
LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell, LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell,
Lightbulb, KeyRound, HardDrive, RefreshCw, type LucideIcon, Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, type LucideIcon,
} from "lucide-react"; } 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[] = [ export const navItems: NavItem[] = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
@@ -15,6 +15,10 @@ export const navItems: NavItem[] = [
{ label: "Reminders", href: "/tasks", icon: Bell }, { label: "Reminders", href: "/tasks", icon: Bell },
]; ];
export const kitchenItems: NavItem[] = [
{ label: "Pantry & Freezer", href: "/kitchen/pantry", icon: UtensilsCrossed },
];
export const settingsItems: NavItem[] = [ export const settingsItems: NavItem[] = [
{ label: "Suggestions", href: "/suggestions", icon: Lightbulb }, { label: "Suggestions", href: "/suggestions", icon: Lightbulb },
{ label: "API tokens", href: "/settings/tokens", icon: KeyRound }, { label: "API tokens", href: "/settings/tokens", icon: KeyRound },

View File

@@ -4,7 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Leaf } from "lucide-react"; import { Leaf } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { navItems, settingsItems, isNavActive } from "./nav-items"; import { navItems, kitchenItems, settingsItems, isNavActive } from "./nav-items";
export function Sidebar() { export function Sidebar() {
const path = usePathname(); const path = usePathname();
@@ -39,6 +39,24 @@ export function Sidebar() {
})} })}
</nav> </nav>
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
<p className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-widest text-[hsl(var(--canopy-foreground))]/40">Kitchen</p>
{kitchenItems.map(({ label, href, icon: Icon }) => {
const active = path.startsWith(href);
return (
<Link key={href} href={href} className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors",
active
? "bg-[hsl(var(--canopy-accent))] text-[hsl(var(--canopy-accent-foreground))]"
: "hover:bg-[hsl(var(--canopy-accent))] hover:text-[hsl(var(--canopy-accent-foreground))] text-[hsl(var(--canopy-foreground))]/80"
)}>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
})}
</div>
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5"> <div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
{settingsItems.map(({ label, href, icon: Icon }) => { {settingsItems.map(({ label, href, icon: Icon }) => {
const active = path.startsWith(href); const active = path.startsWith(href);

View File

@@ -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<string, string> = {
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 <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 px-1.5 py-0.5 rounded">Expired</span>;
if (days <= 30) return <span className="text-xs font-medium text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded">Expires in {days}d</span>;
return <span className="text-xs text-muted-foreground">{new Date(date).toLocaleDateString()}</span>;
}
function ItemRow({ item, locations, plants, onRefresh }: {
item: PantryItem;
locations: LocationOption[];
plants: PlantOption[];
onRefresh: () => void;
}) {
return (
<div className="flex items-center gap-3 px-4 py-3 border-b last:border-0 hover:bg-muted/30 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{item.name}</span>
{item.description && <span className="text-xs text-muted-foreground">{item.description}</span>}
{item.source && <span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{SOURCE_LABELS[item.source] ?? item.source}</span>}
{item.plantName && <span className="text-xs text-green-700 bg-green-50 border border-green-200 px-1.5 py-0.5 rounded">🌱 {item.plantName}</span>}
</div>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
<span className="text-sm text-muted-foreground">
{item.quantity} {item.unit ?? ""}
{item.packageSize ? ` · ${item.packageSize}` : ""}
</span>
{item.storedAt && <span className="text-xs text-muted-foreground">Stored {new Date(item.storedAt).toLocaleDateString()}</span>}
{item.bestBefore && <ExpiryBadge date={item.bestBefore} />}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<UseItemDialog item={item} onSuccess={onRefresh} />
<PantryItemDialog item={item} locations={locations} plants={plants} onSuccess={onRefresh} />
</div>
</div>
);
}
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 (
<div className="rounded-lg border bg-card">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors rounded-t-lg"
>
<div className="flex items-center gap-2">
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<span className="font-semibold">{name}</span>
<span className="text-sm text-muted-foreground">{items.length} {items.length === 1 ? "item" : "items"}</span>
</div>
{expiring.length > 0 && (
<span className="flex items-center gap-1 text-xs text-amber-600">
<AlertTriangle className="h-3 w-3" />
{expiring.length} expiring soon
</span>
)}
</button>
{open && (
<div className="border-t">
{items.map(item => (
<ItemRow key={item.id} item={item} locations={locations} plants={plants} onRefresh={onRefresh} />
))}
</div>
)}
</div>
);
}
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<Record<string, PantryItem[]>>((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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Pantry & Freezer</h1>
{expiringCount > 0 && (
<p className="text-sm text-amber-600 mt-0.5 flex items-center gap-1">
<AlertTriangle className="h-3.5 w-3.5" />
{expiringCount} {expiringCount === 1 ? "item" : "items"} expiring within 30 days
</p>
)}
</div>
<PantryItemDialog locations={locations} plants={plants} onSuccess={refresh} />
</div>
{items.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<UtensilsCrossed className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">Nothing in the pantry yet add your first item.</p>
</div>
) : (
<div className="space-y-4">
{Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([name, groupItems]) => (
<LocationGroup key={name} name={name} items={groupItems} locations={locations} plants={plants} onRefresh={refresh} />
))}
</div>
)}
</div>
);
}

View File

@@ -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<string | null>(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 ? (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setOpen(true)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
) : (
<Button onClick={() => setOpen(true)} className="gap-2">
<Plus className="h-4 w-4" /> Add item
</Button>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{item ? "Edit item" : "Add pantry item"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-2">
<div className="space-y-1">
<Label>Name *</Label>
<input className={inputCls} value={form.name} onChange={e => set("name", e.target.value)} required placeholder="e.g. Blueberries, Chicken broth, Sourdough" />
</div>
<div className="space-y-1">
<Label>Description / package type</Label>
<input className={inputCls} value={form.description} onChange={e => set("description", e.target.value)} placeholder="e.g. quart freezer bag, 1-gallon zip bag" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Quantity *</Label>
<input className={inputCls} type="number" min="0" step="0.25" value={form.quantity} onChange={e => set("quantity", e.target.value)} required />
</div>
<div className="space-y-1">
<Label>Unit</Label>
<input className={inputCls} list="unit-suggestions" value={form.unit} onChange={e => set("unit", e.target.value)} placeholder="bags, lbs, jars…" />
<datalist id="unit-suggestions">
{COMMON_UNITS.map(u => <option key={u} value={u} />)}
</datalist>
</div>
</div>
<div className="space-y-1">
<Label>Location</Label>
<select className={inputCls} value={form.locationId} onChange={e => set("locationId", e.target.value)}>
<option value=""> none </option>
{locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Date stored / frozen</Label>
<input className={inputCls} type="date" value={form.storedAt} onChange={e => set("storedAt", e.target.value)} />
</div>
<div className="space-y-1">
<Label>Best by / expires</Label>
<input className={inputCls} type="date" value={form.bestBefore} onChange={e => set("bestBefore", e.target.value)} />
</div>
</div>
<div className="space-y-1">
<Label>Source</Label>
<select className={inputCls} value={form.source} onChange={e => set("source", e.target.value)}>
<option value=""> unknown </option>
{SOURCES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
{form.source === "GARDEN" && (
<div className="space-y-1">
<Label>Plant (optional)</Label>
<select className={inputCls} value={form.plantId} onChange={e => set("plantId", e.target.value)}>
<option value=""> select a plant </option>
{plants.map(p => (
<option key={p.id} value={p.id}>
{p.commonName}{p.variety ? ` (${p.variety})` : ""}
</option>
))}
</select>
</div>
)}
<div className="space-y-1">
<Label>Notes</Label>
<textarea className={inputCls} rows={2} value={form.notes} onChange={e => set("notes", e.target.value)} placeholder="Any notes…" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? "Saving…" : item ? "Save" : "Add item"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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<string | null>(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 (
<>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setOpen(true)} title="Use some">
<Minus className="h-3.5 w-3.5" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Use {item.name}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">
Currently: <strong>{item.quantity} {item.unit ?? ""}</strong>
</p>
<div className="space-y-1">
<Label>How much are you using? ({item.unit ?? "units"})</Label>
<input
className={inputCls}
type="number"
min="0.25"
max={item.quantity}
step="0.25"
value={amount}
onChange={e => setAmount(e.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label>Notes (optional)</Label>
<input className={inputCls} value={notes} onChange={e => setNotes(e.target.value)} placeholder="e.g. used in chicken soup" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? "Saving…" : "Use"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}