Files
Moonbase/src/components/pantry/pantry-item-dialog.tsx
Bonna Moon 25b1ba9aeb v0.23.0 — Pantry: structured USED pull logs + move to /api/v1 service layer
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>
2026-07-06 00:14:53 -05:00

488 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 &ldquo;{tagInput.trim()}&rdquo;
</button>
)}
</div>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">Press Enter or comma to add. Tag things multiple ways, like &ldquo;Pre-cooked&rdquo; and &ldquo;Poultry&rdquo;.</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>
</>
);
}