Track inventory at the instance level, not by product
Build and push image / build (push) Successful in 46s

The products table conflated catalog ("kind of thing you scan") with
instance ("this jar I bought") — splitting it lets us record every
purchase as its own asset and autofill brand/shop/price/THC from the
last instance when scanning a known SKU.

- products: sku + strain + name + type + kind (catalog only)
- inventory_items: physical jars with short-UUID asset ids, per-batch
  brand/shop/bin/price/cannabinoids/weight, audits, lifecycle
- audits now key on inventory_id; strains lose brand_id and type
- migration: rename existing products/audits/strains to *_legacy on
  first boot so users keep historical reference, fresh start otherwise
- two-step add flow: scan SKU → select/create product → instance
  details (autofilled from last instance) → generated asset id shown
- ScanField matches asset id first, falls back to SKU
- inventory list defaults flat, "By product" toggle groups instances

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 05:59:46 -04:00
parent 1abfda7989
commit 02dc6e523f
28 changed files with 2315 additions and 1355 deletions
+56 -42
View File
@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Product } from "../../types.js";
import { TYPES, helpers, TODAY_STR } from "../../types.js";
import type { Bootstrap, Item } from "../../types.js";
import { TYPES, helpers, TODAY_STR, enrichItems } from "../../types.js";
import { api } from "../../api.js";
import { Btn, Field, Input, Select } from "../primitives/index.js";
import { ScanField } from "../ScanField.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
import { ScanField, type ScanResult } from "../ScanField.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
weigh: {
@@ -25,40 +25,41 @@ const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
export function AuditFlow({
data,
onClose,
product: initialProduct,
item: initialItem,
}: {
data: Bootstrap;
onClose: () => void;
product: Product | null;
item: Item | null;
}) {
const qc = useQueryClient();
const overdueFirst = [...data.products]
.filter((p) => p.status === "active")
const allItems = enrichItems(data);
const overdueFirst = [...allItems]
.filter((i) => i.status === "active")
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
const [productId, setProductId] = useState(initialProduct?.id ?? overdueFirst[0]?.id ?? "");
const [itemId, setItemId] = useState(initialItem?.id ?? overdueFirst[0]?.id ?? "");
const [date, setDate] = useState(TODAY_STR);
const [confirmedBy, setConfirmedBy] = useState<"SKU" | "asset" | "visual">("SKU");
const [confirmedBy, setConfirmedBy] = useState<"asset" | "SKU" | "visual">("asset");
const product = data.products.find((p) => p.id === productId);
const cfg = product ? TYPES.find((t) => t.id === product.type) : undefined;
const item = allItems.find((i) => i.id === itemId);
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
const initialValueFor = (p: Product | undefined): string => {
if (!p) return "0";
if (p.kind === "discrete") {
return String(p.countLastAudit ?? p.countOriginal);
const initialValueFor = (i: Item | undefined): string => {
if (!i) return "0";
if (i.kind === "discrete") {
return String(i.countLastAudit ?? i.countOriginal);
}
return helpers.estimatedRemaining(p, TODAY_STR).toFixed(2);
return helpers.estimatedRemaining(i, TODAY_STR).toFixed(2);
};
const [value, setValue] = useState<string>(initialValueFor(product));
const [value, setValue] = useState<string>(initialValueFor(item));
useEffect(() => {
setValue(initialValueFor(product));
}, [productId]); // eslint-disable-line react-hooks/exhaustive-deps
setValue(initialValueFor(item));
}, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps
const audit = useMutation({
mutationFn: () =>
api.auditProduct(productId, {
api.auditInventoryItem(itemId, {
date,
mode: cfg?.auditMode ?? "weigh",
value: Number(value),
@@ -70,17 +71,29 @@ export function AuditFlow({
},
});
if (!product) return null;
const handleScan = (result: ScanResult) => {
if (result.kind === "item") {
setItemId(result.item.id);
} else {
// SKU scan — pick the most recent active instance of that product.
const candidate = overdueFirst
.filter((i) => i.productId === result.product.id)
.sort((a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate))[0];
if (candidate) setItemId(candidate.id);
}
};
if (!item) return null;
const auditMode = cfg?.auditMode ?? "weigh";
const ml = AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.weigh!;
const last = helpers.lastAudit(product);
const last = helpers.lastAudit(item);
const prevValue =
product.kind === "discrete"
? product.countLastAudit ?? product.countOriginal
item.kind === "discrete"
? item.countLastAudit ?? item.countOriginal
: last
? last.value
: product.weight;
: item.weight;
const delta = Number(value) - prevValue;
@@ -100,21 +113,22 @@ export function AuditFlow({
<div style={{ padding: 32 }}>
<ScanField
products={overdueFirst}
matchedProduct={product ?? null}
onMatch={setProductId}
items={overdueFirst}
products={data.products}
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
onMatch={handleScan}
/>
<div style={{ marginTop: 16 }}>
<Field label="Or pick from list">
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
{overdueFirst.map((p) => {
const od = helpers.auditOverdue(p);
const sc = helpers.daysSinceCheck(p);
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
{overdueFirst.map((i) => {
const od = helpers.auditOverdue(i);
const sc = helpers.daysSinceCheck(i);
return (
<option key={p.id} value={p.id}>
<option key={i.id} value={i.id}>
{od ? "⚠ " : ""}
{p.name} {helpers.brandName(data, p.brandId)} · {sc}d since check
{i.assetId} · {i.name} {helpers.brandName(data, i.brandId)} · {sc}d since check
</option>
);
})}
@@ -134,16 +148,16 @@ export function AuditFlow({
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div className="serif" style={{ fontSize: 20, fontWeight: 500 }}>
{product.name}
{item.name}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d
<span className="mono">{item.assetId}</span> · {item.type} · {item.kind} · cadence every {cfg?.cadenceDays}d
</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LAST CHECKED</div>
<div className="serif" style={{ fontSize: 18 }}>
{last ? `${helpers.daysSinceCheck(product)}d ago` : "Never"}
{last ? `${helpers.daysSinceCheck(item)}d ago` : "Never"}
</div>
</div>
</div>
@@ -162,7 +176,7 @@ export function AuditFlow({
>
<Field
label={
product.kind === "discrete"
item.kind === "discrete"
? `Count now (${cfg?.unit})`
: auditMode === "weigh"
? `Weight now (${cfg?.unit})`
@@ -171,7 +185,7 @@ export function AuditFlow({
>
<Input
type="number"
step={product.kind === "discrete" ? "1" : "0.1"}
step={item.kind === "discrete" ? "1" : "0.1"}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
@@ -185,8 +199,8 @@ export function AuditFlow({
value={confirmedBy}
onChange={(e) => setConfirmedBy(e.target.value as typeof confirmedBy)}
>
<option value="asset">Asset id</option>
<option value="SKU">SKU label</option>
<option value="asset">Asset tag</option>
<option value="visual">Visual ID</option>
</Select>
</Field>
@@ -226,7 +240,7 @@ export function AuditFlow({
color: delta < 0 ? "var(--terracotta)" : "var(--ink)",
}}
>
{delta.toFixed(product.kind === "discrete" ? 0 : 2)} {cfg?.unit}
{delta.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit}
</div>
</div>
</div>