v0.21.0 — Pantry: bulk pull dialog

New "Pull from pantry" button opens a dialog where you search/filter
all items, type a quantity next to each one you're taking, pick or type
a reason, and submit all in one shot. Items with amounts entered
highlight; over-stock amounts show inline error. Backed by new
POST /api/pantry/bulk-use (transactional quantity deduction + ItemLog).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-06-29 18:31:14 -05:00
parent d3af826e3a
commit 6b75e47ee9
5 changed files with 281 additions and 2 deletions

View File

@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const schema = z.object({
pulls: z.array(z.object({
id: z.string(),
amount: z.number().positive(),
})).min(1),
notes: z.string().nullable().optional(),
});
export async function POST(req: Request) {
try {
const session = await requireAuth();
const { pulls, notes } = schema.parse(await req.json());
const ids = pulls.map(p => p.id);
const items = await db.item.findMany({
where: { id: { in: ids }, kind: "CONSUMABLE", active: true },
select: { id: true, name: true, quantity: true, unit: true },
});
const itemMap = new Map(items.map(i => [i.id, i]));
const results: { id: string; remaining: number }[] = [];
await db.$transaction(async tx => {
for (const pull of pulls) {
const item = itemMap.get(pull.id);
if (!item) continue;
const remaining = Math.max(0, Number(item.quantity) - pull.amount);
await tx.item.update({ where: { id: pull.id }, data: { quantity: remaining } });
await tx.itemLog.create({
data: {
itemId: pull.id,
type: "NOTE",
notes: `Used ${pull.amount} ${item.unit ?? ""}${notes ? `${notes}` : ""}`.trim(),
actorId: session.user.id,
},
});
results.push({ id: pull.id, remaining });
}
await tx.auditEvent.create({
data: {
action: "pantry.bulk-use",
actorId: session.user.id,
payload: { pulls: pulls.map(p => ({ id: p.id, amount: p.amount })), notes },
},
});
});
return NextResponse.json({ ok: true, results });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { AlertTriangle, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react"; import { AlertTriangle, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react";
import { PantryItemDialog } from "./pantry-item-dialog"; import { PantryItemDialog } from "./pantry-item-dialog";
import { PullDialog } from "./pull-dialog";
import { UseItemDialog } from "./use-item-dialog"; import { UseItemDialog } from "./use-item-dialog";
export type PantryItem = { export type PantryItem = {
@@ -188,7 +189,10 @@ export function PantryClient({ initialItems, locations, plants }: {
</p> </p>
)} )}
</div> </div>
<PantryItemDialog locations={locations} plants={plants} onSuccess={refresh} /> <div className="flex items-center gap-2">
<PullDialog items={items} onSuccess={refresh} />
<PantryItemDialog locations={locations} plants={plants} onSuccess={refresh} />
</div>
</div> </div>
{items.length === 0 ? ( {items.length === 0 ? (

View File

@@ -0,0 +1,207 @@
"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 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 [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("");
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/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,
}),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
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">
{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>
</>
);
}

View File

@@ -8,6 +8,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.21.0",
date: "2026-06-29",
changes: [
"Pantry: new \"Pull from pantry\" button — search for items, type quantities next to everything you're taking, pick a reason, and submit all at once. No more opening each item individually.",
],
},
{ {
version: "0.20.0", version: "0.20.0",
date: "2026-06-29", date: "2026-06-29",

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.20.0"; export const APP_VERSION = "0.21.0";