Add container weight tracking for weigh-based concentrate audits
Build and push image / build (push) Successful in 1m6s
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:
@@ -14,6 +14,7 @@ db.pragma("foreign_keys = ON");
|
|||||||
archiveLegacyIfPresent();
|
archiveLegacyIfPresent();
|
||||||
archiveV1IfPresent();
|
archiveV1IfPresent();
|
||||||
migrateAddCheckoutDate();
|
migrateAddCheckoutDate();
|
||||||
|
migrateAddContainerWeight();
|
||||||
|
|
||||||
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
@@ -26,6 +27,14 @@ function migrateAddCheckoutDate(): void {
|
|||||||
db.exec(`ALTER TABLE inventory_items ADD COLUMN checkout_date TEXT`);
|
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,
|
// One-shot migration: the original schema put per-instance fields (weight,
|
||||||
// bin_id, etc.) directly on `products`. The split schema separates products
|
// bin_id, etc.) directly on `products`. The split schema separates products
|
||||||
// (catalog) from inventory_items (instance). When we detect the old shape,
|
// (catalog) from inventory_items (instance). When we detect the old shape,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type InventoryRow = {
|
|||||||
cbd: number;
|
cbd: number;
|
||||||
total_cannabinoids: number;
|
total_cannabinoids: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
container_weight: number | null;
|
||||||
last_audit_weight: number | null;
|
last_audit_weight: number | null;
|
||||||
count_original: number;
|
count_original: number;
|
||||||
count_last_audit: number | null;
|
count_last_audit: number | null;
|
||||||
@@ -101,6 +102,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
|
|||||||
cbd: i.cbd,
|
cbd: i.cbd,
|
||||||
totalCannabinoids: i.total_cannabinoids,
|
totalCannabinoids: i.total_cannabinoids,
|
||||||
weight: i.weight,
|
weight: i.weight,
|
||||||
|
containerWeight: i.container_weight,
|
||||||
lastAuditWeight: i.last_audit_weight,
|
lastAuditWeight: i.last_audit_weight,
|
||||||
countOriginal: i.count_original,
|
countOriginal: i.count_original,
|
||||||
countLastAudit: i.count_last_audit,
|
countLastAudit: i.count_last_audit,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type CreateBody = {
|
|||||||
weight?: number;
|
weight?: number;
|
||||||
countOriginal?: number;
|
countOriginal?: number;
|
||||||
unitWeight?: number;
|
unitWeight?: number;
|
||||||
|
containerWeight?: number | null;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,14 +54,14 @@ inventoryRouter.post("/inventory", (req, res) => {
|
|||||||
id, asset_id, product_id,
|
id, asset_id, product_id,
|
||||||
shop_id, bin_id,
|
shop_id, bin_id,
|
||||||
price, thc, cbd, total_cannabinoids,
|
price, thc, cbd, total_cannabinoids,
|
||||||
weight, last_audit_weight,
|
weight, container_weight, last_audit_weight,
|
||||||
count_original, count_last_audit, unit_weight,
|
count_original, count_last_audit, unit_weight,
|
||||||
purchase_date, status
|
purchase_date, status
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @assetId, @productId,
|
@id, @assetId, @productId,
|
||||||
@shopId, @binId,
|
@shopId, @binId,
|
||||||
@price, @thc, @cbd, @totalCannabinoids,
|
@price, @thc, @cbd, @totalCannabinoids,
|
||||||
@weight, @lastAuditWeight,
|
@weight, @containerWeight, @lastAuditWeight,
|
||||||
@countOriginal, @countLastAudit, @unitWeight,
|
@countOriginal, @countLastAudit, @unitWeight,
|
||||||
@purchaseDate, 'active'
|
@purchaseDate, 'active'
|
||||||
)`,
|
)`,
|
||||||
@@ -75,6 +76,7 @@ inventoryRouter.post("/inventory", (req, res) => {
|
|||||||
cbd: body.cbd ?? 0,
|
cbd: body.cbd ?? 0,
|
||||||
totalCannabinoids: body.totalCannabinoids ?? 0,
|
totalCannabinoids: body.totalCannabinoids ?? 0,
|
||||||
weight: isDiscrete ? 0 : body.weight ?? 0,
|
weight: isDiscrete ? 0 : body.weight ?? 0,
|
||||||
|
containerWeight: isDiscrete ? null : body.containerWeight ?? null,
|
||||||
lastAuditWeight: isDiscrete ? null : body.weight ?? 0,
|
lastAuditWeight: isDiscrete ? null : body.weight ?? 0,
|
||||||
countOriginal: isDiscrete ? body.countOriginal ?? 0 : 0,
|
countOriginal: isDiscrete ? body.countOriginal ?? 0 : 0,
|
||||||
countLastAudit: isDiscrete ? body.countOriginal ?? 0 : null,
|
countLastAudit: isDiscrete ? body.countOriginal ?? 0 : null,
|
||||||
@@ -95,6 +97,7 @@ type UpdateBody = Partial<{
|
|||||||
cbd: number;
|
cbd: number;
|
||||||
totalCannabinoids: number;
|
totalCannabinoids: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
containerWeight: number | null;
|
||||||
countOriginal: number;
|
countOriginal: number;
|
||||||
unitWeight: number;
|
unitWeight: number;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
@@ -110,6 +113,7 @@ type ItemRow = {
|
|||||||
cbd: number;
|
cbd: number;
|
||||||
total_cannabinoids: number;
|
total_cannabinoids: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
container_weight: number | null;
|
||||||
last_audit_weight: number | null;
|
last_audit_weight: number | null;
|
||||||
count_original: number;
|
count_original: number;
|
||||||
count_last_audit: number | null;
|
count_last_audit: number | null;
|
||||||
@@ -121,8 +125,8 @@ function doUpdate(id: string, body: UpdateBody): void {
|
|||||||
const existing = db
|
const existing = db
|
||||||
.prepare<[string], ItemRow>(
|
.prepare<[string], ItemRow>(
|
||||||
`SELECT id, shop_id, bin_id, product_id, price, thc, cbd,
|
`SELECT id, shop_id, bin_id, product_id, price, thc, cbd,
|
||||||
total_cannabinoids, weight, last_audit_weight, count_original,
|
total_cannabinoids, weight, container_weight, last_audit_weight,
|
||||||
count_last_audit, unit_weight, purchase_date
|
count_original, count_last_audit, unit_weight, purchase_date
|
||||||
FROM inventory_items WHERE id = ?`,
|
FROM inventory_items WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.get(id);
|
.get(id);
|
||||||
@@ -161,6 +165,12 @@ function doUpdate(id: string, body: UpdateBody): void {
|
|||||||
!isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0
|
!isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0
|
||||||
? (body.weight as number)
|
? (body.weight as number)
|
||||||
: existing.weight;
|
: existing.weight;
|
||||||
|
const nextContainerWeight =
|
||||||
|
body.containerWeight === undefined
|
||||||
|
? existing.container_weight
|
||||||
|
: body.containerWeight != null && Number.isFinite(body.containerWeight)
|
||||||
|
? body.containerWeight
|
||||||
|
: null;
|
||||||
const nextCountOriginal =
|
const nextCountOriginal =
|
||||||
isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0
|
isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0
|
||||||
? Math.floor(body.countOriginal as number)
|
? Math.floor(body.countOriginal as number)
|
||||||
@@ -184,6 +194,7 @@ function doUpdate(id: string, body: UpdateBody): void {
|
|||||||
cbd = @cbd,
|
cbd = @cbd,
|
||||||
total_cannabinoids = @totalCannabinoids,
|
total_cannabinoids = @totalCannabinoids,
|
||||||
weight = @weight,
|
weight = @weight,
|
||||||
|
container_weight = @containerWeight,
|
||||||
last_audit_weight = @lastAuditWeight,
|
last_audit_weight = @lastAuditWeight,
|
||||||
count_original = @countOriginal,
|
count_original = @countOriginal,
|
||||||
count_last_audit = @countLastAudit,
|
count_last_audit = @countLastAudit,
|
||||||
@@ -199,6 +210,7 @@ function doUpdate(id: string, body: UpdateBody): void {
|
|||||||
cbd: nextCbd,
|
cbd: nextCbd,
|
||||||
totalCannabinoids: nextTotalCanna,
|
totalCannabinoids: nextTotalCanna,
|
||||||
weight: nextWeight,
|
weight: nextWeight,
|
||||||
|
containerWeight: nextContainerWeight,
|
||||||
lastAuditWeight: nextLastAuditWeight,
|
lastAuditWeight: nextLastAuditWeight,
|
||||||
countOriginal: nextCountOriginal,
|
countOriginal: nextCountOriginal,
|
||||||
countLastAudit: nextCountLastAudit,
|
countLastAudit: nextCountLastAudit,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS inventory_items (
|
|||||||
cbd REAL DEFAULT 0,
|
cbd REAL DEFAULT 0,
|
||||||
total_cannabinoids REAL DEFAULT 0,
|
total_cannabinoids REAL DEFAULT 0,
|
||||||
weight REAL DEFAULT 0,
|
weight REAL DEFAULT 0,
|
||||||
|
container_weight REAL,
|
||||||
last_audit_weight REAL,
|
last_audit_weight REAL,
|
||||||
count_original INTEGER DEFAULT 0,
|
count_original INTEGER DEFAULT 0,
|
||||||
count_last_audit INTEGER,
|
count_last_audit INTEGER,
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { Bootstrap, AuditMode } from "./types.js";
|
import type { Bootstrap, AuditMode } from "./types.js";
|
||||||
|
|
||||||
export type BatchOp =
|
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: "checkout"; id: string; date: string }
|
||||||
| { action: "checkin"; id: string; date: string; binId: string }
|
| { action: "checkin"; id: string; date: string; binId: string }
|
||||||
| { action: "finish"; id: string; date: string; rating?: number; notes?: string }
|
| { action: "finish"; id: string; date: string; rating?: number; notes?: string }
|
||||||
@@ -79,6 +79,7 @@ export const api = {
|
|||||||
cbd?: number;
|
cbd?: number;
|
||||||
totalCannabinoids?: number;
|
totalCannabinoids?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
|
containerWeight?: number | null;
|
||||||
countOriginal?: number;
|
countOriginal?: number;
|
||||||
unitWeight?: number;
|
unitWeight?: number;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
@@ -98,6 +99,7 @@ export const api = {
|
|||||||
cbd: number;
|
cbd: number;
|
||||||
totalCannabinoids: number;
|
totalCannabinoids: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
containerWeight: number | null;
|
||||||
countOriginal: number;
|
countOriginal: number;
|
||||||
unitWeight: number;
|
unitWeight: number;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export function ProductDetail({
|
|||||||
: []),
|
: []),
|
||||||
["Purchase date", fmt.date(item.purchaseDate, getStoredTimezone())],
|
["Purchase date", fmt.date(item.purchaseDate, getStoredTimezone())],
|
||||||
["Bin", isCheckedOut ? "In your custody" : bin ? bin.name : <span style={{ color: "var(--ink-3)" }}>—</span>],
|
["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 ?? "—"}`],
|
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
|
||||||
[
|
[
|
||||||
"Cost per gram",
|
"Cost per gram",
|
||||||
@@ -294,6 +300,18 @@ export function ProductDetail({
|
|||||||
{cfg?.unit}). Re-audit to update.
|
{cfg?.unit}). Re-audit to update.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ function InstanceDetailsStep({
|
|||||||
const [newShopLocation, setNewShopLocation] = useState("");
|
const [newShopLocation, setNewShopLocation] = useState("");
|
||||||
const [newBinName, setNewBinName] = useState("");
|
const [newBinName, setNewBinName] = useState("");
|
||||||
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
||||||
|
const [containerWeight, setContainerWeight] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||||
@@ -419,6 +420,7 @@ function InstanceDetailsStep({
|
|||||||
shopId,
|
shopId,
|
||||||
binId,
|
binId,
|
||||||
weight: isDiscrete ? undefined : form.weight,
|
weight: isDiscrete ? undefined : form.weight,
|
||||||
|
containerWeight: !isDiscrete && containerWeight !== "" ? parseFloat(containerWeight) : undefined,
|
||||||
countOriginal: isDiscrete ? 1 : undefined,
|
countOriginal: isDiscrete ? 1 : undefined,
|
||||||
unitWeight: isDiscrete ? form.unitWeight : undefined,
|
unitWeight: isDiscrete ? form.unitWeight : undefined,
|
||||||
price: form.price,
|
price: form.price,
|
||||||
@@ -624,6 +626,27 @@ function InstanceDetailsStep({
|
|||||||
</div>
|
</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 && (
|
{cfg?.showCannabinoidPct !== false && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -55,18 +55,37 @@ export function AuditFlow({
|
|||||||
return helpers.estimatedRemaining(i, getToday(getStoredTimezone())).toFixed(2);
|
return helpers.estimatedRemaining(i, getToday(getStoredTimezone())).toFixed(2);
|
||||||
};
|
};
|
||||||
const [value, setValue] = useState<string>(initialValueFor(item));
|
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);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(initialValueFor(item));
|
setValue(initialValueFor(item));
|
||||||
|
setInputMode(item?.containerWeight != null ? "container" : "direct");
|
||||||
|
setContainerTotal("");
|
||||||
}, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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({
|
const audit = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
api.auditInventoryItem(itemId, {
|
api.auditInventoryItem(itemId, {
|
||||||
date,
|
date,
|
||||||
mode: cfg?.auditMode ?? "weigh",
|
mode: effectiveMode,
|
||||||
value: Number(value),
|
value: effectiveValue,
|
||||||
confirmedBy: cfg?.auditMode === "presence" ? confirmedBy : undefined,
|
confirmedBy: cfg?.auditMode === "presence" ? confirmedBy : undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -84,7 +103,9 @@ export function AuditFlow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const auditMode = cfg?.auditMode ?? "weigh";
|
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 last = item ? helpers.lastAudit(item) : null;
|
||||||
const prevValue = item
|
const prevValue = item
|
||||||
@@ -95,7 +116,7 @@ export function AuditFlow({
|
|||||||
: item.weight
|
: item.weight
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const delta = Number(value) - prevValue;
|
const delta = effectiveValue - prevValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalBackdrop onClose={onClose}>
|
<ModalBackdrop onClose={onClose}>
|
||||||
@@ -156,6 +177,23 @@ export function AuditFlow({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -164,22 +202,33 @@ export function AuditFlow({
|
|||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field
|
{inputMode === "container" && tare != null ? (
|
||||||
label={
|
<Field label="Container weight now (g)">
|
||||||
item.kind === "discrete"
|
<Input
|
||||||
? `Count now (${cfg?.unit})`
|
type="number"
|
||||||
: auditMode === "weigh"
|
step="0.01"
|
||||||
? `Weight now (${cfg?.unit})`
|
value={containerTotal}
|
||||||
: `Estimate now (${cfg?.unit})`
|
onChange={(e) => setContainerTotal(e.target.value)}
|
||||||
}
|
/>
|
||||||
>
|
</Field>
|
||||||
<Input
|
) : (
|
||||||
type="number"
|
<Field
|
||||||
step={item.kind === "discrete" ? "1" : "0.1"}
|
label={
|
||||||
value={value}
|
item.kind === "discrete"
|
||||||
onChange={(e) => setValue(e.target.value)}
|
? `Count now (${cfg?.unit})`
|
||||||
/>
|
: auditMode === "weigh"
|
||||||
</Field>
|
? `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">
|
<Field label="Date">
|
||||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -196,6 +245,17 @@ export function AuditFlow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
@@ -217,7 +277,7 @@ export function AuditFlow({
|
|||||||
<div>
|
<div>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
|
||||||
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}>
|
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}>
|
||||||
{value} {cfg?.unit}
|
{effectiveValue.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Item } from "../../types.js";
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
|
import { TYPES } from "../../types.js";
|
||||||
import { api } from "../../api.js";
|
import { api } from "../../api.js";
|
||||||
import type { BatchOp } from "../../api.js";
|
import type { BatchOp } from "../../api.js";
|
||||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||||
@@ -26,8 +27,14 @@ export function BulkEditModal({
|
|||||||
const [cbd, setCbd] = useState("");
|
const [cbd, setCbd] = useState("");
|
||||||
const [totalCannabinoids, setTotalCannabinoids] = useState("");
|
const [totalCannabinoids, setTotalCannabinoids] = useState("");
|
||||||
const [purchaseDate, setPurchaseDate] = useState("");
|
const [purchaseDate, setPurchaseDate] = useState("");
|
||||||
|
const [containerWeight, setContainerWeight] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
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({
|
const save = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
const fields: Record<string, string | number | null> = {};
|
const fields: Record<string, string | number | null> = {};
|
||||||
@@ -38,6 +45,7 @@ export function BulkEditModal({
|
|||||||
if (cbd !== "") fields.cbd = parseFloat(cbd);
|
if (cbd !== "") fields.cbd = parseFloat(cbd);
|
||||||
if (totalCannabinoids !== "") fields.totalCannabinoids = parseFloat(totalCannabinoids);
|
if (totalCannabinoids !== "") fields.totalCannabinoids = parseFloat(totalCannabinoids);
|
||||||
if (purchaseDate) fields.purchaseDate = purchaseDate;
|
if (purchaseDate) fields.purchaseDate = purchaseDate;
|
||||||
|
if (containerWeight !== "") fields.containerWeight = parseFloat(containerWeight);
|
||||||
|
|
||||||
if (Object.keys(fields).length === 0) {
|
if (Object.keys(fields).length === 0) {
|
||||||
return Promise.reject(new Error("No fields to update — fill in at least one field."));
|
return Promise.reject(new Error("No fields to update — fill in at least one field."));
|
||||||
@@ -163,6 +171,20 @@ export function BulkEditModal({
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export function EditInventoryFlow({
|
|||||||
const [newShopLocation, setNewShopLocation] = useState("");
|
const [newShopLocation, setNewShopLocation] = useState("");
|
||||||
const [newBinName, setNewBinName] = useState("");
|
const [newBinName, setNewBinName] = useState("");
|
||||||
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
||||||
|
const [containerWeight, setContainerWeight] = useState(
|
||||||
|
item.containerWeight != null ? String(item.containerWeight) : "",
|
||||||
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||||
@@ -69,6 +72,7 @@ export function EditInventoryFlow({
|
|||||||
shopId,
|
shopId,
|
||||||
binId,
|
binId,
|
||||||
weight: isDiscrete ? undefined : form.weight,
|
weight: isDiscrete ? undefined : form.weight,
|
||||||
|
containerWeight: !isDiscrete ? (containerWeight !== "" ? parseFloat(containerWeight) : null) : undefined,
|
||||||
unitWeight: isDiscrete ? form.unitWeight : undefined,
|
unitWeight: isDiscrete ? form.unitWeight : undefined,
|
||||||
price: form.price,
|
price: form.price,
|
||||||
thc: form.thc,
|
thc: form.thc,
|
||||||
@@ -235,6 +239,27 @@ export function EditInventoryFlow({
|
|||||||
</div>
|
</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 && (
|
{cfg?.showCannabinoidPct !== false && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface InventoryItem {
|
|||||||
cbd: number;
|
cbd: number;
|
||||||
totalCannabinoids: number;
|
totalCannabinoids: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
containerWeight: number | null;
|
||||||
lastAuditWeight: number | null;
|
lastAuditWeight: number | null;
|
||||||
countOriginal: number;
|
countOriginal: number;
|
||||||
countLastAudit: number | null;
|
countLastAudit: number | null;
|
||||||
@@ -183,6 +184,10 @@ export const helpers = {
|
|||||||
typeConfig(id: string): TypeConfig {
|
typeConfig(id: string): TypeConfig {
|
||||||
return TYPES.find((t) => t.id === id) ?? TYPES[0]!;
|
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 {
|
daysSince(iso: string | null, today = TODAY_STR): number {
|
||||||
if (!iso) return Infinity;
|
if (!iso) return Infinity;
|
||||||
return Math.floor((+new Date(today) - +new Date(iso)) / 86_400_000);
|
return Math.floor((+new Date(today) - +new Date(iso)) / 86_400_000);
|
||||||
|
|||||||
Reference in New Issue
Block a user