Pantry now runs on the v1 API like Inventory/Animals: service layer in src/lib/services/pantry.ts, pure logic + tests in src/lib/pantry/core.ts, thin routes at /api/v1/pantry/* riding the items:* scopes. Pulls write ItemLog type USED with a structured amount (queryable consumption). Bulk-use runs its reads inside the transaction, accumulates repeated pulls, and reports skipped items instead of silently dropping them — the Pull dialog now surfaces those by name. Old /api/pantry routes deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
488 lines
20 KiB
TypeScript
488 lines
20 KiB
TypeScript
"use client";
|
||
|
||
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";
|
||
import { Combobox } from "@/components/ui/combobox";
|
||
import { CATEGORY_OPTIONS, getSubcategoryOptions } from "@/lib/pantry/categories";
|
||
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", "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"];
|
||
|
||
|
||
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 }[];
|
||
labels: 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;
|
||
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 [catalog, setCatalog] = useState<CatalogData | null>(null);
|
||
const [nameSuggestions, setNameSuggestions] = useState<Suggestion[]>([]);
|
||
const [showDropdown, setShowDropdown] = useState(false);
|
||
const [autofillBanner, setAutofillBanner] = useState<Suggestion | null>(null);
|
||
const [tags, setTags] = useState<string[]>(item?.tags ?? []);
|
||
const [tagInput, setTagInput] = useState("");
|
||
const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
|
||
const [showTagDropdown, setShowTagDropdown] = useState(false);
|
||
const nameRef = useRef<HTMLInputElement>(null);
|
||
const tagInputRef = useRef<HTMLInputElement>(null);
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||
|
||
const blankForm = (): Form => ({
|
||
name: item?.name ?? "",
|
||
description: item?.description ?? "",
|
||
quantity: item?.quantity?.toString() ?? "1",
|
||
unit: item?.unit ?? "",
|
||
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 ?? "",
|
||
});
|
||
|
||
const [form, setForm] = useState<Form>(blankForm);
|
||
|
||
function set(key: keyof Form, val: string) {
|
||
setForm(f => ({ ...f, [key]: val }));
|
||
}
|
||
|
||
function addTag(name: string) {
|
||
const t = name.trim();
|
||
if (!t || tags.includes(t)) return;
|
||
setTags(prev => [...prev, t]);
|
||
setTagInput("");
|
||
setShowTagDropdown(false);
|
||
}
|
||
|
||
function removeTag(name: string) {
|
||
setTags(prev => prev.filter(t => t !== name));
|
||
}
|
||
|
||
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||
if ((e.key === "Enter" || e.key === ",") && tagInput.trim()) {
|
||
e.preventDefault();
|
||
addTag(tagInput);
|
||
} else if (e.key === "Backspace" && !tagInput && tags.length) {
|
||
removeTag(tags[tags.length - 1]);
|
||
}
|
||
}
|
||
|
||
// Reset tag state when dialog opens/closes
|
||
useEffect(() => {
|
||
if (open) {
|
||
setTags(item?.tags ?? []);
|
||
setTagInput("");
|
||
}
|
||
}, [open]);
|
||
|
||
// Load catalog on open (for name autocomplete suggestions)
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
fetch("/api/v1/pantry/catalog")
|
||
.then(r => r.json())
|
||
.then(setCatalog)
|
||
.catch(() => {});
|
||
}, [open]);
|
||
|
||
// Tag autocomplete from catalog labels
|
||
useEffect(() => {
|
||
const q = tagInput.trim().toLowerCase();
|
||
if (!q || !catalog?.labels) { setTagSuggestions([]); setShowTagDropdown(false); return; }
|
||
const matches = catalog.labels.filter(l => l.toLowerCase().includes(q) && !tags.includes(l));
|
||
setTagSuggestions(matches.slice(0, 6));
|
||
setShowTagDropdown(matches.length > 0 || q.length > 0);
|
||
}, [tagInput, catalog, tags]);
|
||
|
||
// 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/v1/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);
|
||
}
|
||
|
||
// Merge static category list with any custom categories previously saved to the DB.
|
||
const catalogCategories: string[] = catalog?.categories ?? [];
|
||
const knownCategoryValues = new Set(CATEGORY_OPTIONS.map(o => o.value.toLowerCase()));
|
||
const extraCategoryOptions = catalogCategories
|
||
.filter(c => !knownCategoryValues.has(c.toLowerCase()))
|
||
.map(c => ({ value: c, label: c }));
|
||
const allCategoryOptions = [...CATEGORY_OPTIONS, ...extraCategoryOptions];
|
||
|
||
// Static subcategories for this category + any custom ones from the catalog.
|
||
const staticSubcategoryOptions = getSubcategoryOptions(form.pantryCategory);
|
||
const catalogSubcategories: { category: string; subcategory: string }[] = catalog?.subcategories ?? [];
|
||
const knownSubValues = new Set(staticSubcategoryOptions.map(o => o.value.toLowerCase()));
|
||
const extraSubOptions = catalogSubcategories
|
||
.filter(cs => cs.category.toLowerCase() === form.pantryCategory.toLowerCase() && !knownSubValues.has(cs.subcategory.toLowerCase()))
|
||
.map(cs => ({ value: cs.subcategory, label: cs.subcategory }));
|
||
const subcategoryOptions = [...staticSubcategoryOptions, ...extraSubOptions];
|
||
|
||
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,
|
||
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",
|
||
tags,
|
||
};
|
||
const res = await fetch(item ? `/api/v1/pantry/${item.id}` : "/api/v1/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);
|
||
setForm(blankForm());
|
||
setAutofillBanner(null);
|
||
setTags([]);
|
||
setTagInput("");
|
||
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={o => { setOpen(o); if (!o) { setAutofillBanner(null); setShowDropdown(false); } }}>
|
||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>{item ? "Edit item" : "Add pantry item"}</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<form onSubmit={handleSubmit} noValidate className="space-y-4 py-2">
|
||
|
||
{/* Name with autocomplete */}
|
||
<div className="space-y-1 relative">
|
||
<Label>Name *</Label>
|
||
<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>
|
||
|
||
{/* 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">
|
||
<Label>Category</Label>
|
||
<Combobox
|
||
options={allCategoryOptions}
|
||
value={form.pantryCategory}
|
||
onChange={v => { set("pantryCategory", v); set("pantrySubcategory", ""); }}
|
||
placeholder="Select or add category…"
|
||
allowCustom
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Subcategory</Label>
|
||
<Combobox
|
||
options={subcategoryOptions}
|
||
value={form.pantrySubcategory}
|
||
onChange={v => set("pantrySubcategory", v)}
|
||
placeholder="Select or add…"
|
||
disabled={!form.pantryCategory}
|
||
emptyText="Type to add a subcategory"
|
||
allowCustom
|
||
/>
|
||
</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="space-y-1">
|
||
<Label>Quantity *</Label>
|
||
<input className={inputCls} type="number" min="0" step="1" 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-list" value={form.unit} onChange={e => set("unit", e.target.value)} placeholder="bags, lbs, jars…" />
|
||
<datalist id="unit-list">
|
||
{COMMON_UNITS.map(u => <option key={u} value={u} />)}
|
||
</datalist>
|
||
</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">
|
||
<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>
|
||
|
||
{/* Dates */}
|
||
<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>
|
||
|
||
{/* Source */}
|
||
<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</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>
|
||
|
||
{/* Tags — multi-label, e.g. "Pre-cooked" + "Poultry" */}
|
||
<div className="space-y-1.5">
|
||
<Label>Tags</Label>
|
||
<div className={`${inputCls} flex flex-wrap gap-1.5 min-h-[40px] cursor-text`} onClick={() => tagInputRef.current?.focus()}>
|
||
{tags.map(tag => (
|
||
<span key={tag} className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary text-xs px-2 py-0.5">
|
||
{tag}
|
||
<button type="button" onClick={e => { e.stopPropagation(); removeTag(tag); }} className="hover:text-destructive">
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
<div className="relative flex-1 min-w-[120px]">
|
||
<input
|
||
ref={tagInputRef}
|
||
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||
placeholder={tags.length ? "" : "e.g. Pre-cooked, Poultry…"}
|
||
value={tagInput}
|
||
onChange={e => setTagInput(e.target.value)}
|
||
onKeyDown={handleTagKeyDown}
|
||
onBlur={() => setTimeout(() => setShowTagDropdown(false), 150)}
|
||
/>
|
||
{showTagDropdown && (
|
||
<div className="absolute z-50 left-0 top-full mt-1 bg-popover border rounded-md shadow-md overflow-hidden min-w-[160px]">
|
||
{tagSuggestions.map(s => (
|
||
<button key={s} type="button" onMouseDown={() => addTag(s)}
|
||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent">
|
||
{s}
|
||
</button>
|
||
))}
|
||
{tagInput.trim() && !tagSuggestions.some(s => s.toLowerCase() === tagInput.trim().toLowerCase()) && (
|
||
<button type="button" onMouseDown={() => addTag(tagInput)}
|
||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center gap-2 text-primary border-t">
|
||
<Plus className="h-3.5 w-3.5" /> Add “{tagInput.trim()}”
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">Press Enter or comma to add. Tag things multiple ways, like “Pre-cooked” and “Poultry”.</p>
|
||
</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>
|
||
</>
|
||
);
|
||
}
|