From 6b75e47ee90e49f101a2694365ce7bd7ab529772 Mon Sep 17 00:00:00 2001
From: Bonna Moon
Date: Mon, 29 Jun 2026 18:31:14 -0500
Subject: [PATCH] =?UTF-8?q?v0.21.0=20=E2=80=94=20Pantry:=20bulk=20pull=20d?=
=?UTF-8?q?ialog?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
src/app/api/pantry/bulk-use/route.ts | 61 +++++++
src/components/pantry/pantry-client.tsx | 6 +-
src/components/pantry/pull-dialog.tsx | 207 ++++++++++++++++++++++++
src/lib/changelog.ts | 7 +
src/lib/version.ts | 2 +-
5 files changed, 281 insertions(+), 2 deletions(-)
create mode 100644 src/app/api/pantry/bulk-use/route.ts
create mode 100644 src/components/pantry/pull-dialog.tsx
diff --git a/src/app/api/pantry/bulk-use/route.ts b/src/app/api/pantry/bulk-use/route.ts
new file mode 100644
index 0000000..d9b8bf0
--- /dev/null
+++ b/src/app/api/pantry/bulk-use/route.ts
@@ -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 });
+ }
+}
diff --git a/src/components/pantry/pantry-client.tsx b/src/components/pantry/pantry-client.tsx
index 79000d6..a6944ab 100644
--- a/src/components/pantry/pantry-client.tsx
+++ b/src/components/pantry/pantry-client.tsx
@@ -3,6 +3,7 @@
import { useState } from "react";
import { AlertTriangle, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react";
import { PantryItemDialog } from "./pantry-item-dialog";
+import { PullDialog } from "./pull-dialog";
import { UseItemDialog } from "./use-item-dialog";
export type PantryItem = {
@@ -188,7 +189,10 @@ export function PantryClient({ initialItems, locations, plants }: {
)}
-
+
{items.length === 0 ? (
diff --git a/src/components/pantry/pull-dialog.tsx b/src/components/pantry/pull-dialog.tsx
new file mode 100644
index 0000000..03994d8
--- /dev/null
+++ b/src/components/pantry/pull-dialog.tsx
@@ -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>({});
+ const [reason, setReason] = useState("");
+ const [selectedReason, setSelectedReason] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const searchRef = useRef(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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
index f113092..5eee720 100644
--- a/src/lib/changelog.ts
+++ b/src/lib/changelog.ts
@@ -8,6 +8,13 @@ export type 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",
date: "2026-06-29",
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 43eea40..a6f7233 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -1,2 +1,2 @@
// 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";