- #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>
237 lines
9.5 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|