From a1be29ab6eeced721a9908a5e51846372a25df10 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 7 May 2026 23:11:39 -0400 Subject: [PATCH] Add container weight tracking for weigh-based concentrate audits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the total weight of a jar (product + container) at acquisition so audits can be done by simply re-weighing the sealed jar. The tare is derived (containerWeight − productWeight), and the audit flow offers a "Weigh container" toggle that auto-calculates remaining product from the scale reading. Co-Authored-By: Claude Opus 4.6 --- server/src/db.ts | 9 ++ server/src/routes/bootstrap.ts | 2 + server/src/routes/inventory.ts | 20 +++- server/src/schema.sql | 1 + web/src/api.ts | 4 +- web/src/components/ProductDetail.tsx | 18 ++++ .../components/modals/AddInventoryFlow.tsx | 23 ++++ web/src/components/modals/AuditFlow.tsx | 102 ++++++++++++++---- web/src/components/modals/BulkEditModal.tsx | 22 ++++ .../components/modals/EditInventoryFlow.tsx | 25 +++++ web/src/types.ts | 5 + 11 files changed, 205 insertions(+), 26 deletions(-) diff --git a/server/src/db.ts b/server/src/db.ts index 49ea90d..38406ff 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -14,6 +14,7 @@ db.pragma("foreign_keys = ON"); archiveLegacyIfPresent(); archiveV1IfPresent(); migrateAddCheckoutDate(); +migrateAddContainerWeight(); const schema = readFileSync(join(__dirname, "schema.sql"), "utf8"); db.exec(schema); @@ -26,6 +27,14 @@ function migrateAddCheckoutDate(): void { db.exec(`ALTER TABLE inventory_items ADD COLUMN checkout_date TEXT`); } +function migrateAddContainerWeight(): void { + const cols = db + .prepare(`PRAGMA table_info(inventory_items)`) + .all() as { name: string }[]; + if (cols.length === 0 || cols.some((c) => c.name === "container_weight")) return; + db.exec(`ALTER TABLE inventory_items ADD COLUMN container_weight REAL`); +} + // 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 a6502f6..4c23b28 100644 --- a/server/src/routes/bootstrap.ts +++ b/server/src/routes/bootstrap.ts @@ -24,6 +24,7 @@ type InventoryRow = { cbd: number; total_cannabinoids: number; weight: number; + container_weight: number | null; last_audit_weight: number | null; count_original: number; count_last_audit: number | null; @@ -101,6 +102,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => { cbd: i.cbd, totalCannabinoids: i.total_cannabinoids, weight: i.weight, + containerWeight: i.container_weight, lastAuditWeight: i.last_audit_weight, countOriginal: i.count_original, countLastAudit: i.count_last_audit, diff --git a/server/src/routes/inventory.ts b/server/src/routes/inventory.ts index 68741ea..a885f1f 100644 --- a/server/src/routes/inventory.ts +++ b/server/src/routes/inventory.ts @@ -15,6 +15,7 @@ type CreateBody = { weight?: number; countOriginal?: number; unitWeight?: number; + containerWeight?: number | null; purchaseDate: string; }; @@ -53,14 +54,14 @@ inventoryRouter.post("/inventory", (req, res) => { id, asset_id, product_id, shop_id, bin_id, price, thc, cbd, total_cannabinoids, - weight, last_audit_weight, + weight, container_weight, last_audit_weight, count_original, count_last_audit, unit_weight, purchase_date, status ) VALUES ( @id, @assetId, @productId, @shopId, @binId, @price, @thc, @cbd, @totalCannabinoids, - @weight, @lastAuditWeight, + @weight, @containerWeight, @lastAuditWeight, @countOriginal, @countLastAudit, @unitWeight, @purchaseDate, 'active' )`, @@ -75,6 +76,7 @@ inventoryRouter.post("/inventory", (req, res) => { cbd: body.cbd ?? 0, totalCannabinoids: body.totalCannabinoids ?? 0, weight: isDiscrete ? 0 : body.weight ?? 0, + containerWeight: isDiscrete ? null : body.containerWeight ?? null, lastAuditWeight: isDiscrete ? null : body.weight ?? 0, countOriginal: isDiscrete ? body.countOriginal ?? 0 : 0, countLastAudit: isDiscrete ? body.countOriginal ?? 0 : null, @@ -95,6 +97,7 @@ type UpdateBody = Partial<{ cbd: number; totalCannabinoids: number; weight: number; + containerWeight: number | null; countOriginal: number; unitWeight: number; purchaseDate: string; @@ -110,6 +113,7 @@ type ItemRow = { cbd: number; total_cannabinoids: number; weight: number; + container_weight: number | null; last_audit_weight: number | null; count_original: number; count_last_audit: number | null; @@ -121,8 +125,8 @@ function doUpdate(id: string, body: UpdateBody): void { const existing = db .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 + total_cannabinoids, weight, container_weight, last_audit_weight, + count_original, count_last_audit, unit_weight, purchase_date FROM inventory_items WHERE id = ?`, ) .get(id); @@ -161,6 +165,12 @@ function doUpdate(id: string, body: UpdateBody): void { !isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0 ? (body.weight as number) : existing.weight; + const nextContainerWeight = + body.containerWeight === undefined + ? existing.container_weight + : body.containerWeight != null && Number.isFinite(body.containerWeight) + ? body.containerWeight + : null; const nextCountOriginal = isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0 ? Math.floor(body.countOriginal as number) @@ -184,6 +194,7 @@ function doUpdate(id: string, body: UpdateBody): void { cbd = @cbd, total_cannabinoids = @totalCannabinoids, weight = @weight, + container_weight = @containerWeight, last_audit_weight = @lastAuditWeight, count_original = @countOriginal, count_last_audit = @countLastAudit, @@ -199,6 +210,7 @@ function doUpdate(id: string, body: UpdateBody): void { cbd: nextCbd, totalCannabinoids: nextTotalCanna, weight: nextWeight, + containerWeight: nextContainerWeight, lastAuditWeight: nextLastAuditWeight, countOriginal: nextCountOriginal, countLastAudit: nextCountLastAudit, diff --git a/server/src/schema.sql b/server/src/schema.sql index 3799e43..319c169 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS inventory_items ( cbd REAL DEFAULT 0, total_cannabinoids REAL DEFAULT 0, weight REAL DEFAULT 0, + container_weight REAL, last_audit_weight REAL, count_original INTEGER DEFAULT 0, count_last_audit INTEGER, diff --git a/web/src/api.ts b/web/src/api.ts index c931b3a..f73527b 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,7 +1,7 @@ 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; purchaseDate: string }> } + | { action: "update"; id: string; fields: Partial<{ shopId: string | null; binId: string | null; price: number; thc: number; cbd: number; totalCannabinoids: number; containerWeight: number | null; purchaseDate: string }> } | { action: "checkout"; id: string; date: string } | { action: "checkin"; id: string; date: string; binId: string } | { action: "finish"; id: string; date: string; rating?: number; notes?: string } @@ -79,6 +79,7 @@ export const api = { cbd?: number; totalCannabinoids?: number; weight?: number; + containerWeight?: number | null; countOriginal?: number; unitWeight?: number; purchaseDate: string; @@ -98,6 +99,7 @@ export const api = { cbd: number; totalCannabinoids: number; weight: number; + containerWeight: number | null; countOriginal: number; unitWeight: number; purchaseDate: string; diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index 20c3659..d365c0c 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -67,6 +67,12 @@ export function ProductDetail({ : []), ["Purchase date", fmt.date(item.purchaseDate, getStoredTimezone())], ["Bin", isCheckedOut ? "In your custody" : bin ? bin.name : ], + ...(item.containerWeight != null + ? [ + ["Container weight", `${item.containerWeight.toFixed(2)}g`] as [string, React.ReactNode], + ["Tare (empty jar)", `${(item.containerWeight - item.weight).toFixed(2)}g`] as [string, React.ReactNode], + ] + : []), ["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`], [ "Cost per gram", @@ -294,6 +300,18 @@ export function ProductDetail({ {cfg?.unit}). Re-audit to update. )} + {item.containerWeight != null && ( +
+ Expected container total: {((item.containerWeight - item.weight) + est).toFixed(2)}g +
+ )} )} diff --git a/web/src/components/modals/AddInventoryFlow.tsx b/web/src/components/modals/AddInventoryFlow.tsx index ec480dc..72beadf 100644 --- a/web/src/components/modals/AddInventoryFlow.tsx +++ b/web/src/components/modals/AddInventoryFlow.tsx @@ -382,6 +382,7 @@ function InstanceDetailsStep({ const [newShopLocation, setNewShopLocation] = useState(""); const [newBinName, setNewBinName] = useState(""); const [newBinCapacity, setNewBinCapacity] = useState(10); + const [containerWeight, setContainerWeight] = useState(""); const [error, setError] = useState(null); const update = (k: K, v: (typeof form)[K]) => @@ -419,6 +420,7 @@ function InstanceDetailsStep({ shopId, binId, weight: isDiscrete ? undefined : form.weight, + containerWeight: !isDiscrete && containerWeight !== "" ? parseFloat(containerWeight) : undefined, countOriginal: isDiscrete ? 1 : undefined, unitWeight: isDiscrete ? form.unitWeight : undefined, price: form.price, @@ -624,6 +626,27 @@ function InstanceDetailsStep({ )} + {!isDiscrete && cfg?.weighable && ( +
+ + setContainerWeight(e.target.value)} + /> + + {containerWeight !== "" && form.weight > 0 && ( +
+ {parseFloat(containerWeight) > form.weight + ? `Tare (empty jar): ${(parseFloat(containerWeight) - form.weight).toFixed(2)}g` + : "Container weight must be greater than product weight"} +
+ )} +
+ )} + {cfg?.showCannabinoidPct !== false && ( <>
(initialValueFor(item)); + const [inputMode, setInputMode] = useState<"direct" | "container">( + item?.containerWeight != null ? "container" : "direct", + ); + const [containerTotal, setContainerTotal] = useState(""); const [error, setError] = useState(null); useEffect(() => { setValue(initialValueFor(item)); + setInputMode(item?.containerWeight != null ? "container" : "direct"); + setContainerTotal(""); }, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps + const tare = item ? helpers.tare(item) : null; + const derivedRemaining = + tare != null && containerTotal !== "" + ? parseFloat(containerTotal) - tare + : null; + + const effectiveValue = + inputMode === "container" && derivedRemaining != null + ? derivedRemaining + : Number(value); + const effectiveMode = + inputMode === "container" ? "weigh" : (cfg?.auditMode ?? "weigh"); + const audit = useMutation({ mutationFn: () => api.auditInventoryItem(itemId, { date, - mode: cfg?.auditMode ?? "weigh", - value: Number(value), + mode: effectiveMode, + value: effectiveValue, confirmedBy: cfg?.auditMode === "presence" ? confirmedBy : undefined, }), onSuccess: () => { @@ -84,7 +103,9 @@ export function AuditFlow({ }; const auditMode = cfg?.auditMode ?? "weigh"; - const ml = AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.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!; const last = item ? helpers.lastAudit(item) : null; const prevValue = item @@ -95,7 +116,7 @@ export function AuditFlow({ : item.weight : 0; - const delta = Number(value) - prevValue; + const delta = effectiveValue - prevValue; return ( @@ -156,6 +177,23 @@ export function AuditFlow({
+ {tare != null && ( +
+ setInputMode("container")} + > + Weigh container + + setInputMode("direct")} + > + Direct entry + +
+ )} +
- - setValue(e.target.value)} - /> - + {inputMode === "container" && tare != null ? ( + + setContainerTotal(e.target.value)} + /> + + ) : ( + + setValue(e.target.value)} + /> + + )} setDate(e.target.value)} /> @@ -196,6 +245,17 @@ export function AuditFlow({ )}
+ {inputMode === "container" && tare != null && ( +
+ Tare (empty jar): {tare.toFixed(2)}g + {derivedRemaining != null && ( + = 0 ? "var(--sage)" : "var(--terracotta)" }}> + {" · "}Product remaining: {derivedRemaining.toFixed(2)}g + + )} +
+ )} +
Now
- {value} {cfg?.unit} + {effectiveValue.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit}
diff --git a/web/src/components/modals/BulkEditModal.tsx b/web/src/components/modals/BulkEditModal.tsx index 4920efa..2ed9fff 100644 --- a/web/src/components/modals/BulkEditModal.tsx +++ b/web/src/components/modals/BulkEditModal.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; +import { TYPES } from "../../types.js"; import { api } from "../../api.js"; import type { BatchOp } from "../../api.js"; import { Btn, Field, Input, Select } from "../primitives/index.js"; @@ -26,8 +27,14 @@ export function BulkEditModal({ const [cbd, setCbd] = useState(""); const [totalCannabinoids, setTotalCannabinoids] = useState(""); const [purchaseDate, setPurchaseDate] = useState(""); + const [containerWeight, setContainerWeight] = useState(""); const [error, setError] = useState(null); + const hasBulkWeighable = items.some((i) => { + const cfg = TYPES.find((t) => t.id === i.type); + return i.kind === "bulk" && cfg?.weighable; + }); + const save = useMutation({ mutationFn: () => { const fields: Record = {}; @@ -38,6 +45,7 @@ export function BulkEditModal({ if (cbd !== "") fields.cbd = parseFloat(cbd); if (totalCannabinoids !== "") fields.totalCannabinoids = parseFloat(totalCannabinoids); if (purchaseDate) fields.purchaseDate = purchaseDate; + if (containerWeight !== "") fields.containerWeight = parseFloat(containerWeight); if (Object.keys(fields).length === 0) { return Promise.reject(new Error("No fields to update — fill in at least one field.")); @@ -163,6 +171,20 @@ export function BulkEditModal({
+ {hasBulkWeighable && ( +
+ + setContainerWeight(e.target.value)} + /> + +
+ )} + {error && (
{error}
)} diff --git a/web/src/components/modals/EditInventoryFlow.tsx b/web/src/components/modals/EditInventoryFlow.tsx index 0f92490..f27af94 100644 --- a/web/src/components/modals/EditInventoryFlow.tsx +++ b/web/src/components/modals/EditInventoryFlow.tsx @@ -38,6 +38,9 @@ export function EditInventoryFlow({ const [newShopLocation, setNewShopLocation] = useState(""); const [newBinName, setNewBinName] = useState(""); const [newBinCapacity, setNewBinCapacity] = useState(10); + const [containerWeight, setContainerWeight] = useState( + item.containerWeight != null ? String(item.containerWeight) : "", + ); const [error, setError] = useState(null); const update = (k: K, v: (typeof form)[K]) => @@ -69,6 +72,7 @@ export function EditInventoryFlow({ shopId, binId, weight: isDiscrete ? undefined : form.weight, + containerWeight: !isDiscrete ? (containerWeight !== "" ? parseFloat(containerWeight) : null) : undefined, unitWeight: isDiscrete ? form.unitWeight : undefined, price: form.price, thc: form.thc, @@ -235,6 +239,27 @@ export function EditInventoryFlow({ )} + {!isDiscrete && cfg?.weighable && ( +
+ + setContainerWeight(e.target.value)} + /> + + {containerWeight !== "" && form.weight > 0 && ( +
+ {parseFloat(containerWeight) > form.weight + ? `Tare (empty jar): ${(parseFloat(containerWeight) - form.weight).toFixed(2)}g` + : "Container weight must be greater than product weight"} +
+ )} +
+ )} + {cfg?.showCannabinoidPct !== false && ( <>
t.id === id) ?? TYPES[0]!; }, + tare(item: { containerWeight: number | null; weight: number }): number | null { + if (item.containerWeight == null) return null; + return item.containerWeight - item.weight; + }, daysSince(iso: string | null, today = TODAY_STR): number { if (!iso) return Infinity; return Math.floor((+new Date(today) - +new Date(iso)) / 86_400_000);