Add container weight tracking for weigh-based concentrate audits
Build and push image / build (push) Successful in 1m6s

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 23:11:39 -04:00
parent e9e66ab1cb
commit a1be29ab6e
11 changed files with 205 additions and 26 deletions
+3 -1
View File
@@ -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;
+18
View File
@@ -67,6 +67,12 @@ export function ProductDetail({
: []),
["Purchase date", fmt.date(item.purchaseDate, getStoredTimezone())],
["Bin", isCheckedOut ? "In your custody" : bin ? bin.name : <span style={{ color: "var(--ink-3)" }}></span>],
...(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.
</div>
)}
{item.containerWeight != null && (
<div
style={{
fontSize: 11,
color: "var(--ink-3)",
marginTop: 6,
fontStyle: "italic",
}}
>
Expected container total: {((item.containerWeight - item.weight) + est).toFixed(2)}g
</div>
)}
</div>
)}
@@ -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<string | null>(null);
const update = <K extends keyof typeof form>(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({
</div>
)}
{!isDiscrete && cfg?.weighable && (
<div style={{ marginTop: 16 }}>
<Field label="Container weight (g)" hint="Total weight of jar + lid + product on a scale. Optional — enables weigh-based audits.">
<Input
type="number"
step="0.01"
placeholder="—"
value={containerWeight}
onChange={(e) => setContainerWeight(e.target.value)}
/>
</Field>
{containerWeight !== "" && form.weight > 0 && (
<div style={{ marginTop: 6, fontSize: 12, color: parseFloat(containerWeight) <= form.weight ? "var(--terracotta)" : "var(--ink-3)" }}>
{parseFloat(containerWeight) > form.weight
? `Tare (empty jar): ${(parseFloat(containerWeight) - form.weight).toFixed(2)}g`
: "Container weight must be greater than product weight"}
</div>
)}
</div>
)}
{cfg?.showCannabinoidPct !== false && (
<>
<div
+81 -21
View File
@@ -55,18 +55,37 @@ export function AuditFlow({
return helpers.estimatedRemaining(i, getToday(getStoredTimezone())).toFixed(2);
};
const [value, setValue] = useState<string>(initialValueFor(item));
const [inputMode, setInputMode] = useState<"direct" | "container">(
item?.containerWeight != null ? "container" : "direct",
);
const [containerTotal, setContainerTotal] = useState("");
const [error, setError] = useState<string | null>(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 (
<ModalBackdrop onClose={onClose}>
@@ -156,6 +177,23 @@ export function AuditFlow({
</div>
</div>
{tare != null && (
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<Btn
variant={inputMode === "container" ? "primary" : "ghost"}
onClick={() => setInputMode("container")}
>
Weigh container
</Btn>
<Btn
variant={inputMode === "direct" ? "primary" : "ghost"}
onClick={() => setInputMode("direct")}
>
Direct entry
</Btn>
</div>
)}
<div
style={{
display: "grid",
@@ -164,22 +202,33 @@ export function AuditFlow({
marginTop: 24,
}}
>
<Field
label={
item.kind === "discrete"
? `Count now (${cfg?.unit})`
: auditMode === "weigh"
? `Weight now (${cfg?.unit})`
: `Estimate now (${cfg?.unit})`
}
>
<Input
type="number"
step={item.kind === "discrete" ? "1" : "0.1"}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</Field>
{inputMode === "container" && tare != null ? (
<Field label="Container weight now (g)">
<Input
type="number"
step="0.01"
value={containerTotal}
onChange={(e) => setContainerTotal(e.target.value)}
/>
</Field>
) : (
<Field
label={
item.kind === "discrete"
? `Count now (${cfg?.unit})`
: auditMode === "weigh"
? `Weight now (${cfg?.unit})`
: `Estimate now (${cfg?.unit})`
}
>
<Input
type="number"
step={item.kind === "discrete" ? "1" : "0.1"}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</Field>
)}
<Field label="Date">
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</Field>
@@ -196,6 +245,17 @@ export function AuditFlow({
)}
</div>
{inputMode === "container" && tare != null && (
<div style={{ marginTop: 8, fontSize: 12, color: "var(--ink-3)" }}>
Tare (empty jar): {tare.toFixed(2)}g
{derivedRemaining != null && (
<span style={{ color: derivedRemaining >= 0 ? "var(--sage)" : "var(--terracotta)" }}>
{" · "}Product remaining: {derivedRemaining.toFixed(2)}g
</span>
)}
</div>
)}
<div
style={{
marginTop: 20,
@@ -217,7 +277,7 @@ export function AuditFlow({
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}>
{value} {cfg?.unit}
{effectiveValue.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit}
</div>
</div>
<div>
@@ -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<string | null>(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<string, string | number | null> = {};
@@ -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({
</Field>
</div>
{hasBulkWeighable && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 16, marginTop: 16 }}>
<Field label="Container weight (g)">
<Input
type="number"
step="0.01"
placeholder="—"
value={containerWeight}
onChange={(e) => setContainerWeight(e.target.value)}
/>
</Field>
</div>
)}
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
@@ -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<string | null>(null);
const update = <K extends keyof typeof form>(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({
</div>
)}
{!isDiscrete && cfg?.weighable && (
<div style={{ marginTop: 16 }}>
<Field label="Container weight (g)" hint="Total weight of jar + lid + product on a scale. Leave blank to clear.">
<Input
type="number"
step="0.01"
placeholder="—"
value={containerWeight}
onChange={(e) => setContainerWeight(e.target.value)}
/>
</Field>
{containerWeight !== "" && form.weight > 0 && (
<div style={{ marginTop: 6, fontSize: 12, color: parseFloat(containerWeight) <= form.weight ? "var(--terracotta)" : "var(--ink-3)" }}>
{parseFloat(containerWeight) > form.weight
? `Tare (empty jar): ${(parseFloat(containerWeight) - form.weight).toFixed(2)}g`
: "Container weight must be greater than product weight"}
</div>
)}
</div>
)}
{cfg?.showCannabinoidPct !== false && (
<>
<div
+5
View File
@@ -38,6 +38,7 @@ export interface InventoryItem {
cbd: number;
totalCannabinoids: number;
weight: number;
containerWeight: number | null;
lastAuditWeight: number | null;
countOriginal: number;
countLastAudit: number | null;
@@ -183,6 +184,10 @@ export const helpers = {
typeConfig(id: string): TypeConfig {
return TYPES.find((t) => 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);