From 62a0089ec2118f0e0ae7e198109427dbd0390e2c Mon Sep 17 00:00:00 2001 From: Bonna Moon Date: Mon, 29 Jun 2026 17:37:59 -0500 Subject: [PATCH] =?UTF-8?q?v0.6.1=20=E2=80=94=20Pantry:=20categories,=20pr?= =?UTF-8?q?ice=20tracking,=20and=20autocomplete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 9 + prisma/schema.prisma | 19 +- src/app/(dashboard)/kitchen/pantry/page.tsx | 5 + src/app/api/pantry/[id]/route.ts | 9 + src/app/api/pantry/catalog/route.ts | 64 +++++ src/app/api/pantry/route.ts | 13 + src/components/pantry/pantry-client.tsx | 94 ++++--- src/components/pantry/pantry-item-dialog.tsx | 250 ++++++++++++++++-- 8 files changed, 407 insertions(+), 56 deletions(-) create mode 100644 prisma/migrations/20260629000002_v0_20_pantry_detail/migration.sql create mode 100644 src/app/api/pantry/catalog/route.ts diff --git a/prisma/migrations/20260629000002_v0_20_pantry_detail/migration.sql b/prisma/migrations/20260629000002_v0_20_pantry_detail/migration.sql new file mode 100644 index 0000000..b3d75e3 --- /dev/null +++ b/prisma/migrations/20260629000002_v0_20_pantry_detail/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2ba968c..5e00980 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -349,13 +349,17 @@ model Item { customFields Json? // Consumable attributes - barcode String? - 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) + barcode String? + 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) + 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 qrSlug String? @unique // short slug for a printed QR label @@ -377,6 +381,7 @@ model Item { @@index([name]) @@index([barcode]) @@index([plantId]) + @@index([pantryCategory]) } model Label { diff --git a/src/app/(dashboard)/kitchen/pantry/page.tsx b/src/app/(dashboard)/kitchen/pantry/page.tsx index c5cbd76..1bbee37 100644 --- a/src/app/(dashboard)/kitchen/pantry/page.tsx +++ b/src/app/(dashboard)/kitchen/pantry/page.tsx @@ -12,6 +12,7 @@ export default async function PantryPage() { select: { id: true, name: true, description: true, quantity: true, unit: true, storedAt: true, bestBefore: true, source: true, plantId: true, notes: true, + pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true, location: { select: { id: true, name: true } }, plant: { select: { id: true, commonName: true, variety: true } }, }, @@ -31,6 +32,10 @@ export default async function PantryPage() { bestBefore: i.bestBefore, 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, locationName: i.location?.name ?? null, plantId: i.plant?.id ?? null, diff --git a/src/app/api/pantry/[id]/route.ts b/src/app/api/pantry/[id]/route.ts index af0f806..9c595de 100644 --- a/src/app/api/pantry/[id]/route.ts +++ b/src/app/api/pantry/[id]/route.ts @@ -15,6 +15,10 @@ const updateSchema = z.object({ bestBefore: z.string().nullable().optional(), source: z.nativeEnum(ItemSource).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(), }); @@ -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.source !== undefined && { source: data.source ?? 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 }), }, select: { id: true, name: true, description: true, quantity: true, unit: true, storedAt: true, bestBefore: true, source: true, plantId: true, notes: true, + pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true, location: { select: { id: true, name: true } }, plant: { select: { id: true, commonName: true, variety: true } }, }, diff --git a/src/app/api/pantry/catalog/route.ts b/src/app/api/pantry/catalog/route.ts new file mode 100644 index 0000000..3e0399c --- /dev/null +++ b/src/app/api/pantry/catalog/route.ts @@ -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(); + 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, + }); +} diff --git a/src/app/api/pantry/route.ts b/src/app/api/pantry/route.ts index 07dc029..93456c4 100644 --- a/src/app/api/pantry/route.ts +++ b/src/app/api/pantry/route.ts @@ -7,6 +7,7 @@ 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, + pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true, location: { select: { id: true, name: true } }, plant: { select: { id: true, commonName: true, variety: true } }, } as const; @@ -18,6 +19,10 @@ function serialize(i: any) { packageSize: i.description, storedAt: i.storedAt, bestBefore: i.bestBefore, 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, locationName: i.location?.name ?? null, plantId: i.plant?.id ?? null, @@ -46,6 +51,10 @@ const createSchema = z.object({ bestBefore: z.string().nullable().optional(), source: z.nativeEnum(ItemSource).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(), }); @@ -66,6 +75,10 @@ export async function POST(req: Request) { bestBefore: data.bestBefore ? new Date(data.bestBefore) : null, source: data.source ?? 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, }, select: PANTRY_SELECT, diff --git a/src/components/pantry/pantry-client.tsx b/src/components/pantry/pantry-client.tsx index 5d357a6..1b77666 100644 --- a/src/components/pantry/pantry-client.tsx +++ b/src/components/pantry/pantry-client.tsx @@ -1,8 +1,7 @@ "use client"; import { useState } from "react"; -import { AlertTriangle, Plus, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { AlertTriangle, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react"; import { PantryItemDialog } from "./pantry-item-dialog"; import { UseItemDialog } from "./use-item-dialog"; @@ -20,6 +19,10 @@ export type PantryItem = { locationName: string | null; plantId: string | null; plantName: string | null; + pantryCategory: string | null; + pantrySubcategory: string | null; + pricePaid: number | null; + priceUnit: 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 }; const SOURCE_LABELS: Record = { - GARDEN: "Our garden", - HOMEMADE: "Homemade", - STORE: "Store", - FARMERS_MARKET: "Farmers market", - GIFTED: "Gifted", - OTHER: "Other", + GARDEN: "Our garden", HOMEMADE: "Homemade", STORE: "Store", + FARMERS_MARKET: "Farmers market", GIFTED: "Gifted", OTHER: "Other", }; function daysUntil(date: Date) { @@ -42,16 +41,18 @@ function daysUntil(date: Date) { function ExpiryBadge({ date }: { date: Date }) { const days = daysUntil(date); if (days < 0) return Expired; + if (days <= 7) return Expires in {days}d; 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; + item: PantryItem; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void; }) { + const priceStr = item.pricePaid != null + ? `$${item.pricePaid.toFixed(2)}${item.priceUnit && item.priceUnit !== "package" ? `/${item.priceUnit}` : ""}` + : null; + return (
@@ -64,11 +65,13 @@ function ItemRow({ item, locations, plants, onRefresh }: {
{item.quantity} {item.unit ?? ""} - {item.packageSize ? ` · ${item.packageSize}` : ""} + {priceStr && {priceStr}} + {item.locationName && · {item.locationName}} {item.storedAt && Stored {new Date(item.storedAt).toLocaleDateString()}} {item.bestBefore && }
+ {item.notes &&

{item.notes}

}
@@ -78,21 +81,44 @@ function ItemRow({ item, locations, plants, onRefresh }: { ); } -function LocationGroup({ name, items, locations, plants, onRefresh }: { - name: string; - items: PantryItem[]; - locations: LocationOption[]; - plants: PlantOption[]; - onRefresh: () => void; +function SubcategoryGroup({ name, items, locations, plants, onRefresh }: { + name: string; items: PantryItem[]; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void; +}) { + const [open, setOpen] = useState(true); + return ( +
+ + {open && items.map(item => ( + + ))} +
+ ); +} + +function CategoryGroup({ 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); + // Group by subcategory + const subcats = items.reduce>((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 ( -
+
+ {open && (
- {items.map(item => ( - - ))} + {hasSubcats + ? Object.entries(subcats).sort(([a], [b]) => a.localeCompare(b)).map(([sub, subItems]) => ( + + )) + : items.map(item => ( + + )) + }
)}
@@ -118,9 +150,7 @@ function LocationGroup({ name, items, locations, plants, onRefresh }: { } export function PantryClient({ initialItems, locations, plants }: { - initialItems: PantryItem[]; - locations: LocationOption[]; - plants: PlantOption[]; + initialItems: PantryItem[]; locations: LocationOption[]; plants: PlantOption[]; }) { const [items, setItems] = useState(initialItems); @@ -130,7 +160,7 @@ export function PantryClient({ initialItems, locations, plants }: { } const grouped = items.reduce>((acc, item) => { - const key = item.locationName ?? "No location"; + const key = item.pantryCategory ?? "Uncategorized"; if (!acc[key]) acc[key] = []; acc[key].push(item); return acc; @@ -160,9 +190,11 @@ export function PantryClient({ initialItems, locations, plants }: {
) : (
- {Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([name, groupItems]) => ( - - ))} + {Object.entries(grouped) + .sort(([a], [b]) => a === "Uncategorized" ? 1 : b === "Uncategorized" ? -1 : a.localeCompare(b)) + .map(([name, catItems]) => ( + + ))}
)}
diff --git a/src/components/pantry/pantry-item-dialog.tsx b/src/components/pantry/pantry-item-dialog.tsx index a5aa712..3861e43 100644 --- a/src/components/pantry/pantry-item-dialog.tsx +++ b/src/components/pantry/pantry-item-dialog.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { Plus, Pencil } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { Plus, Pencil, Sparkles, X } 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"; @@ -16,7 +16,55 @@ const SOURCES = [ { 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 = { + "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 = { item?: PantryItem; @@ -29,25 +77,92 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [catalog, setCatalog] = useState(null); + const [nameSuggestions, setNameSuggestions] = useState([]); + const [showDropdown, setShowDropdown] = useState(false); + const [autofillBanner, setAutofillBanner] = useState(null); + const nameRef = useRef(null); + const debounceRef = useRef>(); - const [form, setForm] = useState({ + const blankForm = (): Form => ({ 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 ?? "", + 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 ?? "", }); - function set(key: string, val: string) { + const [form, setForm] = useState
(blankForm); + + function set(key: keyof Form, val: string) { 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) { e.preventDefault(); setSaving(true); @@ -58,12 +173,15 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) 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, + 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, 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"); setOpen(false); + setForm(blankForm()); + setAutofillBanner(null); onSuccess(); } catch (err) { setError(String(err).replace("Error: ", "")); @@ -96,22 +216,98 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) )} - + { setOpen(o); if (!o) { setAutofillBanner(null); setShowDropdown(false); } }}> {item ? "Edit item" : "Add pantry item"} + -
+ + {/* Name with autocomplete */} +
- set("name", e.target.value)} required placeholder="e.g. Blueberries, Chicken broth, Sourdough" /> + handleNameChange(e.target.value)} + onBlur={() => setTimeout(() => setShowDropdown(false), 150)} + required + placeholder="e.g. Blueberries, Ground beef, Chicken broth" + /> + {showDropdown && nameSuggestions.length > 0 && ( +
+

Previously added

+ {nameSuggestions.map(s => ( + + ))} +
+ )}
+ {/* Autofill banner */} + {autofillBanner && ( +
+ + + Fill in previous settings for {autofillBanner.name}? + + + +
+ )} + + {/* Category */} +
+
+ + { set("pantryCategory", e.target.value); set("pantrySubcategory", ""); }} + placeholder="e.g. Meat" + /> + + {allCategories.map(c => +
+
+ + set("pantrySubcategory", e.target.value)} + placeholder="e.g. Beef" + disabled={!form.pantryCategory} + /> + + {allSubcategories.map(c => +
+
+ + {/* Description */}
- - set("description", e.target.value)} placeholder="e.g. quart freezer bag, 1-gallon zip bag" /> + + set("description", e.target.value)} placeholder="e.g. 1-gallon freezer bag, quart jar, vacuum-sealed" />
+ {/* Quantity + unit */}
@@ -119,13 +315,31 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
- set("unit", e.target.value)} placeholder="bags, lbs, jars…" /> - + set("unit", e.target.value)} placeholder="bags, lbs, jars…" /> + {COMMON_UNITS.map(u =>
+ {/* Price */} +
+
+ +
+ $ + set("pricePaid", e.target.value)} placeholder="0.00" /> +
+
+
+ + +
+
+ + {/* Location */}
+ {/* Dates */}
@@ -145,6 +360,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
+ {/* Source */}
set("plantId", e.target.value)}> {plants.map(p => ( - + ))}