From e7fd9af62ccb603217775ca3944a4fd80d975f39 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 7 May 2026 20:49:58 -0400 Subject: [PATCH] Add checkout/custody feature for tracking items in personal possession Items can now be checked out of their bin into "my custody" and later checked back in or marked consumed. Adds checkout/checkin API endpoints, a My Custody sidebar page, CheckoutFlow and CheckinFlow modals, and updates ProductDetail, Inventory, ConsumeFlow, and MarkGoneFlow to handle the new checked-out status. Bulk items prompt for remaining weight on check-in. Co-Authored-By: Claude Opus 4.6 --- server/src/db.ts | 9 + server/src/routes/bootstrap.ts | 2 + server/src/routes/inventory.ts | 69 +++++++- server/src/schema.sql | 1 + web/src/App.tsx | 27 +++ web/src/api.ts | 18 ++ web/src/components/ProductDetail.tsx | 29 +++- web/src/components/Sidebar.tsx | 7 + web/src/components/modals/CheckinFlow.tsx | 193 +++++++++++++++++++++ web/src/components/modals/CheckoutFlow.tsx | 152 ++++++++++++++++ web/src/components/modals/ConsumeFlow.tsx | 2 +- web/src/components/modals/MarkGoneFlow.tsx | 2 +- web/src/components/primitives/index.tsx | 1 + web/src/stats.ts | 6 +- web/src/types.ts | 7 +- web/src/views/CustodyView.tsx | 169 ++++++++++++++++++ web/src/views/Inventory.tsx | 13 +- 17 files changed, 689 insertions(+), 18 deletions(-) create mode 100644 web/src/components/modals/CheckinFlow.tsx create mode 100644 web/src/components/modals/CheckoutFlow.tsx create mode 100644 web/src/views/CustodyView.tsx diff --git a/server/src/db.ts b/server/src/db.ts index bc03e83..49ea90d 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -13,10 +13,19 @@ db.pragma("foreign_keys = ON"); archiveLegacyIfPresent(); archiveV1IfPresent(); +migrateAddCheckoutDate(); const schema = readFileSync(join(__dirname, "schema.sql"), "utf8"); db.exec(schema); +function migrateAddCheckoutDate(): void { + const cols = db + .prepare(`PRAGMA table_info(inventory_items)`) + .all() as { name: string }[]; + if (cols.length === 0 || cols.some((c) => c.name === "checkout_date")) return; + db.exec(`ALTER TABLE inventory_items ADD COLUMN checkout_date TEXT`); +} + // One-shot migration: the original schema put per-instance fields (weight, // bin_id, etc.) directly on `products`. The split schema separates products // (catalog) from inventory_items (instance). When we detect the old shape, diff --git a/server/src/routes/bootstrap.ts b/server/src/routes/bootstrap.ts index b28d84d..a6502f6 100644 --- a/server/src/routes/bootstrap.ts +++ b/server/src/routes/bootstrap.ts @@ -32,6 +32,7 @@ type InventoryRow = { status: string; consumed_date: string | null; gone_date: string | null; + checkout_date: string | null; rating: number | null; notes: string | null; }; @@ -108,6 +109,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => { status: i.status, consumedDate: i.consumed_date, goneDate: i.gone_date, + checkoutDate: i.checkout_date, rating: i.rating, notes: i.notes, audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({ diff --git a/server/src/routes/inventory.ts b/server/src/routes/inventory.ts index fad03fa..903074f 100644 --- a/server/src/routes/inventory.ts +++ b/server/src/routes/inventory.ts @@ -212,6 +212,67 @@ inventoryRouter.patch("/inventory/:id", (req, res) => { res.json({ ok: true }); }); +inventoryRouter.post("/inventory/:id/checkout", (req, res) => { + const { id } = req.params; + const { date } = req.body as { date: string }; + const result = db + .prepare( + `UPDATE inventory_items + SET status = 'checked-out', checkout_date = ?, bin_id = NULL + 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; + }; + + const item = db + .prepare< + [string], + { product_id: string; last_audit_weight: number | null; weight: number } + >( + `SELECT product_id, last_audit_weight, weight + 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" }); + + 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( + `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 { @@ -222,8 +283,8 @@ inventoryRouter.post("/inventory/:id/finish", (req, res) => { const result = db .prepare( `UPDATE inventory_items - SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL - WHERE id = ? AND status = 'active'`, + SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL, checkout_date = NULL + 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" }); @@ -242,8 +303,8 @@ inventoryRouter.post("/inventory/:id/gone", (req, res) => { const result = db .prepare( `UPDATE inventory_items - SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL - WHERE id = ? AND status = 'active'`, + 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"); diff --git a/server/src/schema.sql b/server/src/schema.sql index b56f9a7..3799e43 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS inventory_items ( status TEXT NOT NULL DEFAULT 'active', consumed_date TEXT, gone_date TEXT, + checkout_date TEXT, rating INTEGER, notes TEXT ); diff --git a/web/src/App.tsx b/web/src/App.tsx index 31322dd..d3c4c35 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,6 +20,9 @@ import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js"; import { ConsumeFlow } from "./components/modals/ConsumeFlow.js"; import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js"; import { AuditFlow } from "./components/modals/AuditFlow.js"; +import { CheckoutFlow } from "./components/modals/CheckoutFlow.js"; +import { CheckinFlow } from "./components/modals/CheckinFlow.js"; +import { CustodyView } from "./views/CustodyView.js"; import { AddBinModal, AddBrandModal, @@ -35,6 +38,8 @@ type ModalKey = | "consume" | "gone" | "audit" + | "checkout" + | "checkin" | "addBrand" | "addShop" | "addBin" @@ -96,6 +101,16 @@ export function App() { setModalItem(i ?? null); setModal("audit"); }; + const openCheckout = (i?: Item) => { + setModalItem(i ?? null); + setSelected(null); + setModal("checkout"); + }; + const openCheckin = (i?: Item) => { + setModalItem(i ?? null); + setSelected(null); + setModal("checkin"); + }; const openEdit = (i: Item) => { setModalItem(i); setSelected(null); @@ -157,6 +172,7 @@ export function App() { onAddProduct={openAdd} onMarkFinished={() => openConsume()} onAudit={() => openAudit()} + onCheckout={() => openCheckout()} />
@@ -167,6 +183,9 @@ export function App() { openAudit()} /> } /> + + } /> setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} /> } /> @@ -192,6 +211,8 @@ export function App() { onMarkGone={openMarkGone} onAudit={openAudit} onEdit={openEdit} + onCheckout={openCheckout} + onCheckin={openCheckin} /> )} @@ -208,6 +229,12 @@ export function App() { {modal === "audit" && ( setModal(null)} item={modalItem} /> )} + {modal === "checkout" && ( + setModal(null)} item={modalItem} /> + )} + {modal === "checkin" && ( + setModal(null)} item={modalItem} /> + )} {modal === "addBrand" && setModal(null)} />} {modal === "addShop" && setModal(null)} />} {modal === "addBin" && setModal(null)} />} diff --git a/web/src/api.ts b/web/src/api.ts index 4f4cc10..733adf1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -119,6 +119,24 @@ export const api = { body: JSON.stringify(body), }), + checkoutInventoryItem: ( + id: string, + body: { date: string }, + ) => + request<{ ok: true }>(`/inventory/${id}/checkout`, { + method: "POST", + body: JSON.stringify(body), + }), + + checkinInventoryItem: ( + id: string, + body: { date: string; binId: string; remainingWeight?: number }, + ) => + request<{ ok: true }>(`/inventory/${id}/checkin`, { + method: "POST", + body: JSON.stringify(body), + }), + auditInventoryItem: ( id: string, body: { date: string; mode: AuditMode; value: number; confirmedBy?: string }, diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index ab2e3f5..8a09670 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -15,6 +15,8 @@ export function ProductDetail({ onMarkGone, onAudit, onEdit, + onCheckout, + onCheckin, }: { item: Item; data: Bootstrap; @@ -23,6 +25,8 @@ export function ProductDetail({ onMarkGone: (i: Item) => void; onAudit: (i: Item) => void; onEdit: (i: Item) => void; + onCheckout: (i: Item) => void; + onCheckin: (i: Item) => void; }) { const bin = data.bins.find((b) => b.id === item.binId); const cfg = TYPES.find((t) => t.id === item.type); @@ -34,6 +38,7 @@ export function ProductDetail({ const sinceCheck = helpers.daysSinceCheck(item, TODAY_STR); const isActive = item.status === "active"; + const isCheckedOut = item.status === "checked-out"; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -58,7 +63,7 @@ export function ProductDetail({ ["Shop", helpers.shopName(data, item.shopId)], ["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`], ["Purchase date", fmt.date(item.purchaseDate)], - ["Bin", bin ? bin.name : ], + ["Bin", isCheckedOut ? "In your custody" : bin ? bin.name : ], ["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`], [ "Cost per gram", @@ -69,6 +74,9 @@ export function ProductDetail({ : "—", ], ]; + if (item.status === "checked-out") { + detailRows.push(["Checked out", fmt.date(item.checkoutDate)]); + } if (item.status === "consumed") { detailRows.push( ["Date finished", fmt.date(item.consumedDate)], @@ -130,17 +138,27 @@ export function ProductDetail({ Inventory · {item.assetId}
+ {isActive && ( + onCheckout(item)}> + Check out + + )} {isActive && ( onAudit(item)}> Audit )} - {isActive && ( + {isCheckedOut && ( + onCheckin(item)}> + Check in + + )} + {(isActive || isCheckedOut) && ( onConsume(item)}> Mark consumed )} - {isActive && ( + {(isActive || isCheckedOut) && ( onMarkGone(item)} /> )} onEdit(item)} /> @@ -159,6 +177,9 @@ export function ProductDetail({ {item.status === "gone" && ( Gone · {fmt.daysAgo(item.goneDate)} )} + {isCheckedOut && ( + Checked out · {fmt.daysAgo(item.checkoutDate)} + )} {isActive && overdue && Audit overdue · {sinceCheck}d}

- {isActive && ( + {(isActive || isCheckedOut) && (
void; onMarkFinished: () => void; onAudit: () => void; + onCheckout: () => void; }) { return (