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
@@ -0,0 +1,730 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, InventoryItem, Item, Product, Strain } from "../../types.js";
import { TYPES, TODAY_STR, enrichItems, getLastInstance } from "../../types.js";
import { fmt } from "../../format.js";
import { api } from "../../api.js";
import { Btn, Field, Input, Select } from "../primitives/index.js";
import { ScanField, type ScanResult } from "../ScanField.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
const NEW_BRAND = "__new_brand__";
const NEW_SHOP = "__new_shop__";
const NEW_BIN = "__new_bin__";
type Step = "select" | "details" | "done";
export function AddInventoryFlow({ data, onClose }: { data: Bootstrap; onClose: () => void }) {
const qc = useQueryClient();
const items = useMemo(() => enrichItems(data), [data]);
const [step, setStep] = useState<Step>("select");
const [productId, setProductId] = useState<string | null>(null);
const [savedAssetId, setSavedAssetId] = useState<string | null>(null);
const product = productId
? data.products.find((p) => p.id === productId) ?? null
: null;
const goToDetails = (id: string) => {
setProductId(id);
setStep("details");
};
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(840px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader
title={
step === "select"
? "Add inventory"
: step === "details"
? `Add ${product?.name ?? ""}`
: "Saved"
}
eyebrow={
step === "select"
? "Step 1 · Scan or pick a product"
: step === "details"
? "Step 2 · This batch's details"
: "Inventory item created"
}
onClose={onClose}
/>
{step === "select" && (
<SelectProductStep
data={data}
items={items}
onPickProduct={(id) => goToDetails(id)}
onClose={onClose}
/>
)}
{step === "details" && product && (
<InstanceDetailsStep
data={data}
items={items}
product={product}
onBack={() => setStep("select")}
onSaved={(assetId) => {
setSavedAssetId(assetId);
qc.invalidateQueries({ queryKey: ["bootstrap"] });
setStep("done");
}}
/>
)}
{step === "done" && savedAssetId && product && (
<DonePane
assetId={savedAssetId}
productName={product.name}
onAddAnother={() => {
setSavedAssetId(null);
setProductId(null);
setStep("select");
}}
onClose={onClose}
/>
)}
</div>
</ModalBackdrop>
);
}
// ─── Step 1 ─────────────────────────────────────────────────────────
function SelectProductStep({
data,
items,
onPickProduct,
onClose,
}: {
data: Bootstrap;
items: Item[];
onPickProduct: (productId: string) => void;
onClose: () => void;
}) {
const qc = useQueryClient();
const [creating, setCreating] = useState(false);
const [pickedProductId, setPickedProductId] = useState<string>(
data.products[0]?.id ?? "",
);
// New-product subform
const [newSku, setNewSku] = useState("");
const [newName, setNewName] = useState("");
const [newType, setNewType] = useState("Flower");
const [newStrain, setNewStrain] = useState(""); // typed strain name
const [newStrainId, setNewStrainId] = useState<string>(""); // empty = match-by-name / create
const [error, setError] = useState<string | null>(null);
const handleScan = (result: ScanResult) => {
if (result.kind === "product") {
onPickProduct(result.product.id);
} else {
// Asset scan in add-inventory flow — interpret as "I want another of
// this kind", select that product so the form pre-fills from the
// existing instance.
onPickProduct(result.item.productId);
}
};
const matchedStrain: Strain | null = useMemo(() => {
const q = newStrain.trim().toLowerCase();
if (!q) return null;
return data.strains.find((s) => s.name.trim().toLowerCase() === q) ?? null;
}, [newStrain, data.strains]);
const create = useMutation({
mutationFn: async () => {
const sku = newSku.trim();
const name = newName.trim();
const strainName = newStrain.trim() || name;
if (!sku) throw new Error("SKU required");
if (!name) throw new Error("Product name required");
const cfg = TYPES.find((t) => t.id === newType);
if (!cfg) throw new Error("Type required");
const result = await api.createProduct({
sku,
name,
type: newType,
kind: cfg.kind,
strainId: newStrainId || matchedStrain?.id || undefined,
strainName: newStrainId || matchedStrain ? undefined : strainName,
});
return result.id;
},
onSuccess: async (id) => {
await qc.invalidateQueries({ queryKey: ["bootstrap"] });
onPickProduct(id);
},
onError: (e: Error) => setError(e.message),
});
return (
<>
<div style={{ padding: 32 }}>
<ScanField
items={items}
products={data.products}
onMatch={handleScan}
matchedLabel={null}
/>
{data.products.length > 0 && !creating && (
<div style={{ marginTop: 16 }}>
<Field label="Or pick an existing product">
<Select
value={pickedProductId}
onChange={(e) => setPickedProductId(e.target.value)}
>
{data.products.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · {p.sku} ({p.type})
</option>
))}
</Select>
</Field>
</div>
)}
{!creating && (
<div style={{ marginTop: 18 }}>
<Btn variant="ghost" icon="plus" onClick={() => setCreating(true)}>
Create a new product
</Btn>
</div>
)}
{creating && (
<div
style={{
marginTop: 24,
padding: 20,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
New product (catalog entry)
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16 }}>
<Field label="SKU" hint="The barcode you'll scan">
<Input
value={newSku}
placeholder="SKU-XXXXXX"
onChange={(e) => setNewSku(e.target.value)}
/>
</Field>
<Field label="Type">
<Select value={newType} onChange={(e) => setNewType(e.target.value)}>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.id} ({t.kind})
</option>
))}
</Select>
</Field>
<Field label="Product name" span={2}>
<Input
value={newName}
placeholder="e.g. Garden Ghost 3.5g"
onChange={(e) => setNewName(e.target.value)}
/>
</Field>
<Field
label="Strain"
span={2}
hint={
matchedStrain
? `Will link to existing strain "${matchedStrain.name}".`
: "Will create a new strain entry from this name (defaults blank — link from product later)."
}
>
<Select
value={newStrainId}
onChange={(e) => setNewStrainId(e.target.value)}
style={{ marginBottom: 8 }}
>
<option value=""> Match by name typed below </option>
{data.strains.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
<Input
value={newStrain}
placeholder={`Strain name (defaults to product name if blank)`}
onChange={(e) => setNewStrain(e.target.value)}
disabled={!!newStrainId}
/>
</Field>
</div>
{error && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
)}
</div>
<ModalFooter>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{creating
? "Create the product, then we'll capture this batch's details."
: "Scan a SKU, pick a product, or create one."}
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>
Cancel
</Btn>
{creating ? (
<>
<Btn variant="ghost" onClick={() => setCreating(false)}>
Back
</Btn>
<Btn
variant="primary"
icon="check"
disabled={create.isPending}
onClick={() => create.mutate()}
>
{create.isPending ? "Creating…" : "Create product"}
</Btn>
</>
) : (
data.products.length > 0 && (
<Btn
variant="primary"
icon="plus"
disabled={!pickedProductId}
onClick={() => onPickProduct(pickedProductId)}
>
Add inventory
</Btn>
)
)}
</div>
</ModalFooter>
</>
);
}
// ─── Step 2 ─────────────────────────────────────────────────────────
function InstanceDetailsStep({
data,
items,
product,
onBack,
onSaved,
}: {
data: Bootstrap;
items: Item[];
product: Product;
onBack: () => void;
onSaved: (assetId: string) => void;
}) {
const last: InventoryItem | null = useMemo(
() => getLastInstance(data.inventoryItems, product.id),
[data.inventoryItems, product.id],
);
const cfg = TYPES.find((t) => t.id === product.type);
const isDiscrete = product.kind === "discrete";
// form.price is total for bulk, per-unit for discrete (matches existing UI).
const initialPrice = (() => {
if (!last) return isDiscrete ? 5 : 45;
if (isDiscrete && last.countOriginal > 0) return last.price / last.countOriginal;
return last.price;
})();
const [form, setForm] = useState({
brandId: last?.brandId ?? data.brands[0]?.id ?? NEW_BRAND,
shopId: last?.shopId ?? data.shops[0]?.id ?? NEW_SHOP,
binId: data.bins[0]?.id ?? NEW_BIN,
weight: last?.weight ?? (isDiscrete ? 0 : 3.5),
countOriginal: last?.countOriginal ?? (isDiscrete ? 1 : 0),
unitWeight: last?.unitWeight ?? (isDiscrete ? 0.7 : 0),
price: initialPrice,
thc: last?.thc ?? 22,
cbd: last?.cbd ?? 0.4,
totalCannabinoids: last?.totalCannabinoids ?? 26,
purchaseDate: TODAY_STR,
});
const [newBrand, setNewBrand] = useState("");
const [newShopName, setNewShopName] = useState("");
const [newShopLocation, setNewShopLocation] = useState("");
const [newBinName, setNewBinName] = useState("");
const [newBinCapacity, setNewBinCapacity] = useState(10);
const [error, setError] = useState<string | null>(null);
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const totalPrice = isDiscrete ? form.price * form.countOriginal : form.price;
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
const save = useMutation({
mutationFn: async () => {
let { brandId, shopId, binId } = form;
if (brandId === NEW_BRAND) {
if (!newBrand.trim()) throw new Error("New brand name required");
const b = await api.createBrand(newBrand.trim());
brandId = b.id;
}
if (shopId === NEW_SHOP) {
if (!newShopName.trim()) throw new Error("New shop name required");
const s = await api.createShop({
name: newShopName.trim(),
location: newShopLocation.trim(),
});
shopId = s.id;
}
if (binId === NEW_BIN) {
if (!newBinName.trim()) throw new Error("New bin name required");
const b = await api.createBin({
name: newBinName.trim(),
capacity: newBinCapacity,
});
binId = b.id;
}
return api.createInventoryItem({
productId: product.id,
brandId,
shopId,
binId,
weight: isDiscrete ? undefined : form.weight,
countOriginal: isDiscrete ? form.countOriginal : undefined,
unitWeight: isDiscrete ? form.unitWeight : undefined,
price: totalPrice,
thc: form.thc,
cbd: form.cbd,
totalCannabinoids: form.totalCannabinoids,
purchaseDate: form.purchaseDate,
});
},
onSuccess: (result) => onSaved(result.assetId),
onError: (e: Error) => setError(e.message),
});
const isNewBrand = form.brandId === NEW_BRAND;
const isNewShop = form.shopId === NEW_SHOP;
const isNewBin = form.binId === NEW_BIN;
// Find prior instances of this product (excluding any from before bootstrap;
// safe — we just count to surface "we've had this N times before").
const priorCount = items.filter((i) => i.productId === product.id).length;
return (
<>
<div style={{ padding: 32 }}>
<div
style={{
marginBottom: 20,
padding: "12px 16px",
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-sm)",
fontSize: 12,
color: "var(--ink-3)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
<strong style={{ color: "var(--ink-2)" }}>{product.name}</strong> · {product.type} ·{" "}
<span className="mono">{product.sku}</span>
</span>
<span>
{priorCount > 0
? `${priorCount} prior instance${priorCount === 1 ? "" : "s"} — fields autofilled from most recent.`
: "First instance of this product."}
</span>
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Source
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: 16,
marginBottom: 28,
}}
>
<Field label="Brand">
<Select value={form.brandId} onChange={(e) => update("brandId", e.target.value)}>
{data.brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
<option value={NEW_BRAND}>+ Add new brand</option>
</Select>
</Field>
<Field label="Shop">
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
{data.shops.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
<option value={NEW_SHOP}>+ Add new shop</option>
</Select>
</Field>
{isNewBrand && (
<Field label="New brand name" span={2}>
<Input
value={newBrand}
onChange={(e) => setNewBrand(e.target.value)}
placeholder="e.g. Foxglove Farms"
/>
</Field>
)}
{isNewShop && (
<>
<Field label="New shop name">
<Input
value={newShopName}
onChange={(e) => setNewShopName(e.target.value)}
placeholder="e.g. Greenleaf Co-op"
/>
</Field>
<Field label="Location (optional)">
<Input
value={newShopLocation}
onChange={(e) => setNewShopLocation(e.target.value)}
placeholder="e.g. Capitol Hill"
/>
</Field>
</>
)}
<Field label="Bin">
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
{data.bins.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
<option value={NEW_BIN}>+ Add new bin</option>
</Select>
</Field>
{isNewBin && (
<>
<Field label="New bin name">
<Input
value={newBinName}
onChange={(e) => setNewBinName(e.target.value)}
placeholder="e.g. A1"
/>
</Field>
<Field label="Capacity">
<Input
type="number"
min={1}
step={1}
value={newBinCapacity}
onChange={(e) =>
setNewBinCapacity(Math.max(1, Math.floor(+e.target.value || 1)))
}
/>
</Field>
</>
)}
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Acquisition
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 16,
marginBottom: 8,
}}
>
{isDiscrete ? (
<>
<Field label={`Quantity (${cfg!.unit})`}>
<Input
type="number"
step="1"
value={form.countOriginal}
onChange={(e) => update("countOriginal", +e.target.value)}
/>
</Field>
<Field label="Per-unit weight (g)" hint="For grams stats">
<Input
type="number"
step="0.1"
value={form.unitWeight}
onChange={(e) => update("unitWeight", +e.target.value)}
/>
</Field>
</>
) : (
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
<Input
type="number"
step="0.1"
value={form.weight}
onChange={(e) => update("weight", +e.target.value)}
/>
</Field>
)}
<Field label={isDiscrete ? "Price per unit ($)" : "Price ($)"}>
<Input
type="number"
step="0.01"
value={form.price}
onChange={(e) => update("price", +e.target.value)}
/>
</Field>
<Field label="Purchase date">
<Input
type="date"
value={form.purchaseDate}
onChange={(e) => update("purchaseDate", e.target.value)}
/>
</Field>
</div>
{!isDiscrete && cpg > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Cost per {cfg?.unit ?? "g"}:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.money(cpg)}
</span>
</div>
)}
{isDiscrete && form.price > 0 && form.countOriginal > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Total:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.money(totalPrice)}
</span>
<span style={{ marginLeft: 6 }}>
({form.countOriginal} × {fmt.money(form.price)})
</span>
</div>
)}
<div
className="smallcaps"
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
>
Cannabinoid profile
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
<Field label="THC %">
<Input
type="number"
step="0.1"
value={form.thc}
onChange={(e) => update("thc", +e.target.value)}
/>
</Field>
<Field label="CBD %">
<Input
type="number"
step="0.1"
value={form.cbd}
onChange={(e) => update("cbd", +e.target.value)}
/>
</Field>
<Field label="Total cannabinoids %">
<Input
type="number"
step="0.1"
value={form.totalCannabinoids}
onChange={(e) => update("totalCannabinoids", +e.target.value)}
/>
</Field>
</div>
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
<ModalFooter>
<Btn variant="ghost" onClick={onBack}>
Back
</Btn>
<Btn
variant="primary"
icon="check"
disabled={save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : "Save inventory item"}
</Btn>
</ModalFooter>
</>
);
}
// ─── Step 3 ─────────────────────────────────────────────────────────
function DonePane({
assetId,
productName,
onAddAnother,
onClose,
}: {
assetId: string;
productName: string;
onAddAnother: () => void;
onClose: () => void;
}) {
return (
<>
<div style={{ padding: "48px 32px 32px", textAlign: "center" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Asset id</div>
<div
className="mono"
style={{
fontSize: 64,
fontWeight: 600,
letterSpacing: "0.08em",
margin: "16px 0",
color: "var(--ink)",
}}
>
{assetId}
</div>
<div style={{ fontSize: 13, color: "var(--ink-2)", marginBottom: 8 }}>
Label this {productName} with{" "}
<span className="mono" style={{ color: "var(--ink)" }}>{assetId}</span>{" "}
so you can scan it later.
</div>
</div>
<ModalFooter>
<Btn variant="ghost" onClick={onAddAnother}>
+ Add another
</Btn>
<Btn variant="primary" icon="check" onClick={onClose}>
Done
</Btn>
</ModalFooter>
</>
);
}