User-supplied asset ids; brand on product; strain is the name
Build and push image / build (push) Successful in 48s

Four UX changes after using the rework for a bit:

1. Asset ids are 6-digit numbers from a roll of physical labels — server
   no longer generates them. POST /api/inventory requires assetId; the
   add-inventory form has a digits-only input that auto-focuses on entry.
2. Strain and product name are the same thing. Drop products.name; the
   strain's name supplies the display. Product creation just asks for
   "Name (strain)" and matches/creates a strain by that name.
3. Brand moves from inventory_items to products. SKUs are brand-specific,
   so all instances of a product share the brand. Brand selector lives
   on the product create/edit form, not the per-instance form.
4. Scanning an unknown SKU on the add-inventory step now opens the
   create-product subform with the SKU prefilled — one less click.

Migration: detect prior shape (products.name column present) and rename
products/inventory_items/audits to *_v1 archives, recreate empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 18:17:12 -04:00
parent 02dc6e523f
commit 80034b47c5
15 changed files with 380 additions and 256 deletions
+135 -89
View File
@@ -1,7 +1,13 @@
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 {
ASSET_ID_RE,
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";
@@ -25,6 +31,10 @@ export function AddInventoryFlow({ data, onClose }: { data: Bootstrap; onClose:
const product = productId
? data.products.find((p) => p.id === productId) ?? null
: null;
const productName =
product?.strainId
? data.strains.find((s) => s.id === product.strainId)?.name ?? ""
: "";
const goToDetails = (id: string) => {
setProductId(id);
@@ -48,7 +58,7 @@ export function AddInventoryFlow({ data, onClose }: { data: Bootstrap; onClose:
step === "select"
? "Add inventory"
: step === "details"
? `Add ${product?.name ?? ""}`
? `Add ${productName}`
: "Saved"
}
eyebrow={
@@ -75,6 +85,7 @@ export function AddInventoryFlow({ data, onClose }: { data: Bootstrap; onClose:
data={data}
items={items}
product={product}
productName={productName}
onBack={() => setStep("select")}
onSaved={(assetId) => {
setSavedAssetId(assetId);
@@ -87,7 +98,7 @@ export function AddInventoryFlow({ data, onClose }: { data: Bootstrap; onClose:
{step === "done" && savedAssetId && product && (
<DonePane
assetId={savedAssetId}
productName={product.name}
productName={productName}
onAddAnother={() => {
setSavedAssetId(null);
setProductId(null);
@@ -123,10 +134,12 @@ function SelectProductStep({
// New-product subform
const [newSku, setNewSku] = useState("");
const [newName, setNewName] = useState("");
const [newName, setNewName] = useState(""); // strain name; doubles as display name
const [newType, setNewType] = useState("Flower");
const [newStrain, setNewStrain] = useState(""); // typed strain name
const [newStrainId, setNewStrainId] = useState<string>(""); // empty = match-by-name / create
const [newBrandId, setNewBrandId] = useState<string>(
data.brands[0]?.id ?? NEW_BRAND,
);
const [newBrandName, setNewBrandName] = useState("");
const [error, setError] = useState<string | null>(null);
const handleScan = (result: ScanResult) => {
@@ -140,29 +153,43 @@ function SelectProductStep({
}
};
const handleNoMatch = (raw: string) => {
// Scanned a SKU we've never seen — open the create form prefilled.
setNewSku(raw.toUpperCase());
setCreating(true);
};
const matchedStrain: Strain | null = useMemo(() => {
const q = newStrain.trim().toLowerCase();
const q = newName.trim().toLowerCase();
if (!q) return null;
return data.strains.find((s) => s.name.trim().toLowerCase() === q) ?? null;
}, [newStrain, data.strains]);
}, [newName, 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");
if (!name) throw new Error("Name (strain) required");
const cfg = TYPES.find((t) => t.id === newType);
if (!cfg) throw new Error("Type required");
let brandId: string | null = null;
if (newBrandId === NEW_BRAND) {
if (!newBrandName.trim()) throw new Error("New brand name required");
const b = await api.createBrand(newBrandName.trim());
brandId = b.id;
} else {
brandId = newBrandId || null;
}
const result = await api.createProduct({
sku,
name,
type: newType,
kind: cfg.kind,
strainId: newStrainId || matchedStrain?.id || undefined,
strainName: newStrainId || matchedStrain ? undefined : strainName,
strainId: matchedStrain?.id,
strainName: matchedStrain ? undefined : name,
brandId,
});
return result.id;
},
@@ -173,6 +200,8 @@ function SelectProductStep({
onError: (e: Error) => setError(e.message),
});
const isNewBrand = newBrandId === NEW_BRAND;
return (
<>
<div style={{ padding: 32 }}>
@@ -180,6 +209,7 @@ function SelectProductStep({
items={items}
products={data.products}
onMatch={handleScan}
onScanNoMatch={creating ? undefined : handleNoMatch}
matchedLabel={null}
/>
@@ -190,11 +220,15 @@ function SelectProductStep({
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>
))}
{data.products.map((p) => {
const strainName =
data.strains.find((s) => s.id === p.strainId)?.name ?? "?";
return (
<option key={p.id} value={p.id}>
{strainName} · {p.sku} ({p.type})
</option>
);
})}
</Select>
</Field>
</div>
@@ -227,6 +261,7 @@ function SelectProductStep({
value={newSku}
placeholder="SKU-XXXXXX"
onChange={(e) => setNewSku(e.target.value)}
autoFocus={!newSku}
/>
</Field>
<Field label="Type">
@@ -238,41 +273,43 @@ function SelectProductStep({
))}
</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"
label="Name (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)."
: "Will create a new strain entry from this name."
}
>
<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}
value={newName}
placeholder="e.g. Garden Ghost"
onChange={(e) => setNewName(e.target.value)}
/>
</Field>
<Field label="Brand">
<Select
value={newBrandId}
onChange={(e) => setNewBrandId(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>
{isNewBrand && (
<Field label="New brand name">
<Input
value={newBrandName}
onChange={(e) => setNewBrandName(e.target.value)}
placeholder="e.g. Foxglove Farms"
/>
</Field>
)}
</div>
{error && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
@@ -329,12 +366,14 @@ function InstanceDetailsStep({
data,
items,
product,
productName,
onBack,
onSaved,
}: {
data: Bootstrap;
items: Item[];
product: Product;
productName: string;
onBack: () => void;
onSaved: (assetId: string) => void;
}) {
@@ -353,8 +392,8 @@ function InstanceDetailsStep({
return last.price;
})();
const [assetId, setAssetId] = useState("");
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),
@@ -366,7 +405,6 @@ function InstanceDetailsStep({
totalCannabinoids: last?.totalCannabinoids ?? 26,
purchaseDate: TODAY_STR,
});
const [newBrand, setNewBrand] = useState("");
const [newShopName, setNewShopName] = useState("");
const [newShopLocation, setNewShopLocation] = useState("");
const [newBinName, setNewBinName] = useState("");
@@ -378,15 +416,15 @@ function InstanceDetailsStep({
const totalPrice = isDiscrete ? form.price * form.countOriginal : form.price;
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
const assetIdValid = ASSET_ID_RE.test(assetId);
const assetIdConflict =
assetIdValid && data.inventoryItems.some((i) => i.assetId === assetId);
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 (!assetIdValid) throw new Error("Asset id must be exactly 6 digits");
if (assetIdConflict) throw new Error("Asset id already used");
let { shopId, binId } = form;
if (shopId === NEW_SHOP) {
if (!newShopName.trim()) throw new Error("New shop name required");
const s = await api.createShop({
@@ -404,8 +442,8 @@ function InstanceDetailsStep({
binId = b.id;
}
return api.createInventoryItem({
assetId,
productId: product.id,
brandId,
shopId,
binId,
weight: isDiscrete ? undefined : form.weight,
@@ -422,13 +460,11 @@ function InstanceDetailsStep({
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;
const brandName = data.brands.find((b) => b.id === product.brandId)?.name ?? "—";
return (
<>
@@ -448,17 +484,46 @@ function InstanceDetailsStep({
}}
>
<span>
<strong style={{ color: "var(--ink-2)" }}>{product.name}</strong> · {product.type} ·{" "}
<strong style={{ color: "var(--ink-2)" }}>{productName}</strong> · {brandName} · {product.type} ·{" "}
<span className="mono">{product.sku}</span>
</span>
<span>
{priorCount > 0
? `${priorCount} prior instance${priorCount === 1 ? "" : "s"} — fields autofilled from most recent.`
? `${priorCount} prior instance${priorCount === 1 ? "" : "s"} — fields autofilled.`
: "First instance of this product."}
</span>
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Asset tag
</div>
<Field
label="Asset id (6 digits)"
hint={
assetIdConflict
? "That asset id is already in use."
: "From the next sticker on your roll. Required."
}
>
<Input
autoFocus
value={assetId}
inputMode="numeric"
maxLength={6}
placeholder="000000"
onChange={(e) => setAssetId(e.target.value.replace(/\D/g, "").slice(0, 6))}
style={{
fontFamily: "var(--mono)",
letterSpacing: "0.1em",
borderColor: assetIdConflict ? "var(--terracotta)" : undefined,
}}
/>
</Field>
<div
className="smallcaps"
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
>
Source
</div>
<div
@@ -469,16 +534,6 @@ function InstanceDetailsStep({
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) => (
@@ -489,15 +544,16 @@ function InstanceDetailsStep({
<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>
)}
<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>
{isNewShop && (
<>
<Field label="New shop name">
@@ -516,16 +572,6 @@ function InstanceDetailsStep({
</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">
@@ -672,7 +718,7 @@ function InstanceDetailsStep({
<Btn
variant="primary"
icon="check"
disabled={save.isPending}
disabled={save.isPending || !assetIdValid || assetIdConflict}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : "Save inventory item"}
@@ -712,9 +758,9 @@ function DonePane({
{assetId}
</div>
<div style={{ fontSize: 13, color: "var(--ink-2)", marginBottom: 8 }}>
Label this {productName} with{" "}
Stick label{" "}
<span className="mono" style={{ color: "var(--ink)" }}>{assetId}</span>{" "}
so you can scan it later.
on the {productName}.
</div>
</div>
<ModalFooter>