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
+18 -14
View File
@@ -11,25 +11,26 @@ export interface Audit {
}
// Product = catalog entry. Identified by SKU. One row per "kind of thing
// you can scan" — strain + form factor (Flower/bulk, Pre-roll/discrete, …).
// you can scan" — strain + brand + form factor (Flower/bulk, Pre-roll/discrete).
// Display name comes from the linked strain (strainId is required).
export interface Product {
id: string;
sku: string;
strainId: string | null;
name: string;
strainId: string;
brandId: string | null;
type: string;
kind: ProductKind;
createdAt: string;
}
// InventoryItem = a physical jar/pack you bought. Has its own asset id
// (label-printed). Carries everything that varies per purchase: brand, shop,
// bin, price, cannabinoids, weight/count, lifecycle, audits.
// InventoryItem = a physical jar/pack you bought. Has a 6-digit asset id
// (printed on a roll of labels the user owns). Carries per-batch values:
// shop, bin, price, cannabinoids, weight/count, lifecycle, audits. Brand
// lives on the product, not here.
export interface InventoryItem {
id: string;
assetId: string;
productId: string;
brandId: string | null;
shopId: string | null;
binId: string | null;
price: number;
@@ -52,15 +53,15 @@ export interface InventoryItem {
// Item = InventoryItem with its product's catalog fields denormalized in.
// Built once from bootstrap (`enrichItems`) so views can access `name`,
// `sku`, `type`, `kind` without a per-row lookup. This is the shape the
// UI and helpers operate on.
// `sku`, `type`, `kind`, `brandId` without a per-row lookup. The display
// `name` is the strain's name. This is the shape the UI and helpers operate on.
export interface Item extends InventoryItem {
name: string;
sku: string;
type: string;
kind: ProductKind;
strainId: string | null;
strainName: string | null;
brandId: string | null;
strainId: string;
}
export interface Strain {
@@ -118,6 +119,9 @@ export const TYPES: TypeConfig[] = [
{ id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false },
];
// User-supplied 6-digit asset ids are printed on a roll of physical tags.
export const ASSET_ID_RE = /^\d{6}$/;
// Local-time YYYY-MM-DD captured once at module load. Used as the default
// value for date inputs and as the "today" anchor for days-since math.
export const TODAY_STR = (() => {
@@ -138,15 +142,15 @@ export function enrichItems(data: Bootstrap): Item[] {
for (const inv of data.inventoryItems) {
const product = productMap.get(inv.productId);
if (!product) continue;
const strain = product.strainId ? strainMap.get(product.strainId) ?? null : null;
const strain = strainMap.get(product.strainId);
out.push({
...inv,
name: product.name,
name: strain?.name ?? "(unknown strain)",
sku: product.sku,
type: product.type,
kind: product.kind,
brandId: product.brandId,
strainId: product.strainId,
strainName: strain?.name ?? null,
});
}
return out;