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
+16 -6
View File
@@ -14,11 +14,16 @@ export function ScanField({
items,
products,
onMatch,
onScanNoMatch,
matchedLabel,
}: {
items: Item[];
products?: Product[];
onMatch: (result: ScanResult) => void;
// Fired once after a debounce when the scanned text doesn't resolve to
// any known asset id or SKU. The parent can use the raw value (e.g. to
// open a "create new product" form prefilled with the scanned SKU).
onScanNoMatch?: (raw: string) => void;
matchedLabel: string | null;
}) {
const [scan, setScan] = useState("");
@@ -32,11 +37,10 @@ export function ScanField({
}
const hit = lookup(trimmed, items, products);
if (hit) {
const label = hit.kind === "item" ? hit.item.name : hit.product.sku;
onMatch(hit);
setScan("");
const name =
hit.kind === "item" ? hit.item.name : hit.product.name;
setFeedback({ type: "matched", text: `Matched ${name}` });
setFeedback({ type: "matched", text: `Matched ${label}` });
}
}, [scan]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -45,9 +49,15 @@ export function ScanField({
useEffect(() => {
if (!scan.trim() || feedback?.type === "matched") return;
const timer = setTimeout(() => {
const trimmed = scan.trim().toLowerCase();
if (!lookup(trimmed, items, products)) {
setFeedback({ type: "miss", text: "No asset id or SKU matches that." });
const raw = scan.trim();
if (!lookup(raw.toLowerCase(), items, products)) {
if (onScanNoMatch) {
onScanNoMatch(raw);
setScan("");
setFeedback(null);
} else {
setFeedback({ type: "miss", text: "No asset id or SKU matches that." });
}
}
}, 400);
return () => clearTimeout(timer);