From 946e96c3ea0a84440690b5b0c2d36feaa29b1e2d Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 7 May 2026 22:14:01 -0400 Subject: [PATCH] Add bulk editing to inventory tab with atomic batch API Multi-select inventory items via checkboxes (select-all, shift-click range, group header select) and apply bulk actions through a floating toolbar: edit fields (shop, bin, price, THC/CBD), consume, checkout, check in, and mark gone. Backend processes all operations in a single SQLite transaction for atomicity. Co-Authored-By: Claude Opus 4.6 --- server/src/routes/inventory.ts | 255 +++++++++++------- web/src/App.tsx | 46 +++- web/src/api.ts | 13 + web/src/components/BulkToolbar.tsx | 96 +++++++ .../components/modals/BulkCheckinModal.tsx | 124 +++++++++ .../components/modals/BulkCheckoutModal.tsx | 114 ++++++++ .../components/modals/BulkConsumeModal.tsx | 154 +++++++++++ web/src/components/modals/BulkEditModal.tsx | 179 ++++++++++++ web/src/components/modals/BulkGoneModal.tsx | 162 +++++++++++ web/src/components/primitives/index.tsx | 39 +++ web/src/hooks/useSelection.ts | 75 ++++++ web/src/styles/global.css | 16 +- web/src/views/Inventory.tsx | 172 ++++++++++-- 13 files changed, 1328 insertions(+), 117 deletions(-) create mode 100644 web/src/components/BulkToolbar.tsx create mode 100644 web/src/components/modals/BulkCheckinModal.tsx create mode 100644 web/src/components/modals/BulkCheckoutModal.tsx create mode 100644 web/src/components/modals/BulkConsumeModal.tsx create mode 100644 web/src/components/modals/BulkEditModal.tsx create mode 100644 web/src/components/modals/BulkGoneModal.tsx create mode 100644 web/src/hooks/useSelection.ts diff --git a/server/src/routes/inventory.ts b/server/src/routes/inventory.ts index 903074f..68741ea 100644 --- a/server/src/routes/inventory.ts +++ b/server/src/routes/inventory.ts @@ -85,6 +85,8 @@ inventoryRouter.post("/inventory", (req, res) => { res.json({ id, assetId }); }); +// ─── Shared helpers (used by individual routes + batch) ─────────── + type UpdateBody = Partial<{ shopId: string | null; binId: string | null; @@ -98,36 +100,33 @@ type UpdateBody = Partial<{ purchaseDate: string; }>; -inventoryRouter.patch("/inventory/:id", (req, res) => { - const { id } = req.params; - const body = req.body as UpdateBody; - - type Row = { - id: string; - shop_id: string | null; - bin_id: string | null; - product_id: string; - price: number; - thc: number; - cbd: number; - total_cannabinoids: number; - weight: number; - last_audit_weight: number | null; - count_original: number; - count_last_audit: number | null; - unit_weight: number; - purchase_date: string; - }; +type ItemRow = { + id: string; + shop_id: string | null; + bin_id: string | null; + product_id: string; + price: number; + thc: number; + cbd: number; + total_cannabinoids: number; + weight: number; + last_audit_weight: number | null; + count_original: number; + count_last_audit: number | null; + unit_weight: number; + purchase_date: string; +}; +function doUpdate(id: string, body: UpdateBody): void { const existing = db - .prepare<[string], Row>( + .prepare<[string], ItemRow>( `SELECT id, shop_id, bin_id, product_id, price, thc, cbd, total_cannabinoids, weight, last_audit_weight, count_original, count_last_audit, unit_weight, purchase_date FROM inventory_items WHERE id = ?`, ) .get(id); - if (!existing) return res.status(404).json({ error: "inventory item not found" }); + if (!existing) throw new Error("inventory item not found"); const product = db .prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`) @@ -171,8 +170,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => { ? (body.unitWeight as number) : existing.unit_weight; - // Mirror the original size into the "last audit" field while no audits - // exist — keeps the next audit's prev_value accurate after an edit. const nextLastAuditWeight = !isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight; const nextCountLastAudit = @@ -208,13 +205,9 @@ inventoryRouter.patch("/inventory/:id", (req, res) => { unitWeight: nextUnitWeight, purchaseDate: nextPurchaseDate, }); +} - res.json({ ok: true }); -}); - -inventoryRouter.post("/inventory/:id/checkout", (req, res) => { - const { id } = req.params; - const { date } = req.body as { date: string }; +function doCheckout(id: string, date: string): void { const result = db .prepare( `UPDATE inventory_items @@ -222,18 +215,10 @@ inventoryRouter.post("/inventory/:id/checkout", (req, res) => { WHERE id = ? AND status = 'active'`, ) .run(date, id); - if (result.changes === 0) return res.status(404).json({ error: "not found or not active" }); - res.json({ ok: true }); -}); - -inventoryRouter.post("/inventory/:id/checkin", (req, res) => { - const { id } = req.params; - const { date, binId, remainingWeight } = req.body as { - date: string; - binId: string; - remainingWeight?: number; - }; + if (result.changes === 0) throw new Error("not found or not active"); +} +function doCheckin(id: string, date: string, binId: string, remainingWeight?: number): void { const item = db .prepare< [string], @@ -243,43 +228,33 @@ inventoryRouter.post("/inventory/:id/checkin", (req, res) => { FROM inventory_items WHERE id = ? AND status = 'checked-out'`, ) .get(id); - if (!item) return res.status(404).json({ error: "not found or not checked-out" }); + if (!item) throw new Error("not found or not checked-out"); const product = db .prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`) .get(item.product_id); const isBulk = product?.kind === "bulk"; - const tx = db.transaction(() => { + db.prepare( + `UPDATE inventory_items + SET status = 'active', bin_id = ?, checkout_date = NULL + WHERE id = ?`, + ).run(binId, id); + + if (isBulk && remainingWeight != null && Number.isFinite(remainingWeight)) { + const prev = item.last_audit_weight ?? item.weight; db.prepare( - `UPDATE inventory_items - SET status = 'active', bin_id = ?, checkout_date = NULL - WHERE id = ?`, - ).run(binId, id); + `INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by) + VALUES (?, ?, 'weigh', ?, ?, 'checkin')`, + ).run(id, date, remainingWeight, prev); + db.prepare(`UPDATE inventory_items SET last_audit_weight = ? WHERE id = ?`).run( + remainingWeight, + id, + ); + } +} - if (isBulk && remainingWeight != null && Number.isFinite(remainingWeight)) { - const prev = item.last_audit_weight ?? item.weight; - db.prepare( - `INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by) - VALUES (?, ?, 'weigh', ?, ?, 'checkin')`, - ).run(id, date, remainingWeight, prev); - db.prepare(`UPDATE inventory_items SET last_audit_weight = ? WHERE id = ?`).run( - remainingWeight, - id, - ); - } - }); - tx(); - res.json({ ok: true }); -}); - -inventoryRouter.post("/inventory/:id/finish", (req, res) => { - const { id } = req.params; - const { date, rating, notes } = req.body as { - date: string; - rating?: number; - notes?: string; - }; +function doFinish(id: string, date: string, rating?: number, notes?: string): void { const result = db .prepare( `UPDATE inventory_items @@ -287,37 +262,84 @@ inventoryRouter.post("/inventory/:id/finish", (req, res) => { WHERE id = ? AND status IN ('active', 'checked-out')`, ) .run(date, rating ?? null, notes ?? null, id); - if (result.changes === 0) return res.status(404).json({ error: "not found or not active" }); - res.json({ ok: true }); + if (result.changes === 0) throw new Error("not found or not active"); +} + +function doGone(id: string, date: string, reason: string, notes?: string): void { + const combinedNotes = notes ? `${reason}: ${notes}` : reason; + const result = db + .prepare( + `UPDATE inventory_items + SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL, checkout_date = NULL + WHERE id = ? AND status IN ('active', 'checked-out')`, + ) + .run(date, combinedNotes, id); + if (result.changes === 0) throw new Error("not found or not active"); + db.prepare( + `INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by) + VALUES (?, ?, 'presence', 0, NULL, 'lost')`, + ).run(id, date); +} + +// ─── Individual routes (thin wrappers around helpers) ───────────── + +inventoryRouter.patch("/inventory/:id", (req, res) => { + try { + doUpdate(req.params.id, req.body as UpdateBody); + res.json({ ok: true }); + } catch (e: any) { + res.status(404).json({ error: e.message }); + } +}); + +inventoryRouter.post("/inventory/:id/checkout", (req, res) => { + try { + doCheckout(req.params.id, (req.body as { date: string }).date); + res.json({ ok: true }); + } catch (e: any) { + res.status(404).json({ error: e.message }); + } +}); + +inventoryRouter.post("/inventory/:id/checkin", (req, res) => { + const { date, binId, remainingWeight } = req.body as { + date: string; + binId: string; + remainingWeight?: number; + }; + try { + doCheckin(req.params.id, date, binId, remainingWeight); + res.json({ ok: true }); + } catch (e: any) { + res.status(404).json({ error: e.message }); + } +}); + +inventoryRouter.post("/inventory/:id/finish", (req, res) => { + const { date, rating, notes } = req.body as { + date: string; + rating?: number; + notes?: string; + }; + try { + doFinish(req.params.id, date, rating, notes); + res.json({ ok: true }); + } catch (e: any) { + res.status(404).json({ error: e.message }); + } }); inventoryRouter.post("/inventory/:id/gone", (req, res) => { - const { id } = req.params; const { date, reason, notes } = req.body as { date: string; reason: string; notes?: string; }; - const combinedNotes = notes ? `${reason}: ${notes}` : reason; - const tx = db.transaction(() => { - const result = db - .prepare( - `UPDATE inventory_items - SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL, checkout_date = NULL - WHERE id = ? AND status IN ('active', 'checked-out')`, - ) - .run(date, combinedNotes, id); - if (result.changes === 0) throw new Error("not found"); - db.prepare( - `INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by) - VALUES (?, ?, 'presence', 0, NULL, 'lost')`, - ).run(id, date); - }); try { - tx(); + doGone(req.params.id, date, reason, notes); res.json({ ok: true }); - } catch { - res.status(404).json({ error: "not found or not active" }); + } catch (e: any) { + res.status(404).json({ error: e.message }); } }); @@ -376,3 +398,54 @@ inventoryRouter.post("/inventory/:id/audit", (req, res) => { tx(); res.json({ ok: true }); }); + +// ─── Batch endpoint ─────────────────────────────────────────────── + +type BatchOp = + | { action: "update"; id: string; fields: UpdateBody } + | { action: "checkout"; id: string; date: string } + | { action: "checkin"; id: string; date: string; binId: string } + | { action: "finish"; id: string; date: string; rating?: number; notes?: string } + | { action: "gone"; id: string; date: string; reason: string; notes?: string }; + +inventoryRouter.post("/inventory/batch", (req, res) => { + const { ops } = req.body as { ops: BatchOp[] }; + + if (!Array.isArray(ops) || ops.length === 0) { + return res.status(400).json({ error: "ops array required" }); + } + if (ops.length > 200) { + return res.status(400).json({ error: "too many operations (max 200)" }); + } + + const tx = db.transaction(() => { + for (const op of ops) { + switch (op.action) { + case "update": + doUpdate(op.id, op.fields); + break; + case "checkout": + doCheckout(op.id, op.date); + break; + case "checkin": + doCheckin(op.id, op.date, op.binId); + break; + case "finish": + doFinish(op.id, op.date, op.rating, op.notes); + break; + case "gone": + doGone(op.id, op.date, op.reason, op.notes); + break; + default: + throw new Error(`unknown action: ${(op as any).action}`); + } + } + }); + + try { + tx(); + res.json({ ok: true, count: ops.length }); + } catch (e: any) { + res.status(400).json({ error: e.message ?? "batch failed" }); + } +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index d3c4c35..a60d992 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -31,6 +31,11 @@ import { EditBrandModal, EditShopModal, } from "./components/modals/CatalogModals.js"; +import { BulkEditModal } from "./components/modals/BulkEditModal.js"; +import { BulkConsumeModal } from "./components/modals/BulkConsumeModal.js"; +import { BulkCheckoutModal } from "./components/modals/BulkCheckoutModal.js"; +import { BulkCheckinModal } from "./components/modals/BulkCheckinModal.js"; +import { BulkGoneModal } from "./components/modals/BulkGoneModal.js"; type ModalKey = | "add" @@ -46,6 +51,11 @@ type ModalKey = | "editBin" | "editBrand" | "editShop" + | "bulkEdit" + | "bulkConsume" + | "bulkCheckout" + | "bulkCheckin" + | "bulkGone" | null; export function App() { @@ -55,6 +65,7 @@ export function App() { const [modalBin, setModalBin] = useState(null); const [modalBrand, setModalBrand] = useState(null); const [modalShop, setModalShop] = useState(null); + const [bulkItems, setBulkItems] = useState([]); const [theme, setTheme] = useState( () => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light", @@ -116,6 +127,13 @@ export function App() { setSelected(null); setModal("edit"); }; + + const openBulkEdit = (items: Item[]) => { setBulkItems(items); setModal("bulkEdit"); }; + const openBulkConsume = (items: Item[]) => { setBulkItems(items); setModal("bulkConsume"); }; + const openBulkCheckout = (items: Item[]) => { setBulkItems(items); setModal("bulkCheckout"); }; + const openBulkCheckin = (items: Item[]) => { setBulkItems(items); setModal("bulkCheckin"); }; + const openBulkGone = (items: Item[]) => { setBulkItems(items); setModal("bulkGone"); }; + if (isLoading) { return (
} /> openAudit()} /> + openAudit()} + onBulkEdit={openBulkEdit} + onBulkConsume={openBulkConsume} + onBulkCheckout={openBulkCheckout} + onBulkCheckin={openBulkCheckin} + onBulkGone={openBulkGone} + /> } /> @@ -247,6 +275,22 @@ export function App() { {modal === "editShop" && modalShop && ( setModal(null)} /> )} + + {modal === "bulkEdit" && ( + setModal(null)} /> + )} + {modal === "bulkConsume" && ( + setModal(null)} /> + )} + {modal === "bulkCheckout" && ( + setModal(null)} /> + )} + {modal === "bulkCheckin" && ( + setModal(null)} /> + )} + {modal === "bulkGone" && ( + setModal(null)} /> + )}
); } diff --git a/web/src/api.ts b/web/src/api.ts index 733adf1..d938be1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,5 +1,12 @@ import type { Bootstrap, AuditMode } from "./types.js"; +export type BatchOp = + | { action: "update"; id: string; fields: Partial<{ shopId: string | null; binId: string | null; price: number; thc: number; cbd: number; totalCannabinoids: number }> } + | { action: "checkout"; id: string; date: string } + | { action: "checkin"; id: string; date: string; binId: string } + | { action: "finish"; id: string; date: string; rating?: number; notes?: string } + | { action: "gone"; id: string; date: string; reason: string; notes?: string }; + async function request(path: string, init?: RequestInit): Promise { const res = await fetch(`/api${path}`, { ...init, @@ -146,6 +153,12 @@ export const api = { body: JSON.stringify(body), }), + batchInventory: (ops: BatchOp[]) => + request<{ ok: true; count: number }>("/inventory/batch", { + method: "POST", + body: JSON.stringify({ ops }), + }), + // Catalog tables (brand/shop/bin) — unchanged createBrand: (name: string) => request<{ id: string; name: string }>("/brands", { diff --git a/web/src/components/BulkToolbar.tsx b/web/src/components/BulkToolbar.tsx new file mode 100644 index 0000000..83075be --- /dev/null +++ b/web/src/components/BulkToolbar.tsx @@ -0,0 +1,96 @@ +import type { Item } from "../types.js"; +import { Btn, Icon } from "./primitives/index.js"; + +export function BulkToolbar({ + count, + selectedItems, + onClear, + onBulkEdit, + onBulkConsume, + onBulkCheckout, + onBulkCheckin, + onBulkGone, +}: { + count: number; + selectedItems: Item[]; + onClear: () => void; + onBulkEdit: () => void; + onBulkConsume: () => void; + onBulkCheckout: () => void; + onBulkCheckin: () => void; + onBulkGone: () => void; +}) { + const canCheckout = selectedItems.filter((i) => i.status === "active").length; + const canCheckin = selectedItems.filter((i) => i.status === "checked-out").length; + const canConsume = selectedItems.filter((i) => i.status === "active" || i.status === "checked-out").length; + const canGone = canConsume; + + return ( +
+
+ + {count} selected + + +
+
+ + Edit + + {canCheckout > 0 && ( + + Checkout ({canCheckout}) + + )} + {canCheckin > 0 && ( + + Check in ({canCheckin}) + + )} + {canConsume > 0 && ( + + Consume ({canConsume}) + + )} + {canGone > 0 && ( + + Mark gone ({canGone}) + + )} +
+
+ ); +} diff --git a/web/src/components/modals/BulkCheckinModal.tsx b/web/src/components/modals/BulkCheckinModal.tsx new file mode 100644 index 0000000..867e33f --- /dev/null +++ b/web/src/components/modals/BulkCheckinModal.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Bootstrap, Item } from "../../types.js"; +import { TODAY_STR } from "../../types.js"; +import { api } from "../../api.js"; +import type { BatchOp } from "../../api.js"; +import { Btn, Field, Input, Select } from "../primitives/index.js"; +import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; +import { useToast } from "../Toast.js"; + +export function BulkCheckinModal({ + data, + items, + onClose, +}: { + data: Bootstrap; + items: Item[]; + onClose: () => void; +}) { + const qc = useQueryClient(); + const { toast } = useToast(); + const eligible = items.filter((i) => i.status === "checked-out"); + const excluded = items.length - eligible.length; + + const [date, setDate] = useState(TODAY_STR); + const [binId, setBinId] = useState(data.bins[0]?.id ?? ""); + const [error, setError] = useState(null); + + const checkin = useMutation({ + mutationFn: () => { + const ops: BatchOp[] = eligible.map((i) => ({ + action: "checkin" as const, + id: i.id, + date, + binId, + })); + return api.batchInventory(ops); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + const binName = data.bins.find((b) => b.id === binId)?.name ?? "bin"; + toast(`Checked ${eligible.length} item${eligible.length === 1 ? "" : "s"} into ${binName}`); + onClose(); + }, + onError: (e: Error) => setError(e.message), + }); + + return ( + +
+ + +
+ {excluded > 0 && ( +
+ {excluded} item{excluded === 1 ? " is" : "s are"} not checked out and will be skipped. +
+ )} + + {eligible.length === 0 ? ( +
+ No checked-out items to return. +
+ ) : ( + <> +
+ + + + + setDate(e.target.value)} /> + +
+
+ Items: {eligible.map((i) => i.name).join(", ")} +
+ + )} + + {error && ( +
{error}
+ )} +
+ + +
+
+ Cancel + checkin.mutate()} + > + {checkin.isPending ? "Saving…" : `Check in ${eligible.length}`} + +
+ +
+ + ); +} diff --git a/web/src/components/modals/BulkCheckoutModal.tsx b/web/src/components/modals/BulkCheckoutModal.tsx new file mode 100644 index 0000000..d2b3fa8 --- /dev/null +++ b/web/src/components/modals/BulkCheckoutModal.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Bootstrap, Item } from "../../types.js"; +import { TODAY_STR } from "../../types.js"; +import { api } from "../../api.js"; +import type { BatchOp } from "../../api.js"; +import { Btn, Field, Input } from "../primitives/index.js"; +import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; +import { useToast } from "../Toast.js"; + +export function BulkCheckoutModal({ + data, + items, + onClose, +}: { + data: Bootstrap; + items: Item[]; + onClose: () => void; +}) { + const qc = useQueryClient(); + const { toast } = useToast(); + const eligible = items.filter((i) => i.status === "active"); + const excluded = items.length - eligible.length; + + const [date, setDate] = useState(TODAY_STR); + const [error, setError] = useState(null); + + const checkout = useMutation({ + mutationFn: () => { + const ops: BatchOp[] = eligible.map((i) => ({ + action: "checkout" as const, + id: i.id, + date, + })); + return api.batchInventory(ops); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + toast(`Checked out ${eligible.length} item${eligible.length === 1 ? "" : "s"}`); + onClose(); + }, + onError: (e: Error) => setError(e.message), + }); + + return ( + +
+ + +
+ {excluded > 0 && ( +
+ {excluded} item{excluded === 1 ? " is" : "s are"} not active and will be skipped. +
+ )} + + {eligible.length === 0 ? ( +
+ No active items to check out. +
+ ) : ( + <> +
+ + setDate(e.target.value)} /> + +
+
+ Items: {eligible.map((i) => i.name).join(", ")} +
+ + )} + + {error && ( +
{error}
+ )} +
+ + +
+
+ Cancel + checkout.mutate()} + > + {checkout.isPending ? "Saving…" : `Check out ${eligible.length}`} + +
+ +
+ + ); +} diff --git a/web/src/components/modals/BulkConsumeModal.tsx b/web/src/components/modals/BulkConsumeModal.tsx new file mode 100644 index 0000000..25036a1 --- /dev/null +++ b/web/src/components/modals/BulkConsumeModal.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Bootstrap, Item } from "../../types.js"; +import { TODAY_STR } from "../../types.js"; +import { api } from "../../api.js"; +import type { BatchOp } from "../../api.js"; +import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.js"; +import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; +import { useToast } from "../Toast.js"; + +export function BulkConsumeModal({ + data, + items, + onClose, +}: { + data: Bootstrap; + items: Item[]; + onClose: () => void; +}) { + const qc = useQueryClient(); + const { toast } = useToast(); + const eligible = items.filter((i) => i.status === "active" || i.status === "checked-out"); + const excluded = items.length - eligible.length; + + const [rating, setRating] = useState(4); + const [notes, setNotes] = useState(""); + const [date, setDate] = useState(TODAY_STR); + const [error, setError] = useState(null); + + const finish = useMutation({ + mutationFn: () => { + const ops: BatchOp[] = eligible.map((i) => ({ + action: "finish" as const, + id: i.id, + date, + rating, + notes: notes || undefined, + })); + return api.batchInventory(ops); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + toast(`Marked ${eligible.length} item${eligible.length === 1 ? "" : "s"} as consumed`); + onClose(); + }, + onError: (e: Error) => setError(e.message), + }); + + return ( + +
+ + +
+ {excluded > 0 && ( +
+ {excluded} item{excluded === 1 ? "" : "s"} already consumed or gone — {excluded === 1 ? "it" : "they"} will be skipped. +
+ )} + + {eligible.length === 0 ? ( +
+ No eligible items to consume. +
+ ) : ( + <> +
+ + setDate(e.target.value)} /> + + +
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + {rating}/5 + +
+
+
+
+ +