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 ( + <> + + + { setOpen(o); if (!o) reset(); }}> + + + Pull from pantry + + +
+ {/* Reason */} +
+
+ {QUICK_REASONS.map(r => ( + + ))} +
+ { setReason(e.target.value); setSelectedReason(null); }} + /> +
+ + {/* Search */} +
+ setSearch(e.target.value)} + autoFocus + /> +
+ + {/* Items list */} +
+ {filtered.length === 0 && ( +

No items match.

+ )} + {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 ( +
+
+

{item.name}

+

+ {item.quantity} {item.unit ?? "on hand"} + {item.pantryCategory && <> · {item.pantryCategory}} + {item.tags?.length > 0 && <> · {item.tags.join(", ")}} +

+
+
+ setAmount(item.id, e.target.value)} + /> + {over &&

Max {item.quantity}

} +
+ {item.unit ?? ""} +
+ ); + })} +
+ + {/* Footer */} +
+ {activePulls.length > 0 && ( +

+ Pulling {activePulls.length} item{activePulls.length !== 1 ? "s" : ""} + {reason && <> for {reason}} +

+ )} + {error &&

{error}

} +
+ + +
+
+
+
+
+ + ); +} 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";