Files
Moonbase/src/components/pantry/pull-dialog.tsx
tonym 083e2992c9 v0.29.0 — Ramble box, dark-mode toggle, pantry pull reasons
- #10 Ramble box: new /ramble sticky-note board (post/pin/archive/delete),
  ported from FMB. New Ramble model + migration, /api/rambles routes, nav entry.
- #6 Dark-mode toggle: sun/moon button in the top bar (dark palette already
  existed in CSS, just had no control).
- #9 Pantry pull reason: structured disposition (Immediate use / Preserved /
  Gave away / Waste) on ItemLog.reason, added to single + bulk pull dialogs
  and the audit payload. New migration.
- #2 Suggestions: drop the stale white-card wrapper; render SuggestionsPanel
  bare like FMB so it's readable and thumbnails show.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 14:40:54 -05:00

237 lines
9.5 KiB
TypeScript

"use client";
import { useState, useMemo, useRef } from "react";
import { PackageOpen } 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 { cn } from "@/lib/utils";
import { PANTRY_USE_REASONS, type PantryUseReason } from "@/lib/pantry/core";
import type { PantryItem } from "./pantry-client";
const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"];
type Pull = { amount: string };
export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSuccess: () => void }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [pulls, setPulls] = useState<Record<string, Pull>>({});
const [reason, setReason] = useState("");
const [disposition, setDisposition] = useState<PantryUseReason>("consumed");
const [selectedReason, setSelectedReason] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return items;
return items.filter(i =>
i.name.toLowerCase().includes(q) ||
i.pantryCategory?.toLowerCase().includes(q) ||
i.pantrySubcategory?.toLowerCase().includes(q) ||
i.tags?.some(t => t.toLowerCase().includes(q))
);
}, [items, search]);
const activePulls = Object.entries(pulls).filter(([, p]) => {
const n = parseFloat(p.amount);
return !isNaN(n) && n > 0;
});
function setAmount(id: string, val: string) {
setPulls(prev => ({ ...prev, [id]: { amount: val } }));
}
function pickReason(r: string) {
if (selectedReason === r) { setSelectedReason(null); setReason(""); }
else { setSelectedReason(r); setReason(r); }
}
function reset() {
setSearch("");
setPulls({});
setReason("");
setDisposition("consumed");
setSelectedReason(null);
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (activePulls.length === 0) { setError("Enter a quantity for at least one item."); return; }
// Validate amounts against stock
for (const [id, p] of activePulls) {
const item = items.find(i => i.id === id);
const amt = parseFloat(p.amount);
if (item && amt > item.quantity) {
setError(`Only ${item.quantity} ${item.unit ?? ""} of "${item.name}" on hand.`.trim());
return;
}
}
setSaving(true);
setError(null);
try {
const res = await fetch("/api/v1/pantry/bulk-use", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
pulls: activePulls.map(([id, p]) => ({ id, amount: parseFloat(p.amount) })),
notes: reason.trim() || null,
reason: disposition,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Failed");
if (data.skipped?.length > 0) {
// Someone else deleted these since the list loaded — say so instead of
// pretending the whole pull worked.
const names = data.skipped.map(
(s: { id: string }) => items.find(i => i.id === s.id)?.name ?? "an item",
);
setPulls({});
setError(
`Couldn't pull ${names.join(", ")} — no longer in the pantry. Everything else was pulled.`,
);
onSuccess();
return;
}
setOpen(false);
reset();
onSuccess();
} catch (err) {
setError(String(err).replace("Error: ", ""));
} finally {
setSaving(false);
}
}
const inputCls = "w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<>
<Button variant="outline" onClick={() => { reset(); setOpen(true); }} className="gap-2">
<PackageOpen className="h-4 w-4" /> Pull from pantry
</Button>
<Dialog open={open} onOpenChange={o => { setOpen(o); if (!o) reset(); }}>
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-0 shrink-0">
<DialogTitle>Pull from pantry</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
{/* Reason */}
<div className="px-6 pt-4 pb-3 border-b space-y-2 shrink-0">
<div className="flex flex-wrap gap-1.5">
{PANTRY_USE_REASONS.map(r => (
<button key={r.value} type="button" onClick={() => setDisposition(r.value)}
className={cn("rounded-full border px-3 py-1 text-xs font-medium transition-colors",
disposition === r.value ? "bg-primary text-primary-foreground border-primary" : "bg-background hover:bg-accent"
)}>
{r.label}
</button>
))}
</div>
<Label className="text-xs text-muted-foreground">What's it for? (optional)</Label>
<div className="flex flex-wrap gap-1.5">
{QUICK_REASONS.map(r => (
<button key={r} type="button" onClick={() => pickReason(r)}
className={cn("rounded-full border px-3 py-1 text-xs",
selectedReason === r ? "bg-primary text-primary-foreground border-primary" : "bg-background hover:bg-accent"
)}>
{r}
</button>
))}
</div>
<input
className={inputCls}
placeholder="Or type your own reason…"
value={reason}
onChange={e => { setReason(e.target.value); setSelectedReason(null); }}
/>
</div>
{/* Search */}
<div className="px-6 py-3 border-b shrink-0">
<input
ref={searchRef}
className={inputCls}
placeholder="Search by name, category, or tag…"
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
{/* Items list */}
<div className="flex-1 overflow-y-auto px-6 py-2">
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No items match.</p>
)}
{filtered.map(item => {
const amt = pulls[item.id]?.amount ?? "";
const n = parseFloat(amt);
const active = !isNaN(n) && n > 0;
const over = active && n > item.quantity;
return (
<div key={item.id}
className={cn("flex items-center gap-3 py-2.5 border-b last:border-0",
active && "bg-primary/5 -mx-6 px-6 rounded"
)}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.name}</p>
<p className="text-xs text-muted-foreground">
{item.quantity} {item.unit ?? "on hand"}
{item.pantryCategory && <> · {item.pantryCategory}</>}
{item.tags?.length > 0 && <> · {item.tags.join(", ")}</>}
</p>
</div>
<div className="shrink-0 w-24">
<input
type="number"
min="0"
step="1"
className={cn(
"w-full rounded-md border px-2 py-1.5 text-sm text-center focus:outline-none focus:ring-2 focus:ring-ring",
over && "border-destructive text-destructive",
active && !over && "border-primary"
)}
placeholder="0"
value={amt}
onChange={e => setAmount(item.id, e.target.value)}
/>
{over && <p className="text-[10px] text-destructive text-center mt-0.5">Max {item.quantity}</p>}
</div>
<span className="text-xs text-muted-foreground shrink-0 w-8">{item.unit ?? ""}</span>
</div>
);
})}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t shrink-0 space-y-3">
{activePulls.length > 0 && (
<p className="text-xs text-muted-foreground">
Pulling {activePulls.length} item{activePulls.length !== 1 ? "s" : ""}
{reason && <> for <strong>{reason}</strong></>}
</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={saving || activePulls.length === 0}>
{saving ? "Saving…" : activePulls.length > 0 ? `Pull ${activePulls.length} item${activePulls.length !== 1 ? "s" : ""}` : "Pull"}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</>
);
}