v0.6.1 — Pantry: categories, price tracking, and autocomplete

This commit is contained in:
Bonna Moon
2026-06-29 17:37:59 -05:00
parent 0d52ea5ad1
commit 62a0089ec2
8 changed files with 407 additions and 56 deletions

View File

@@ -0,0 +1,9 @@
-- v0.20: pantry categories + price tracking
ALTER TABLE "Item"
ADD COLUMN "pantryCategory" TEXT,
ADD COLUMN "pantrySubcategory" TEXT,
ADD COLUMN "pricePaid" DECIMAL(10,2),
ADD COLUMN "priceUnit" TEXT;
CREATE INDEX "Item_pantryCategory_idx" ON "Item"("pantryCategory");

View File

@@ -356,6 +356,10 @@ model Item {
source ItemSource? // where it came from source ItemSource? // where it came from
plantId String? // link to a garden plant (harvest source) plantId String? // link to a garden plant (harvest source)
plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull) plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull)
pantryCategory String? // e.g. "Meat", "Produce", "Dairy"
pantrySubcategory String? // e.g. "Beef", "Poultry", "Berries"
pricePaid Decimal? @db.Decimal(10, 2) // how much was paid
priceUnit String? // "package", "lb", "oz", "kg", etc.
// Shared // Shared
qrSlug String? @unique // short slug for a printed QR label qrSlug String? @unique // short slug for a printed QR label
@@ -377,6 +381,7 @@ model Item {
@@index([name]) @@index([name])
@@index([barcode]) @@index([barcode])
@@index([plantId]) @@index([plantId])
@@index([pantryCategory])
} }
model Label { model Label {

View File

@@ -12,6 +12,7 @@ export default async function PantryPage() {
select: { select: {
id: true, name: true, description: true, quantity: true, unit: true, id: true, name: true, description: true, quantity: true, unit: true,
storedAt: true, bestBefore: true, source: true, plantId: true, notes: 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 } }, location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } }, plant: { select: { id: true, commonName: true, variety: true } },
}, },
@@ -31,6 +32,10 @@ export default async function PantryPage() {
bestBefore: i.bestBefore, bestBefore: i.bestBefore,
source: i.source, source: i.source,
notes: i.notes, notes: i.notes,
pantryCategory: i.pantryCategory,
pantrySubcategory: i.pantrySubcategory,
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
priceUnit: i.priceUnit,
locationId: i.location?.id ?? null, locationId: i.location?.id ?? null,
locationName: i.location?.name ?? null, locationName: i.location?.name ?? null,
plantId: i.plant?.id ?? null, plantId: i.plant?.id ?? null,

View File

@@ -15,6 +15,10 @@ const updateSchema = z.object({
bestBefore: z.string().nullable().optional(), bestBefore: z.string().nullable().optional(),
source: z.nativeEnum(ItemSource).nullable().optional(), source: z.nativeEnum(ItemSource).nullable().optional(),
plantId: z.string().nullable().optional(), plantId: z.string().nullable().optional(),
pantryCategory: z.string().nullable().optional(),
pantrySubcategory: z.string().nullable().optional(),
pricePaid: z.number().nullable().optional(),
priceUnit: z.string().nullable().optional(),
notes: z.string().nullable().optional(), notes: z.string().nullable().optional(),
}); });
@@ -35,11 +39,16 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
...(data.bestBefore !== undefined && { bestBefore: data.bestBefore ? new Date(data.bestBefore) : null }), ...(data.bestBefore !== undefined && { bestBefore: data.bestBefore ? new Date(data.bestBefore) : null }),
...(data.source !== undefined && { source: data.source ?? null }), ...(data.source !== undefined && { source: data.source ?? null }),
...(data.plantId !== undefined && { plantId: data.plantId ?? null }), ...(data.plantId !== undefined && { plantId: data.plantId ?? null }),
...(data.pantryCategory !== undefined && { pantryCategory: data.pantryCategory ?? null }),
...(data.pantrySubcategory !== undefined && { pantrySubcategory: data.pantrySubcategory ?? null }),
...(data.pricePaid !== undefined && { pricePaid: data.pricePaid ?? null }),
...(data.priceUnit !== undefined && { priceUnit: data.priceUnit ?? null }),
...(data.notes !== undefined && { notes: data.notes ?? null }), ...(data.notes !== undefined && { notes: data.notes ?? null }),
}, },
select: { select: {
id: true, name: true, description: true, quantity: true, unit: true, id: true, name: true, description: true, quantity: true, unit: true,
storedAt: true, bestBefore: true, source: true, plantId: true, notes: 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 } }, location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } }, plant: { select: { id: true, commonName: true, variety: true } },
}, },

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
// Returns distinct pantry item names + their most-recent settings for autocomplete.
export async function GET(req: Request) {
await requireAuth();
const { searchParams } = new URL(req.url);
const q = searchParams.get("q")?.trim().toLowerCase() ?? "";
// Grab all consumable items (active + inactive so we remember old entries).
const items = await db.item.findMany({
where: {
kind: "CONSUMABLE",
...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
},
orderBy: { updatedAt: "desc" },
select: {
name: true, description: true, unit: true, source: true,
pantryCategory: true, pantrySubcategory: true,
priceUnit: true, pricePaid: true,
locationId: true, plantId: true,
location: { select: { id: true, name: true } },
},
take: 200,
});
// Deduplicate by name (case-insensitive), keep the most-recent record per name.
const seen = new Map<string, typeof items[number]>();
for (const item of items) {
const key = item.name.toLowerCase();
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"],
});
const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[];
const subcategories = allCats
.filter(c => c.pantrySubcategory)
.map(c => ({ category: c.pantryCategory!, subcategory: c.pantrySubcategory! }));
return NextResponse.json({
suggestions: Array.from(seen.values()).map(i => ({
name: i.name,
description: i.description,
unit: i.unit,
source: i.source,
pantryCategory: i.pantryCategory,
pantrySubcategory: i.pantrySubcategory,
priceUnit: i.priceUnit,
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
locationId: i.locationId,
locationName: i.location?.name ?? null,
plantId: i.plantId,
})),
categories,
subcategories,
});
}

View File

@@ -7,6 +7,7 @@ import { requireAuth } from "@/lib/auth";
const PANTRY_SELECT = { const PANTRY_SELECT = {
id: true, name: true, description: true, quantity: true, unit: true, id: true, name: true, description: true, quantity: true, unit: true,
storedAt: true, bestBefore: true, source: true, plantId: true, notes: 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 } }, location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } }, plant: { select: { id: true, commonName: true, variety: true } },
} as const; } as const;
@@ -18,6 +19,10 @@ function serialize(i: any) {
packageSize: i.description, packageSize: i.description,
storedAt: i.storedAt, bestBefore: i.bestBefore, storedAt: i.storedAt, bestBefore: i.bestBefore,
source: i.source, notes: i.notes, source: i.source, notes: i.notes,
pantryCategory: i.pantryCategory,
pantrySubcategory: i.pantrySubcategory,
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
priceUnit: i.priceUnit,
locationId: i.location?.id ?? null, locationId: i.location?.id ?? null,
locationName: i.location?.name ?? null, locationName: i.location?.name ?? null,
plantId: i.plant?.id ?? null, plantId: i.plant?.id ?? null,
@@ -46,6 +51,10 @@ const createSchema = z.object({
bestBefore: z.string().nullable().optional(), bestBefore: z.string().nullable().optional(),
source: z.nativeEnum(ItemSource).nullable().optional(), source: z.nativeEnum(ItemSource).nullable().optional(),
plantId: z.string().nullable().optional(), plantId: z.string().nullable().optional(),
pantryCategory: z.string().nullable().optional(),
pantrySubcategory: z.string().nullable().optional(),
pricePaid: z.number().nullable().optional(),
priceUnit: z.string().nullable().optional(),
notes: z.string().nullable().optional(), notes: z.string().nullable().optional(),
}); });
@@ -66,6 +75,10 @@ export async function POST(req: Request) {
bestBefore: data.bestBefore ? new Date(data.bestBefore) : null, bestBefore: data.bestBefore ? new Date(data.bestBefore) : null,
source: data.source ?? null, source: data.source ?? null,
plantId: data.plantId ?? null, plantId: data.plantId ?? null,
pantryCategory: data.pantryCategory ?? null,
pantrySubcategory: data.pantrySubcategory ?? null,
pricePaid: data.pricePaid ?? null,
priceUnit: data.priceUnit ?? null,
notes: data.notes ?? null, notes: data.notes ?? null,
}, },
select: PANTRY_SELECT, select: PANTRY_SELECT,

View File

@@ -1,8 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { AlertTriangle, Plus, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react"; import { AlertTriangle, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PantryItemDialog } from "./pantry-item-dialog"; import { PantryItemDialog } from "./pantry-item-dialog";
import { UseItemDialog } from "./use-item-dialog"; import { UseItemDialog } from "./use-item-dialog";
@@ -20,6 +19,10 @@ export type PantryItem = {
locationName: string | null; locationName: string | null;
plantId: string | null; plantId: string | null;
plantName: string | null; plantName: string | null;
pantryCategory: string | null;
pantrySubcategory: string | null;
pricePaid: number | null;
priceUnit: string | null;
notes: string | null; notes: string | null;
}; };
@@ -27,12 +30,8 @@ export type LocationOption = { id: string; name: string };
export type PlantOption = { id: string; commonName: string; variety: string | null }; export type PlantOption = { id: string; commonName: string; variety: string | null };
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
GARDEN: "Our garden", GARDEN: "Our garden", HOMEMADE: "Homemade", STORE: "Store",
HOMEMADE: "Homemade", FARMERS_MARKET: "Farmers market", GIFTED: "Gifted", OTHER: "Other",
STORE: "Store",
FARMERS_MARKET: "Farmers market",
GIFTED: "Gifted",
OTHER: "Other",
}; };
function daysUntil(date: Date) { function daysUntil(date: Date) {
@@ -42,16 +41,18 @@ function daysUntil(date: Date) {
function ExpiryBadge({ date }: { date: Date }) { function ExpiryBadge({ date }: { date: Date }) {
const days = daysUntil(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 < 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 <= 7) return <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 px-1.5 py-0.5 rounded">Expires in {days}d</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>; 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>; return <span className="text-xs text-muted-foreground">{new Date(date).toLocaleDateString()}</span>;
} }
function ItemRow({ item, locations, plants, onRefresh }: { function ItemRow({ item, locations, plants, onRefresh }: {
item: PantryItem; item: PantryItem; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
locations: LocationOption[];
plants: PlantOption[];
onRefresh: () => void;
}) { }) {
const priceStr = item.pricePaid != null
? `$${item.pricePaid.toFixed(2)}${item.priceUnit && item.priceUnit !== "package" ? `/${item.priceUnit}` : ""}`
: null;
return ( 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 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-1 min-w-0">
@@ -64,11 +65,13 @@ function ItemRow({ item, locations, plants, onRefresh }: {
<div className="flex items-center gap-3 mt-0.5 flex-wrap"> <div className="flex items-center gap-3 mt-0.5 flex-wrap">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{item.quantity} {item.unit ?? ""} {item.quantity} {item.unit ?? ""}
{item.packageSize ? ` · ${item.packageSize}` : ""}
</span> </span>
{priceStr && <span className="text-xs text-muted-foreground">{priceStr}</span>}
{item.locationName && <span className="text-xs text-muted-foreground">· {item.locationName}</span>}
{item.storedAt && <span className="text-xs text-muted-foreground">Stored {new Date(item.storedAt).toLocaleDateString()}</span>} {item.storedAt && <span className="text-xs text-muted-foreground">Stored {new Date(item.storedAt).toLocaleDateString()}</span>}
{item.bestBefore && <ExpiryBadge date={item.bestBefore} />} {item.bestBefore && <ExpiryBadge date={item.bestBefore} />}
</div> </div>
{item.notes && <p className="text-xs text-muted-foreground mt-0.5 italic">{item.notes}</p>}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<UseItemDialog item={item} onSuccess={onRefresh} /> <UseItemDialog item={item} onSuccess={onRefresh} />
@@ -78,21 +81,44 @@ function ItemRow({ item, locations, plants, onRefresh }: {
); );
} }
function LocationGroup({ name, items, locations, plants, onRefresh }: { function SubcategoryGroup({ name, items, locations, plants, onRefresh }: {
name: string; name: string; items: PantryItem[]; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
items: PantryItem[]; }) {
locations: LocationOption[]; const [open, setOpen] = useState(true);
plants: PlantOption[]; return (
onRefresh: () => void; <div>
<button onClick={() => setOpen(o => !o)} className="w-full flex items-center gap-2 px-4 py-2 hover:bg-muted/20 transition-colors">
{open ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
<span className="text-sm font-medium text-muted-foreground">{name}</span>
<span className="text-xs text-muted-foreground">({items.length})</span>
</button>
{open && items.map(item => (
<ItemRow key={item.id} item={item} locations={locations} plants={plants} onRefresh={onRefresh} />
))}
</div>
);
}
function CategoryGroup({ name, items, locations, plants, onRefresh }: {
name: string; items: PantryItem[]; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
}) { }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const expiring = items.filter(i => i.bestBefore && daysUntil(i.bestBefore) <= 30); const expiring = items.filter(i => i.bestBefore && daysUntil(i.bestBefore) <= 30);
// Group by subcategory
const subcats = items.reduce<Record<string, PantryItem[]>>((acc, item) => {
const key = item.pantrySubcategory ?? "—";
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {});
const hasSubcats = Object.keys(subcats).length > 1 || !subcats["—"];
return ( return (
<div className="rounded-lg border bg-card"> <div className="rounded-lg border bg-card overflow-hidden">
<button <button
onClick={() => setOpen(o => !o)} 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" className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors"
> >
<div className="flex items-center gap-2"> <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" />} {open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
@@ -106,11 +132,17 @@ function LocationGroup({ name, items, locations, plants, onRefresh }: {
</span> </span>
)} )}
</button> </button>
{open && ( {open && (
<div className="border-t"> <div className="border-t">
{items.map(item => ( {hasSubcats
? Object.entries(subcats).sort(([a], [b]) => a.localeCompare(b)).map(([sub, subItems]) => (
<SubcategoryGroup key={sub} name={sub === "—" ? "Other" : sub} items={subItems} locations={locations} plants={plants} onRefresh={onRefresh} />
))
: items.map(item => (
<ItemRow key={item.id} item={item} locations={locations} plants={plants} onRefresh={onRefresh} /> <ItemRow key={item.id} item={item} locations={locations} plants={plants} onRefresh={onRefresh} />
))} ))
}
</div> </div>
)} )}
</div> </div>
@@ -118,9 +150,7 @@ function LocationGroup({ name, items, locations, plants, onRefresh }: {
} }
export function PantryClient({ initialItems, locations, plants }: { export function PantryClient({ initialItems, locations, plants }: {
initialItems: PantryItem[]; initialItems: PantryItem[]; locations: LocationOption[]; plants: PlantOption[];
locations: LocationOption[];
plants: PlantOption[];
}) { }) {
const [items, setItems] = useState(initialItems); const [items, setItems] = useState(initialItems);
@@ -130,7 +160,7 @@ export function PantryClient({ initialItems, locations, plants }: {
} }
const grouped = items.reduce<Record<string, PantryItem[]>>((acc, item) => { const grouped = items.reduce<Record<string, PantryItem[]>>((acc, item) => {
const key = item.locationName ?? "No location"; const key = item.pantryCategory ?? "Uncategorized";
if (!acc[key]) acc[key] = []; if (!acc[key]) acc[key] = [];
acc[key].push(item); acc[key].push(item);
return acc; return acc;
@@ -160,8 +190,10 @@ export function PantryClient({ initialItems, locations, plants }: {
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([name, groupItems]) => ( {Object.entries(grouped)
<LocationGroup key={name} name={name} items={groupItems} locations={locations} plants={plants} onRefresh={refresh} /> .sort(([a], [b]) => a === "Uncategorized" ? 1 : b === "Uncategorized" ? -1 : a.localeCompare(b))
.map(([name, catItems]) => (
<CategoryGroup key={name} name={name} items={catItems} locations={locations} plants={plants} onRefresh={refresh} />
))} ))}
</div> </div>
)} )}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { Plus, Pencil } from "lucide-react"; import { Plus, Pencil, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -16,7 +16,55 @@ const SOURCES = [
{ value: "OTHER", label: "Other" }, { 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"]; const COMMON_UNITS = ["lbs", "oz", "g", "kg", "bags", "jars", "quart jars", "pint jars", "gallon bags", "quart bags", "containers", "cans", "bottles", "bunches", "loaves", "portions", "each"];
const PRICE_UNITS = ["package", "lb", "oz", "kg", "each", "dozen", "bunch"];
const DEFAULT_CATEGORIES = ["Meat", "Produce", "Dairy & Eggs", "Baked Goods", "Soups & Broths", "Sauces & Condiments", "Grains & Legumes", "Herbs & Spices", "Prepared Meals", "Fruit", "Seafood", "Other"];
const DEFAULT_SUBCATEGORIES: Record<string, string[]> = {
"Meat": ["Beef", "Poultry", "Pork", "Lamb", "Game", "Sausage", "Other"],
"Produce": ["Berries", "Vegetables", "Leafy Greens", "Root Vegetables", "Corn", "Peppers", "Tomatoes", "Other"],
"Dairy & Eggs": ["Butter", "Cheese", "Milk", "Cream", "Eggs", "Other"],
"Baked Goods": ["Bread", "Muffins", "Cookies", "Cake", "Pie", "Crackers", "Other"],
"Fruit": ["Berries", "Stone Fruit", "Apples & Pears", "Citrus", "Tropical", "Other"],
"Seafood": ["Fish", "Shrimp", "Shellfish", "Other"],
};
type Suggestion = {
name: string;
description: string | null;
unit: string | null;
source: string | null;
pantryCategory: string | null;
pantrySubcategory: string | null;
priceUnit: string | null;
pricePaid: number | null;
locationId: string | null;
locationName: string | null;
plantId: string | null;
};
type CatalogData = {
suggestions: Suggestion[];
categories: string[];
subcategories: { category: string; subcategory: string }[];
};
type Form = {
name: string;
description: string;
quantity: string;
unit: string;
locationId: string;
storedAt: string;
bestBefore: string;
source: string;
plantId: string;
pantryCategory: string;
pantrySubcategory: string;
pricePaid: string;
priceUnit: string;
notes: string;
};
type Props = { type Props = {
item?: PantryItem; item?: PantryItem;
@@ -29,25 +77,92 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [catalog, setCatalog] = useState<CatalogData | null>(null);
const [nameSuggestions, setNameSuggestions] = useState<Suggestion[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [autofillBanner, setAutofillBanner] = useState<Suggestion | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const [form, setForm] = useState({ const blankForm = (): Form => ({
name: item?.name ?? "", name: item?.name ?? "",
description: item?.description ?? "", description: item?.description ?? "",
quantity: item?.quantity?.toString() ?? "1", quantity: item?.quantity?.toString() ?? "1",
unit: item?.unit ?? "", unit: item?.unit ?? "",
packageSize: item?.packageSize ?? "",
locationId: item?.locationId ?? "", locationId: item?.locationId ?? "",
storedAt: item?.storedAt ? new Date(item.storedAt).toISOString().slice(0, 10) : "", storedAt: item?.storedAt ? new Date(item.storedAt).toISOString().slice(0, 10) : "",
bestBefore: item?.bestBefore ? new Date(item.bestBefore).toISOString().slice(0, 10) : "", bestBefore: item?.bestBefore ? new Date(item.bestBefore).toISOString().slice(0, 10) : "",
source: item?.source ?? "", source: item?.source ?? "",
plantId: item?.plantId ?? "", plantId: item?.plantId ?? "",
pantryCategory: (item as any)?.pantryCategory ?? "",
pantrySubcategory: (item as any)?.pantrySubcategory ?? "",
pricePaid: (item as any)?.pricePaid?.toString() ?? "",
priceUnit: (item as any)?.priceUnit ?? "package",
notes: item?.notes ?? "", notes: item?.notes ?? "",
}); });
function set(key: string, val: string) { const [form, setForm] = useState<Form>(blankForm);
function set(key: keyof Form, val: string) {
setForm(f => ({ ...f, [key]: val })); setForm(f => ({ ...f, [key]: val }));
} }
// Load catalog on open (for category/subcategory autocomplete)
useEffect(() => {
if (!open) return;
fetch("/api/pantry/catalog")
.then(r => r.json())
.then(setCatalog)
.catch(() => {});
}, [open]);
// Debounced name search for autocomplete
function handleNameChange(val: string) {
set("name", val);
setAutofillBanner(null);
clearTimeout(debounceRef.current);
if (val.length < 2) { setNameSuggestions([]); setShowDropdown(false); return; }
debounceRef.current = setTimeout(async () => {
const res = await fetch(`/api/pantry/catalog?q=${encodeURIComponent(val)}`);
if (!res.ok) return;
const data: CatalogData = await res.json();
setNameSuggestions(data.suggestions.slice(0, 6));
setShowDropdown(data.suggestions.length > 0);
}, 200);
}
function applyAutofill(s: Suggestion) {
setForm(f => ({
...f,
name: s.name,
description: s.description ?? f.description,
unit: s.unit ?? f.unit,
source: s.source ?? f.source,
pantryCategory: s.pantryCategory ?? f.pantryCategory,
pantrySubcategory: s.pantrySubcategory ?? f.pantrySubcategory,
priceUnit: s.priceUnit ?? f.priceUnit,
pricePaid: s.pricePaid != null ? s.pricePaid.toString() : f.pricePaid,
locationId: s.locationId ?? f.locationId,
plantId: s.plantId ?? f.plantId,
}));
setShowDropdown(false);
setAutofillBanner(null);
}
function selectSuggestion(s: Suggestion) {
setShowDropdown(false);
setAutofillBanner(s);
set("name", s.name);
}
// Subcategory options: merge defaults + previously used
const allCategories = Array.from(new Set([...DEFAULT_CATEGORIES, ...(catalog?.categories ?? [])])).sort();
const subcatDefaults = DEFAULT_SUBCATEGORIES[form.pantryCategory] ?? [];
const subcatFromCatalog = (catalog?.subcategories ?? [])
.filter(s => s.category === form.pantryCategory)
.map(s => s.subcategory);
const allSubcategories = Array.from(new Set([...subcatDefaults, ...subcatFromCatalog])).sort();
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
@@ -58,12 +173,15 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
description: form.description.trim() || null, description: form.description.trim() || null,
quantity: parseFloat(form.quantity) || 1, quantity: parseFloat(form.quantity) || 1,
unit: form.unit.trim() || null, unit: form.unit.trim() || null,
packageSize: form.packageSize.trim() || null,
locationId: form.locationId || null, locationId: form.locationId || null,
storedAt: form.storedAt || null, storedAt: form.storedAt || null,
bestBefore: form.bestBefore || null, bestBefore: form.bestBefore || null,
source: form.source || null, source: form.source || null,
plantId: form.plantId || null, plantId: form.plantId || null,
pantryCategory: form.pantryCategory.trim() || null,
pantrySubcategory: form.pantrySubcategory.trim() || null,
pricePaid: form.pricePaid ? parseFloat(form.pricePaid) : null,
priceUnit: form.priceUnit || null,
notes: form.notes.trim() || null, notes: form.notes.trim() || null,
kind: "CONSUMABLE", kind: "CONSUMABLE",
}; };
@@ -74,6 +192,8 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
}); });
if (!res.ok) throw new Error((await res.json()).error ?? "Failed"); if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
setOpen(false); setOpen(false);
setForm(blankForm());
setAutofillBanner(null);
onSuccess(); onSuccess();
} catch (err) { } catch (err) {
setError(String(err).replace("Error: ", "")); setError(String(err).replace("Error: ", ""));
@@ -96,22 +216,98 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
</Button> </Button>
)} )}
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={o => { setOpen(o); if (!o) { setAutofillBanner(null); setShowDropdown(false); } }}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{item ? "Edit item" : "Add pantry item"}</DialogTitle> <DialogTitle>{item ? "Edit item" : "Add pantry item"}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-2"> <form onSubmit={handleSubmit} className="space-y-4 py-2">
<div className="space-y-1">
{/* Name with autocomplete */}
<div className="space-y-1 relative">
<Label>Name *</Label> <Label>Name *</Label>
<input className={inputCls} value={form.name} onChange={e => set("name", e.target.value)} required placeholder="e.g. Blueberries, Chicken broth, Sourdough" /> <input
ref={nameRef}
className={inputCls}
value={form.name}
onChange={e => handleNameChange(e.target.value)}
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
required
placeholder="e.g. Blueberries, Ground beef, Chicken broth"
/>
{showDropdown && nameSuggestions.length > 0 && (
<div className="absolute z-50 left-0 right-0 top-full mt-1 bg-popover border rounded-md shadow-md overflow-hidden">
<p className="px-3 py-1.5 text-[10px] text-muted-foreground uppercase tracking-wider">Previously added</p>
{nameSuggestions.map(s => (
<button
key={s.name}
type="button"
onMouseDown={() => selectSuggestion(s)}
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2"
>
<span>{s.name}</span>
<span className="text-xs text-muted-foreground shrink-0">
{[s.pantryCategory, s.pantrySubcategory].filter(Boolean).join(" ")}
</span>
</button>
))}
</div>
)}
</div> </div>
{/* Autofill banner */}
{autofillBanner && (
<div className="flex items-center gap-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-2 text-sm">
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
<span className="flex-1 text-amber-800">
Fill in previous settings for <strong>{autofillBanner.name}</strong>?
</span>
<button type="button" onClick={() => applyAutofill(autofillBanner)} className="text-amber-700 font-medium hover:underline shrink-0">Apply</button>
<button type="button" onClick={() => setAutofillBanner(null)} className="text-amber-400 hover:text-amber-600">
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Category */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Description / package type</Label> <Label>Category</Label>
<input className={inputCls} value={form.description} onChange={e => set("description", e.target.value)} placeholder="e.g. quart freezer bag, 1-gallon zip bag" /> <input
className={inputCls}
list="cat-list"
value={form.pantryCategory}
onChange={e => { set("pantryCategory", e.target.value); set("pantrySubcategory", ""); }}
placeholder="e.g. Meat"
/>
<datalist id="cat-list">
{allCategories.map(c => <option key={c} value={c} />)}
</datalist>
</div>
<div className="space-y-1">
<Label>Subcategory</Label>
<input
className={inputCls}
list="subcat-list"
value={form.pantrySubcategory}
onChange={e => set("pantrySubcategory", e.target.value)}
placeholder="e.g. Beef"
disabled={!form.pantryCategory}
/>
<datalist id="subcat-list">
{allSubcategories.map(c => <option key={c} value={c} />)}
</datalist>
</div>
</div> </div>
{/* Description */}
<div className="space-y-1">
<Label>Package / container description</Label>
<input className={inputCls} value={form.description} onChange={e => set("description", e.target.value)} placeholder="e.g. 1-gallon freezer bag, quart jar, vacuum-sealed" />
</div>
{/* Quantity + unit */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Quantity *</Label> <Label>Quantity *</Label>
@@ -119,13 +315,31 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Unit</Label> <Label>Unit</Label>
<input className={inputCls} list="unit-suggestions" value={form.unit} onChange={e => set("unit", e.target.value)} placeholder="bags, lbs, jars…" /> <input className={inputCls} list="unit-list" value={form.unit} onChange={e => set("unit", e.target.value)} placeholder="bags, lbs, jars…" />
<datalist id="unit-suggestions"> <datalist id="unit-list">
{COMMON_UNITS.map(u => <option key={u} value={u} />)} {COMMON_UNITS.map(u => <option key={u} value={u} />)}
</datalist> </datalist>
</div> </div>
</div> </div>
{/* Price */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Price paid</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">$</span>
<input className={inputCls + " pl-6"} type="number" min="0" step="0.01" value={form.pricePaid} onChange={e => set("pricePaid", e.target.value)} placeholder="0.00" />
</div>
</div>
<div className="space-y-1">
<Label>Per</Label>
<select className={inputCls} value={form.priceUnit} onChange={e => set("priceUnit", e.target.value)}>
{PRICE_UNITS.map(u => <option key={u} value={u}>{u}</option>)}
</select>
</div>
</div>
{/* Location */}
<div className="space-y-1"> <div className="space-y-1">
<Label>Location</Label> <Label>Location</Label>
<select className={inputCls} value={form.locationId} onChange={e => set("locationId", e.target.value)}> <select className={inputCls} value={form.locationId} onChange={e => set("locationId", e.target.value)}>
@@ -134,6 +348,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
</select> </select>
</div> </div>
{/* Dates */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Date stored / frozen</Label> <Label>Date stored / frozen</Label>
@@ -145,6 +360,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
</div> </div>
</div> </div>
{/* Source */}
<div className="space-y-1"> <div className="space-y-1">
<Label>Source</Label> <Label>Source</Label>
<select className={inputCls} value={form.source} onChange={e => set("source", e.target.value)}> <select className={inputCls} value={form.source} onChange={e => set("source", e.target.value)}>
@@ -155,13 +371,11 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
{form.source === "GARDEN" && ( {form.source === "GARDEN" && (
<div className="space-y-1"> <div className="space-y-1">
<Label>Plant (optional)</Label> <Label>Plant</Label>
<select className={inputCls} value={form.plantId} onChange={e => set("plantId", e.target.value)}> <select className={inputCls} value={form.plantId} onChange={e => set("plantId", e.target.value)}>
<option value=""> select a plant </option> <option value=""> select a plant </option>
{plants.map(p => ( {plants.map(p => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>{p.commonName}{p.variety ? ` (${p.variety})` : ""}</option>
{p.commonName}{p.variety ? ` (${p.variety})` : ""}
</option>
))} ))}
</select> </select>
</div> </div>