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:
61
src/app/api/pantry/bulk-use/route.ts
Normal file
61
src/app/api/pantry/bulk-use/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
207
src/components/pantry/pull-dialog.tsx
Normal file
207
src/components/pantry/pull-dialog.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user