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);