diff --git a/server/src/db.ts b/server/src/db.ts index 9ba02f0..d3715f5 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -16,6 +16,7 @@ archiveV1IfPresent(); migrateAddCheckoutDate(); migrateAddContainerWeight(); migrateAddPrevBinId(); +migrateAddBinCheckFields(); const schema = readFileSync(join(__dirname, "schema.sql"), "utf8"); db.exec(schema); @@ -44,6 +45,15 @@ function migrateAddPrevBinId(): void { db.exec(`ALTER TABLE inventory_items ADD COLUMN prev_bin_id TEXT REFERENCES bins(id)`); } +function migrateAddBinCheckFields(): void { + const cols = db + .prepare(`PRAGMA table_info(bins)`) + .all() as { name: string }[]; + if (cols.length === 0 || cols.some((c) => c.name === "cadence_days")) return; + db.exec(`ALTER TABLE bins ADD COLUMN cadence_days INTEGER NOT NULL DEFAULT 30`); + db.exec(`ALTER TABLE bins ADD COLUMN last_checked 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 62fa53e..28e6169 100644 --- a/server/src/routes/bootstrap.ts +++ b/server/src/routes/bootstrap.ts @@ -70,7 +70,14 @@ bootstrapRouter.get("/bootstrap", (_req, res) => { .all(); const shops = db.prepare("SELECT * FROM shops ORDER BY id").all(); const brands = db.prepare("SELECT * FROM brands ORDER BY id").all(); - const bins = db.prepare("SELECT id, name, capacity FROM bins ORDER BY id").all(); + const binsRaw = db.prepare("SELECT id, name, capacity, cadence_days, last_checked FROM bins ORDER BY id").all() as { id: string; name: string; capacity: number; cadence_days: number; last_checked: string | null }[]; + const bins = binsRaw.map((b) => ({ + id: b.id, + name: b.name, + capacity: b.capacity, + cadenceDays: b.cadence_days, + lastChecked: b.last_checked, + })); const strains = db .prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE") .all(); diff --git a/server/src/routes/catalog.ts b/server/src/routes/catalog.ts index 2e1eab7..f2f6705 100644 --- a/server/src/routes/catalog.ts +++ b/server/src/routes/catalog.ts @@ -103,20 +103,21 @@ catalogRouter.delete("/shops/:id", (req, res) => { }); catalogRouter.post("/bins", (req, res) => { - const { name, capacity } = req.body as { name: string; capacity?: number }; + const { name, capacity, cadenceDays } = req.body as { name: string; capacity?: number; cadenceDays?: number }; if (!name?.trim()) return res.status(400).json({ error: "name required" }); const id = nextId("bin", "bins"); const cap = Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : 10; - db.prepare("INSERT INTO bins (id, name, capacity) VALUES (?, ?, ?)").run(id, name.trim(), cap); - res.json({ id, name: name.trim(), capacity: cap }); + const cad = Number.isFinite(cadenceDays) && (cadenceDays as number) > 0 ? Math.floor(cadenceDays as number) : 30; + db.prepare("INSERT INTO bins (id, name, capacity, cadence_days) VALUES (?, ?, ?, ?)").run(id, name.trim(), cap, cad); + res.json({ id, name: name.trim(), capacity: cap, cadenceDays: cad, lastChecked: null }); }); catalogRouter.patch("/bins/:id", (req, res) => { const { id } = req.params; - const { name, capacity } = req.body as { name?: string; capacity?: number }; + const { name, capacity, cadenceDays } = req.body as { name?: string; capacity?: number; cadenceDays?: number }; const existing = db - .prepare<[string], { id: string; name: string; capacity: number }>( - "SELECT id, name, capacity FROM bins WHERE id = ?", + .prepare<[string], { id: string; name: string; capacity: number; cadence_days: number; last_checked: string | null }>( + "SELECT id, name, capacity, cadence_days, last_checked FROM bins WHERE id = ?", ) .get(id); if (!existing) return res.status(404).json({ error: "bin not found" }); @@ -126,9 +127,13 @@ catalogRouter.patch("/bins/:id", (req, res) => { Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : existing.capacity; + const nextCadence = + Number.isFinite(cadenceDays) && (cadenceDays as number) > 0 + ? Math.floor(cadenceDays as number) + : existing.cadence_days; - db.prepare("UPDATE bins SET name = ?, capacity = ? WHERE id = ?").run(nextName, nextCapacity, id); - res.json({ id, name: nextName, capacity: nextCapacity }); + db.prepare("UPDATE bins SET name = ?, capacity = ?, cadence_days = ? WHERE id = ?").run(nextName, nextCapacity, nextCadence, id); + res.json({ id, name: nextName, capacity: nextCapacity, cadenceDays: nextCadence, lastChecked: existing.last_checked }); }); // Deleting a bin unassigns any inventory items that reference it (bin_id → NULL), diff --git a/server/src/routes/inventory.ts b/server/src/routes/inventory.ts index 0751cc1..c533c7c 100644 --- a/server/src/routes/inventory.ts +++ b/server/src/routes/inventory.ts @@ -435,6 +435,62 @@ inventoryRouter.post("/inventory/:id/audit", (req, res) => { res.json({ ok: true }); }); +// ─── Bin check endpoint ────────────────────────────────────────── + +inventoryRouter.post("/bins/:id/check", (req, res) => { + const { id } = req.params; + const { date, verifiedItemIds, goneItemIds } = req.body as { + date: string; + verifiedItemIds: string[]; + goneItemIds: string[]; + }; + + const bin = db + .prepare<[string], { id: string }>("SELECT id FROM bins WHERE id = ?") + .get(id); + if (!bin) return res.status(404).json({ error: "bin not found" }); + + const tx = db.transaction(() => { + for (const itemId of verifiedItemIds) { + const item = db + .prepare< + [string], + { product_id: string; count_original: number; count_last_audit: number | null } + >( + `SELECT product_id, count_original, count_last_audit FROM inventory_items WHERE id = ?`, + ) + .get(itemId); + if (!item) continue; + + const prev = item.count_last_audit ?? item.count_original; + db.prepare( + `INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by) + VALUES (?, ?, 'presence', ?, ?, 'bin-check')`, + ).run(itemId, date, prev, prev); + db.prepare( + `UPDATE inventory_items SET count_last_audit = ? WHERE id = ?`, + ).run(prev, itemId); + } + + for (const itemId of goneItemIds) { + try { + doGone(itemId, date, "missing from bin check"); + } catch { + // item may already be gone + } + } + + db.prepare("UPDATE bins SET last_checked = ? WHERE id = ?").run(date, id); + }); + + try { + tx(); + res.json({ ok: true, verified: verifiedItemIds.length, gone: goneItemIds.length }); + } catch (e: any) { + res.status(400).json({ error: e.message }); + } +}); + // ─── Batch endpoint ─────────────────────────────────────────────── type BatchOp = diff --git a/server/src/schema.sql b/server/src/schema.sql index 8518e74..b3a1a4b 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS bins ( id TEXT PRIMARY KEY, name TEXT NOT NULL, location TEXT, - capacity INTEGER NOT NULL DEFAULT 10 + capacity INTEGER NOT NULL DEFAULT 10, + cadence_days INTEGER NOT NULL DEFAULT 30, + last_checked TEXT ); -- Strains: one row per cannabis strain (catalog-level). UNIQUE on name only, diff --git a/web/src/App.tsx b/web/src/App.tsx index cff214c..b0f8ab0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -37,7 +37,8 @@ import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js"; 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 { WeighInFlow } from "./components/modals/WeighInFlow.js"; +import { BinCheckFlow } from "./components/modals/BinCheckFlow.js"; import { CheckoutFlow } from "./components/modals/CheckoutFlow.js"; import { CheckinFlow } from "./components/modals/CheckinFlow.js"; import { CustodyView } from "./views/CustodyView.js"; @@ -61,7 +62,8 @@ type ModalKey = | "edit" | "consume" | "gone" - | "audit" + | "weighIn" + | "binCheck" | "checkout" | "checkin" | "addBrand" @@ -170,17 +172,22 @@ export function App() { setSelected(null); setModal("gone"); }; - const [auditQueue, setAuditQueue] = useState([]); - const openAudit = (i?: Item) => { + const [weighInQueue, setWeighInQueue] = useState([]); + const openWeighIn = (i?: Item) => { setModalItem(i ?? null); - setAuditQueue([]); - setModal("audit"); + setWeighInQueue([]); + setModal("weighIn"); }; - const openAuditQueue = (queue: Item[]) => { + const openWeighInQueue = (queue: Item[]) => { if (queue.length === 0) return; setModalItem(queue[0]!); - setAuditQueue(queue); - setModal("audit"); + setWeighInQueue(queue); + setModal("weighIn"); + }; + const [binCheckBin, setBinCheckBin] = useState(null); + const openBinCheck = (bin?: Bin) => { + setBinCheckBin(bin ?? null); + setModal("binCheck"); }; const openCheckout = (i?: Item) => { setModalItem(i ?? null); @@ -273,7 +280,8 @@ export function App() { openConsume()} - onAudit={() => openAudit()} + onWeighIn={() => openWeighIn()} + onBinCheck={() => openBinCheck()} onCheckout={() => openCheckout()} /> )} @@ -294,14 +302,14 @@ export function App() { )} + } /> openAudit()} + onWeighInNew={() => openWeighIn()} onBulkEdit={openBulkEdit} onBulkConsume={openBulkConsume} onBulkCheckout={openBulkCheckout} @@ -320,7 +328,7 @@ export function App() { } /> setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} /> + setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} onBinCheck={openBinCheck} /> } /> setModal("addShop")} /> @@ -342,7 +350,7 @@ export function App() { onClose={() => { setSelected(null); setDrawerBack(null); }} onConsume={openConsume} onMarkGone={openMarkGone} - onAudit={openAudit} + onWeighIn={openWeighIn} onEdit={openEdit} onCheckout={openCheckout} onCheckin={openCheckin} @@ -462,8 +470,11 @@ export function App() { {modal === "gone" && ( setModal(null)} item={modalItem} /> )} - {modal === "audit" && ( - setModal(null)} item={modalItem} queue={auditQueue.length > 0 ? auditQueue : undefined} /> + {modal === "weighIn" && ( + setModal(null)} item={modalItem} queue={weighInQueue.length > 0 ? weighInQueue : undefined} /> + )} + {modal === "binCheck" && ( + setModal(null)} bin={binCheckBin} /> )} {modal === "checkout" && ( setModal(null)} item={modalItem} /> @@ -510,7 +521,8 @@ export function App() { onScan={() => setScannerOpen(true)} onAddProduct={openAdd} onMarkFinished={() => openConsume()} - onAudit={() => openAudit()} + onWeighIn={() => openWeighIn()} + onBinCheck={() => openBinCheck()} onCheckout={() => openCheckout()} /> )} @@ -529,7 +541,7 @@ export function App() { noMatchText={scanNoMatch} onClose={() => { setScanResult(null); setScanNoMatch(null); }} onViewItem={(i) => setSelected(i)} - onAudit={(i) => openAudit(i)} + onWeighIn={(i) => openWeighIn(i)} onCheckout={(i) => openCheckout(i)} onCheckin={(i) => openCheckin(i)} onConsume={(i) => openConsume(i)} diff --git a/web/src/api.ts b/web/src/api.ts index 4d14063..d6e973b 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -202,18 +202,27 @@ export const api = { deleteShop: (id: string) => request<{ ok: true }>(`/shops/${id}`, { method: "DELETE" }), - createBin: (body: { name: string; capacity?: number }) => - request<{ id: string; name: string; capacity: number }>("/bins", { + createBin: (body: { name: string; capacity?: number; cadenceDays?: number }) => + request<{ id: string; name: string; capacity: number; cadenceDays: number; lastChecked: string | null }>("/bins", { method: "POST", body: JSON.stringify(body), }), - updateBin: (id: string, body: { name?: string; capacity?: number }) => - request<{ id: string; name: string; capacity: number }>(`/bins/${id}`, { + updateBin: (id: string, body: { name?: string; capacity?: number; cadenceDays?: number }) => + request<{ id: string; name: string; capacity: number; cadenceDays: number; lastChecked: string | null }>(`/bins/${id}`, { method: "PATCH", body: JSON.stringify(body), }), deleteBin: (id: string) => request<{ ok: true }>(`/bins/${id}`, { method: "DELETE" }), + + completeBinCheck: ( + binId: string, + body: { date: string; verifiedItemIds: string[]; goneItemIds: string[] }, + ) => + request<{ ok: true; verified: number; gone: number }>(`/bins/${binId}/check`, { + method: "POST", + body: JSON.stringify(body), + }), }; diff --git a/web/src/components/MobileBottomNav.tsx b/web/src/components/MobileBottomNav.tsx index 29cd2ca..49dadf0 100644 --- a/web/src/components/MobileBottomNav.tsx +++ b/web/src/components/MobileBottomNav.tsx @@ -18,13 +18,15 @@ export function MobileBottomNav({ onScan, onAddProduct, onMarkFinished, - onAudit, + onWeighIn, + onBinCheck, onCheckout, }: { onScan: () => void; onAddProduct: () => void; onMarkFinished: () => void; - onAudit: () => void; + onWeighIn: () => void; + onBinCheck: () => void; onCheckout: () => void; }) { const [moreOpen, setMoreOpen] = useState(false); @@ -129,7 +131,8 @@ export function MobileBottomNav({ {[ { icon: "plus", label: "Add inventory", action: onAddProduct }, - { icon: "search", label: "Audit", action: onAudit }, + { icon: "search", label: "Weigh In", action: onWeighIn }, + { icon: "bin", label: "Bin Check", action: onBinCheck }, { icon: "pocket", label: "Check out", action: onCheckout }, { icon: "check", label: "Mark consumed", action: onMarkFinished }, ].map((a) => ( diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index dfaa5db..b194910 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -18,7 +18,7 @@ export function ProductDetail({ onClose, onConsume, onMarkGone, - onAudit, + onWeighIn, onEdit, onCheckout, onCheckin, @@ -30,7 +30,7 @@ export function ProductDetail({ onClose: () => void; onConsume: (i: Item) => void; onMarkGone: (i: Item) => void; - onAudit: (i: Item) => void; + onWeighIn: (i: Item) => void; onEdit: (i: Item) => void; onCheckout: (i: Item) => void; onCheckin: (i: Item) => void; @@ -182,9 +182,9 @@ export function ProductDetail({ ) : (
- {isActive && ( - onAudit(item)}> - Audit + {isActive && item.kind === "bulk" && ( + onWeighIn(item)}> + Weigh In )} {isActive && ( @@ -231,7 +231,7 @@ export function ProductDetail({ {isCheckedOut && ( Checked out · {fmt.daysAgo(item.checkoutDate, getStoredTimezone())} )} - {isActive && overdue && Audit overdue · {sinceCheck}d} + {isActive && overdue && Weigh-in overdue · {sinceCheck}d}

-
Audit history
- {isActive && ( +
+ {item.kind === "bulk" ? "Weigh-in history" : "Check history"} +
+ {isActive && item.kind === "bulk" && ( )} @@ -513,8 +515,8 @@ export function ProductDetail({ {isMobile && ( setActionsOpen(false)}>
- {isActive && ( - { setActionsOpen(false); onAudit(item); }} /> + {isActive && item.kind === "bulk" && ( + { setActionsOpen(false); onWeighIn(item); }} /> )} {isActive && ( { setActionsOpen(false); onCheckout(item); }} /> diff --git a/web/src/components/ScanAction.tsx b/web/src/components/ScanAction.tsx index 5575312..3a47d84 100644 --- a/web/src/components/ScanAction.tsx +++ b/web/src/components/ScanAction.tsx @@ -12,7 +12,7 @@ export function ScanAction({ noMatchText, onClose, onViewItem, - onAudit, + onWeighIn, onCheckout, onCheckin, onConsume, @@ -26,7 +26,7 @@ export function ScanAction({ noMatchText: string | null; onClose: () => void; onViewItem: (item: Item) => void; - onAudit: (item: Item) => void; + onWeighIn: (item: Item) => void; onCheckout: (item: Item) => void; onCheckin: (item: Item) => void; onConsume: (item: Item) => void; @@ -89,14 +89,16 @@ export function ScanAction({ {item.assetId} · {remainingShort(item)} - {overdue && Audit due} + {overdue && Weigh-in due}
{/* Actions */} { onClose(); onViewItem(item); }} /> - { onClose(); onAudit(item); }} /> + {item.kind === "bulk" && ( + { onClose(); onWeighIn(item); }} /> + )} {item.status === "active" && ( { onClose(); onCheckout(item); }} /> )} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 4b9bbbb..83cdb29 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -45,12 +45,14 @@ const TAGLINE = TAGLINES[Math.floor(Math.random() * TAGLINES.length)]!; export function Sidebar({ onAddProduct, onMarkFinished, - onAudit, + onWeighIn, + onBinCheck, onCheckout, }: { onAddProduct: () => void; onMarkFinished: () => void; - onAudit: () => void; + onWeighIn: () => void; + onBinCheck: () => void; onCheckout: () => void; }) { return ( @@ -91,8 +93,11 @@ export function Sidebar({ - + + ); + })} + + )} + + +
+ Cancel + + + )} + + {/* ── Phase: Scan items ─────────────────────────── */} + {phase === "scan" && selectedBin && ( + <> + + + {expectedItems.length > 0 && ( +
+
+
+ )} + +
+ + + {scanMessage && ( +
+ {scanMessage.text} +
+ )} + +
+ {expectedItems.length === 0 ? ( +
+ This bin has no active items. +
+ ) : ( + expectedItems.map((item) => { + const isVerified = verified.has(item.id); + return ( +
+
+ {isVerified && } +
+
+
+ {item.name} +
+
+ {item.assetId} · {item.type} +
+
+
+ ); + }) + )} +
+
+ + + { setPhase("select"); setSelectedBin(null); }}> + Back + + setPhase("review")} + > + Done scanning + + + + )} + + {/* ── Phase: Review & complete ──────────────────── */} + {phase === "review" && selectedBin && ( + <> + + +
+ {/* Summary */} +
+
+
Verified
+
{verified.size}
+
+
+
Missing
+
0 ? "var(--terracotta)" : "var(--ink)" }}> + {missingItems.length} +
+
+
+
Gone
+
0 ? "var(--terracotta)" : "var(--ink)" }}> + {gone.size} +
+
+
+ + {/* Missing items list */} + {missingItems.length > 0 && ( +
+
+ Missing items +
+ {missingItems.map((item) => ( +
+
+
{item.name}
+
+ {item.assetId} · {item.type} +
+
+
+ handleMarkGone(item.id)}> + Mark gone + +
+
+ ))} +
+ setPhase("scan")}> + Re-scan + +
+
+ )} + + {/* Gone items */} + {gone.size > 0 && ( +
+
+ Marked as gone +
+ {[...gone].map((id) => { + const item = allItems.find((i) => i.id === id); + if (!item) return null; + return ( +
+
+
{item.name}
+
+ {item.assetId} +
+
+ setGone((prev) => { const next = new Set(prev); next.delete(id); return next; })} + > + Undo + +
+ ); + })} +
+ )} + + {missingItems.length === 0 && gone.size === 0 && ( +
+ All items verified — ready to complete. +
+ )} +
+ + + setPhase("scan")}> + Back + + complete.mutate()} + > + {complete.isPending ? "Saving…" : "Complete bin check"} + + + + )} +
+ + ); +} diff --git a/web/src/components/modals/CatalogModals.tsx b/web/src/components/modals/CatalogModals.tsx index 2a06639..4f0a7d8 100644 --- a/web/src/components/modals/CatalogModals.tsx +++ b/web/src/components/modals/CatalogModals.tsx @@ -125,14 +125,15 @@ export function EditBinModal({ bin, onClose, }: { - bin: { id: string; name: string; capacity: number }; + bin: { id: string; name: string; capacity: number; cadenceDays: number }; onClose: () => void; }) { const qc = useQueryClient(); const [name, setName] = useState(bin.name); const [capacity, setCapacity] = useState(bin.capacity); + const [cadenceDays, setCadenceDays] = useState(bin.cadenceDays); const update = useMutation({ - mutationFn: () => api.updateBin(bin.id, { name: name.trim(), capacity }), + mutationFn: () => api.updateBin(bin.id, { name: name.trim(), capacity, cadenceDays }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); onClose(); @@ -152,7 +153,7 @@ export function EditBinModal({ }} > -
+
setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))} /> + + setCadenceDays(Math.max(1, Math.floor(+e.target.value || 30)))} + /> +
@@ -194,8 +204,9 @@ export function AddBinModal({ onClose }: { onClose: () => void }) { const qc = useQueryClient(); const [name, setName] = useState(""); const [capacity, setCapacity] = useState(10); + const [cadenceDays, setCadenceDays] = useState(30); const create = useMutation({ - mutationFn: () => api.createBin({ name: name.trim(), capacity }), + mutationFn: () => api.createBin({ name: name.trim(), capacity, cadenceDays }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); onClose(); @@ -215,7 +226,7 @@ export function AddBinModal({ onClose }: { onClose: () => void }) { }} > -
+
void }) { onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))} /> + + setCadenceDays(Math.max(1, Math.floor(+e.target.value || 30)))} + /> +
diff --git a/web/src/components/modals/AuditFlow.tsx b/web/src/components/modals/WeighInFlow.tsx similarity index 80% rename from web/src/components/modals/AuditFlow.tsx rename to web/src/components/modals/WeighInFlow.tsx index a8bbd38..9244a91 100644 --- a/web/src/components/modals/AuditFlow.tsx +++ b/web/src/components/modals/WeighInFlow.tsx @@ -4,12 +4,12 @@ import type { Bootstrap, Item } from "../../types.js"; import { TYPES, helpers, enrichItems } from "../../types.js"; import { getToday, getStoredTimezone } from "../../tz.js"; import { api } from "../../api.js"; -import { Btn, Field, Input, Select } from "../primitives/index.js"; +import { Btn, Field, Input } from "../primitives/index.js"; import { ScanField, type ScanResult } from "../ScanField.js"; import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; import { useToast } from "../Toast.js"; -const AUDIT_MODE_LABELS: Record = { +const MODE_LABELS: Record = { weigh: { title: "Reweigh on a scale", desc: "Place the jar (minus tare) and record the new weight.", @@ -18,13 +18,9 @@ const AUDIT_MODE_LABELS: Record = { title: "Visual estimate", desc: "Eyeball the remaining amount — quick and approximate.", }, - presence: { - title: "Confirm presence", - desc: "Verify the item is still where you left it. Count units if applicable.", - }, }; -export function AuditFlow({ +export function WeighInFlow({ data, onClose, item: initialItem, @@ -38,28 +34,23 @@ export function AuditFlow({ const qc = useQueryClient(); const { toast } = useToast(); const allItems = enrichItems(data); - const overdueFirst = [...allItems] - .filter((i) => i.status === "active") + const bulkItems = [...allItems] + .filter((i) => i.status === "active" && i.kind === "bulk") .sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a)); const [queueIdx, setQueueIdx] = useState(0); const [itemId, setItemId] = useState(initialItem?.id ?? ""); const [date, setDate] = useState(getToday(getStoredTimezone())); - const [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset"); const item = allItems.find((i) => i.id === itemId); const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined; const initialValueFor = (i: Item | undefined): string => { if (!i) return "0"; - if (i.kind === "discrete") { - return String(i.countLastAudit ?? i.countOriginal); - } const last = helpers.lastAudit(i); return (last ? last.value : i.weight).toFixed(2); }; const [value, setValue] = useState(initialValueFor(item)); - const isConcentrate = item?.type === "Concentrate"; const [inputMode, setInputMode] = useState<"direct" | "container">( item?.containerWeight != null ? "container" : "direct", ); @@ -86,17 +77,16 @@ export function AuditFlow({ const effectiveMode = inputMode === "container" ? "weigh" : (cfg?.auditMode ?? "weigh"); - const audit = useMutation({ + const weighIn = useMutation({ mutationFn: () => api.auditInventoryItem(itemId, { date, mode: effectiveMode, value: effectiveValue, - confirmedBy: cfg?.auditMode === "presence" ? confirmedBy : undefined, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); - toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`); + toast(`Weigh-in saved — next due in ${cfg?.cadenceDays ?? "?"}d`); if (queue && queueIdx + 1 < queue.length) { const nextItem = queue[queueIdx + 1]!; setQueueIdx((i) => i + 1); @@ -118,15 +108,13 @@ export function AuditFlow({ const auditMode = cfg?.auditMode ?? "weigh"; const ml = inputMode === "container" ? { title: "Weigh container", desc: "Place the sealed jar on a scale and enter the total weight. Product remaining is calculated from the tare." } - : AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.weigh!; + : MODE_LABELS[auditMode] ?? MODE_LABELS.weigh!; const last = item ? helpers.lastAudit(item) : null; const prevValue = item - ? item.kind === "discrete" - ? item.countLastAudit ?? item.countOriginal - : last - ? last.value - : item.weight + ? last + ? last.value + : item.weight : 0; const delta = effectiveValue - prevValue; @@ -144,8 +132,8 @@ export function AuditFlow({ }} > 1 ? `${queueIdx + 1} of ${queue.length} overdue` : ""} + title={item ? ml.title : "Weigh In"} + eyebrow={queue && queue.length > 1 ? `${queueIdx + 1} of ${queue.length} overdue weigh-ins` : ""} onClose={onClose} /> @@ -165,7 +153,7 @@ export function AuditFlow({
- {tare != null && !isConcentrate && ( + {tare != null && (
setValue(e.target.value)} /> @@ -263,17 +249,6 @@ export function AuditFlow({ setDate(e.target.value)} /> - {auditMode === "presence" && ( - - - - )}
{inputMode === "container" && tare != null && ( @@ -302,13 +277,13 @@ export function AuditFlow({
Was
- {item.kind === "discrete" ? prevValue : prevValue.toFixed(2)} {cfg?.unit} + {prevValue.toFixed(2)} {cfg?.unit}
Now
- {effectiveValue.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit} + {effectiveValue.toFixed(2)} {cfg?.unit}
@@ -320,7 +295,7 @@ export function AuditFlow({ color: delta < 0 ? "var(--terracotta)" : "var(--ink)", }} > - {delta.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit} + {delta.toFixed(2)} {cfg?.unit}
@@ -334,23 +309,23 @@ export function AuditFlow({
- {item ? `Next audit due in ${cfg?.cadenceDays}d` : ""} + {item ? `Next weigh-in due in ${cfg?.cadenceDays}d` : ""}
Cancel audit.mutate()} + disabled={weighIn.isPending || !item} + onClick={() => weighIn.mutate()} > - {audit.isPending + {weighIn.isPending ? "Saving…" : error ? "Try again" : queue && queueIdx + 1 < queue.length ? "Save & next" - : "Save audit"} + : "Save weigh-in"}
diff --git a/web/src/stats.ts b/web/src/stats.ts index a259b2b..5134c93 100644 --- a/web/src/stats.ts +++ b/web/src/stats.ts @@ -3,7 +3,7 @@ // consumed. Gone items contribute spend but NOT grams (so daily averages // stay clean). Operates on the enriched Item[] view, not raw products. -import type { Bootstrap, Item } from "./types.js"; +import type { Bin, Bootstrap, Item } from "./types.js"; import { TYPES, helpers, enrichItems } from "./types.js"; import { getToday, getStoredTimezone } from "./tz.js"; @@ -37,7 +37,8 @@ export interface Stats { goneCount: number; archivedCount: number; purchaseCount: number; - overdueAudits: Item[]; + overdueWeighIns: Item[]; + overdueBinChecks: Bin[]; lowStockBulk: Item[]; lowStockDiscreteGroups: { key: string; @@ -220,7 +221,8 @@ export function computeStats(data: Bootstrap): Stats { } const avgGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0; - const overdueAudits = active.filter((p) => helpers.auditOverdue(p, todayStr)); + const overdueWeighIns = active.filter((p) => helpers.auditOverdue(p, todayStr)); + const overdueBinChecks = data.bins.filter((b) => helpers.binCheckOverdue(b, todayStr)); const lowStockBulk = active.filter( (p) => p.kind === "bulk" && helpers.pctRemaining(p) < 0.25, @@ -283,7 +285,8 @@ export function computeStats(data: Bootstrap): Stats { goneCount: gone.length, archivedCount: consumed.length + gone.length, purchaseCount: items.length, - overdueAudits, + overdueWeighIns, + overdueBinChecks, lowStockBulk, lowStockDiscreteGroups, }; diff --git a/web/src/types.ts b/web/src/types.ts index b61372f..bde18c3 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -91,6 +91,8 @@ export interface Bin { id: string; name: string; capacity: number; + cadenceDays: number; + lastChecked: string | null; } export interface TypeConfig { @@ -203,9 +205,15 @@ export const helpers = { auditOverdue(p: Item, today = TODAY_STR): boolean { if (p.status !== "active" && p.status !== "checked-out") return false; const cfg = TYPES.find((t) => t.id === p.type); - if (!cfg) return false; + if (!cfg || cfg.kind === "discrete") return false; return this.daysSinceCheck(p, today) >= cfg.cadenceDays; }, + binCheckOverdue(bin: Bin, today = TODAY_STR): boolean { + return this.daysSinceBinCheck(bin, today) >= bin.cadenceDays; + }, + daysSinceBinCheck(bin: Bin, today = TODAY_STR): number { + return this.daysSince(bin.lastChecked, today); + }, remaining(p: Item): number { if (p.status !== "active" && p.status !== "checked-out") return 0; if (p.kind === "discrete") { diff --git a/web/src/views/BinsView.tsx b/web/src/views/BinsView.tsx index b288165..c9f90a6 100644 --- a/web/src/views/BinsView.tsx +++ b/web/src/views/BinsView.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Bin, Item } from "../types.js"; import { helpers, enrichItems } from "../types.js"; +import { getToday, getStoredTimezone } from "../tz.js"; import { remainingShort } from "../stats.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; import { api } from "../api.js"; @@ -45,11 +46,13 @@ export function BinsView({ onSelectItem, onAddBin, onEditBin, + onBinCheck, }: { data: Bootstrap; onSelectItem: (i: Item) => void; onAddBin: () => void; onEditBin: (bin: Bin) => void; + onBinCheck: (bin: Bin) => void; }) { const isMobile = useIsMobile(); const qc = useQueryClient(); @@ -219,6 +222,43 @@ export function BinsView({ }} />
+ {(() => { + const todayStr = getToday(getStoredTimezone()); + const overdue = helpers.binCheckOverdue(bin, todayStr); + const days = helpers.daysSinceBinCheck(bin, todayStr); + return ( +
+
+ {days === Infinity ? "Never checked" : `Checked ${days}d ago`} + {overdue && " · overdue"} +
+ +
+ ); + })()}
{binItems.length === 0 && ( diff --git a/web/src/views/Dashboard.tsx b/web/src/views/Dashboard.tsx index 888d38d..4f3b79a 100644 --- a/web/src/views/Dashboard.tsx +++ b/web/src/views/Dashboard.tsx @@ -1,4 +1,4 @@ -import type { Bootstrap, Item } from "../types.js"; +import type { Bin, Bootstrap, Item } from "../types.js"; import { helpers } from "../types.js"; import { getToday, getStoredTimezone } from "../tz.js"; import type { Stats } from "../stats.js"; @@ -20,14 +20,16 @@ const TYPE_COLORS: Record = { export function Dashboard({ data, stats, - onAuditItem, - onAuditQueue, + onWeighInItem, + onWeighInQueue, + onBinCheck, onSelectItem, }: { data: Bootstrap; stats: Stats; - onAuditItem: (i: Item) => void; - onAuditQueue: (items: Item[]) => void; + onWeighInItem: (i: Item) => void; + onWeighInQueue: (items: Item[]) => void; + onBinCheck: (bin?: Bin) => void; onSelectItem: (i: Item) => void; }) { const isMobile = useIsMobile(); @@ -41,7 +43,8 @@ export function Dashboard({ color: TYPE_COLORS[k] ?? "var(--ink-3)", })); - const overdue = stats.overdueAudits; + const overdue = stats.overdueWeighIns; + const overdueBins = stats.overdueBinChecks; const lowBulk = stats.lowStockBulk; const lowDiscrete = stats.lowStockDiscreteGroups; @@ -86,7 +89,12 @@ export function Dashboard({ {stats.goneCount} gone. {overdue.length > 0 && ( - {" "}· {overdue.length} audit{overdue.length === 1 ? "" : "s"} overdue. + {" "}· {overdue.length} weigh-in{overdue.length === 1 ? "" : "s"} overdue. + + )} + {overdueBins.length > 0 && ( + + {" "}· {overdueBins.length} bin check{overdueBins.length === 1 ? "" : "s"} overdue. )}
@@ -182,17 +190,43 @@ export function Dashboard({ >
-
Audit overdue
+
Weigh-ins overdue
- {overdue.length} item{overdue.length === 1 ? "" : "s"} haven't been checked in a while + {overdue.length} item{overdue.length === 1 ? "" : "s"} need weighing
{overdue.slice(0, 3).map((p) => p.name).join(" · ")} {overdue.length > 3 && ` · +${overdue.length - 3} more`}
- onAuditQueue(overdue)}> - Audit {overdue.length > 1 ? `all ${overdue.length}` : ""} + onWeighInQueue(overdue)}> + Weigh in {overdue.length > 1 ? `all ${overdue.length}` : ""} + +
+ + )} + + {overdueBins.length > 0 && ( + +
+
+
Bin checks overdue
+
+ {overdueBins.length} bin{overdueBins.length === 1 ? "" : "s"} need checking +
+
+ {overdueBins.slice(0, 3).map((b) => b.name).join(" · ")} + {overdueBins.length > 3 && ` · +${overdueBins.length - 3} more`} +
+
+ onBinCheck()}> + Start bin check
diff --git a/web/src/views/Inventory.tsx b/web/src/views/Inventory.tsx index b2aa11d..4771206 100644 --- a/web/src/views/Inventory.tsx +++ b/web/src/views/Inventory.tsx @@ -20,7 +20,7 @@ export function Inventory({ data, onSelectItem, onAddInventory, - onAuditNew, + onWeighInNew, onBulkEdit, onBulkConsume, onBulkCheckout, @@ -30,7 +30,7 @@ export function Inventory({ data: Bootstrap; onSelectItem: (i: Item) => void; onAddInventory: () => void; - onAuditNew: () => void; + onWeighInNew: () => void; onBulkEdit: (items: Item[]) => void; onBulkConsume: (items: Item[]) => void; onBulkCheckout: (items: Item[]) => void; @@ -184,7 +184,7 @@ export function Inventory({
{!isMobile && (
- Audit + Weigh In Add inventory
)} @@ -380,7 +380,7 @@ export function Inventory({ - + @@ -586,7 +586,7 @@ export function Inventory({ ["thc", "THC %"], ["remaining", "Remaining"], ["price", "Price"], - ["audit", "Audit overdue"], + ["audit", "Weigh-in overdue"], ] as [SortKey, string][] ).map(([k, l]) => (