diff --git a/server/src/db.ts b/server/src/db.ts index 0bda155..bc03e83 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -12,15 +12,16 @@ db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); archiveLegacyIfPresent(); +archiveV1IfPresent(); const schema = readFileSync(join(__dirname, "schema.sql"), "utf8"); db.exec(schema); -// One-shot migration: the old schema put per-instance fields (weight, bin_id, -// etc.) directly on `products`. The new schema splits products (catalog) from -// inventory_items (instance). When we detect the old shape, rename the live -// tables out of the way so the new CREATE TABLE IF NOT EXISTS statements can -// build the empty new ones. Legacy tables stay queryable for reference. +// One-shot migration: the original schema put per-instance fields (weight, +// bin_id, etc.) directly on `products`. The split schema separates products +// (catalog) from inventory_items (instance). When we detect the old shape, +// rename the live tables out of the way so the new CREATE TABLE IF NOT EXISTS +// statements can build the empty new ones. Legacy tables stay queryable. function archiveLegacyIfPresent(): void { const productCols = db .prepare(`PRAGMA table_info(products)`) @@ -43,6 +44,34 @@ function archiveLegacyIfPresent(): void { `); } +// Second-pass migration: between v1 (split-products) and the current shape we +// (a) move brand from instance to product, (b) drop product.name in favor of +// strain.name as the canonical display, and (c) require user-supplied 6-digit +// asset ids. Detect by `products` having a `name` column. Archive to *_v1 +// and let the schema rebuild the empty current shape. +function archiveV1IfPresent(): void { + const productCols = db + .prepare(`PRAGMA table_info(products)`) + .all() as { name: string }[]; + const looksLikeV1 = + productCols.some((c) => c.name === "name") && + !productCols.some((c) => c.name === "weight"); + if (!looksLikeV1) return; + + const v1Exists = db + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='products_v1'`, + ) + .get(); + if (v1Exists) return; + + db.exec(` + ALTER TABLE products RENAME TO products_v1; + ALTER TABLE inventory_items RENAME TO inventory_items_v1; + ALTER TABLE audits RENAME TO audits_v1; + `); +} + const PAD: Record = { prd: 4, pdt: 4, @@ -65,26 +94,7 @@ export function nextId(prefix: string, table: string): string { return `${prefix}-${n}`; } -// Crockford base32 alphabet — drops I, L, O, U so labels are unambiguous when -// read off a sticker. 32^6 ≈ 1B addresses, so collisions on any reasonable -// inventory are vanishingly rare; we still loop just to be safe. -const ASSET_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; -export function generateAssetId(): string { - for (let attempt = 0; attempt < 16; attempt++) { - let id = ""; - for (let i = 0; i < 6; i++) { - id += ASSET_ALPHABET[Math.floor(Math.random() * ASSET_ALPHABET.length)]; - } - const taken = db - .prepare<[string], { id: string }>( - `SELECT id FROM inventory_items WHERE asset_id = ?`, - ) - .get(id); - if (!taken) return id; - } - throw new Error("could not generate a unique asset id after 16 attempts"); -} - -export function randomSku(): string { - return "SKU-" + Math.random().toString(36).slice(2, 8).toUpperCase(); -} +// Asset ids are 6-digit strings printed on a roll of physical asset tags +// the user owns. The system never generates them; it just enforces format +// and uniqueness at insert time. +export const ASSET_ID_RE = /^\d{6}$/; diff --git a/server/src/routes/bootstrap.ts b/server/src/routes/bootstrap.ts index a1f33d3..b28d84d 100644 --- a/server/src/routes/bootstrap.ts +++ b/server/src/routes/bootstrap.ts @@ -6,8 +6,8 @@ export const bootstrapRouter: Router = Router(); type ProductRow = { id: string; sku: string; - strain_id: string | null; - name: string; + strain_id: string; + brand_id: string | null; type: string; kind: string; created_at: string; @@ -17,7 +17,6 @@ type InventoryRow = { id: string; asset_id: string; product_id: string; - brand_id: string | null; shop_id: string | null; bin_id: string | null; price: number; @@ -84,7 +83,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => { id: p.id, sku: p.sku, strainId: p.strain_id, - name: p.name, + brandId: p.brand_id, type: p.type, kind: p.kind, createdAt: p.created_at, @@ -94,7 +93,6 @@ bootstrapRouter.get("/bootstrap", (_req, res) => { id: i.id, assetId: i.asset_id, productId: i.product_id, - brandId: i.brand_id, shopId: i.shop_id, binId: i.bin_id, price: i.price, diff --git a/server/src/routes/catalog.ts b/server/src/routes/catalog.ts index 7a0b339..2e1eab7 100644 --- a/server/src/routes/catalog.ts +++ b/server/src/routes/catalog.ts @@ -35,12 +35,12 @@ catalogRouter.patch("/brands/:id", (req, res) => { } }); -// Deleting a brand unparents any inventory items that reference it -// (brand_id → NULL), so users never lose inventory when reorganizing. +// Deleting a brand unparents any products that reference it +// (brand_id → NULL on products), so users never lose inventory. catalogRouter.delete("/brands/:id", (req, res) => { const { id } = req.params; const tx = db.transaction(() => { - db.prepare("UPDATE inventory_items SET brand_id = NULL WHERE brand_id = ?").run(id); + db.prepare("UPDATE products SET brand_id = NULL WHERE brand_id = ?").run(id); const result = db.prepare("DELETE FROM brands WHERE id = ?").run(id); if (result.changes === 0) throw new Error("not found"); }); diff --git a/server/src/routes/inventory.ts b/server/src/routes/inventory.ts index b29a98b..fad03fa 100644 --- a/server/src/routes/inventory.ts +++ b/server/src/routes/inventory.ts @@ -1,11 +1,11 @@ import { Router } from "express"; -import { db, generateAssetId, nextId } from "../db.js"; +import { ASSET_ID_RE, db, nextId } from "../db.js"; export const inventoryRouter: Router = Router(); type CreateBody = { + assetId: string; productId: string; - brandId?: string | null; shopId?: string | null; binId?: string | null; price: number; @@ -25,6 +25,18 @@ inventoryRouter.post("/inventory", (req, res) => { if (!Number.isFinite(body.price) || body.price < 0) { return res.status(400).json({ error: "price required" }); } + const assetId = (body.assetId ?? "").trim(); + if (!ASSET_ID_RE.test(assetId)) { + return res.status(400).json({ error: "assetId must be exactly 6 digits" }); + } + const taken = db + .prepare<[string], { id: string }>( + `SELECT id FROM inventory_items WHERE asset_id = ?`, + ) + .get(assetId); + if (taken) { + return res.status(409).json({ error: "asset id already in use", id: taken.id }); + } const product = db .prepare<[string], { id: string; kind: string }>( @@ -35,19 +47,18 @@ inventoryRouter.post("/inventory", (req, res) => { const isDiscrete = product.kind === "discrete"; const id = nextId("inv", "inventory_items"); - const assetId = generateAssetId(); db.prepare( `INSERT INTO inventory_items ( id, asset_id, product_id, - brand_id, shop_id, bin_id, + shop_id, bin_id, price, thc, cbd, total_cannabinoids, weight, last_audit_weight, count_original, count_last_audit, unit_weight, purchase_date, status ) VALUES ( @id, @assetId, @productId, - @brandId, @shopId, @binId, + @shopId, @binId, @price, @thc, @cbd, @totalCannabinoids, @weight, @lastAuditWeight, @countOriginal, @countLastAudit, @unitWeight, @@ -57,7 +68,6 @@ inventoryRouter.post("/inventory", (req, res) => { id, assetId, productId: body.productId, - brandId: body.brandId ?? null, shopId: body.shopId ?? null, binId: body.binId ?? null, price: body.price, @@ -76,7 +86,6 @@ inventoryRouter.post("/inventory", (req, res) => { }); type UpdateBody = Partial<{ - brandId: string | null; shopId: string | null; binId: string | null; price: number; @@ -95,7 +104,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => { type Row = { id: string; - brand_id: string | null; shop_id: string | null; bin_id: string | null; product_id: string; @@ -113,7 +121,7 @@ inventoryRouter.patch("/inventory/:id", (req, res) => { const existing = db .prepare<[string], Row>( - `SELECT id, brand_id, shop_id, bin_id, product_id, price, thc, cbd, + `SELECT id, shop_id, bin_id, product_id, price, thc, cbd, total_cannabinoids, weight, last_audit_weight, count_original, count_last_audit, unit_weight, purchase_date FROM inventory_items WHERE id = ?`, @@ -132,8 +140,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => { ) .get(id)!.n; - const nextBrandId = - body.brandId === undefined ? existing.brand_id : body.brandId || null; const nextShopId = body.shopId === undefined ? existing.shop_id : body.shopId || null; const nextBinId = @@ -174,7 +180,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => { db.prepare( `UPDATE inventory_items SET - brand_id = @brandId, shop_id = @shopId, bin_id = @binId, price = @price, @@ -190,7 +195,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => { WHERE id = @id`, ).run({ id, - brandId: nextBrandId, shopId: nextShopId, binId: nextBinId, price: nextPrice, diff --git a/server/src/routes/products.ts b/server/src/routes/products.ts index 05d70e1..72730d9 100644 --- a/server/src/routes/products.ts +++ b/server/src/routes/products.ts @@ -5,11 +5,14 @@ export const productsRouter: Router = Router(); type CreateBody = { sku: string; - name: string; - type: string; - kind: "bulk" | "discrete"; + // strain identity: either link to an existing strain via id, or supply a + // name and the server creates / matches one. Strain.name doubles as the + // product's display name. strainId?: string | null; strainName?: string; + brandId?: string | null; + type: string; + kind: "bulk" | "discrete"; defaultThc?: number; defaultCbd?: number; defaultTotalCannabinoids?: number; @@ -18,14 +21,15 @@ type CreateBody = { productsRouter.post("/products", (req, res) => { const body = req.body as CreateBody; if (!body.sku?.trim()) return res.status(400).json({ error: "sku required" }); - if (!body.name?.trim()) return res.status(400).json({ error: "name required" }); if (!body.type) return res.status(400).json({ error: "type required" }); if (body.kind !== "bulk" && body.kind !== "discrete") { return res.status(400).json({ error: "kind must be bulk or discrete" }); } + if (!body.strainId && !body.strainName?.trim()) { + return res.status(400).json({ error: "strainId or strainName required" }); + } const sku = body.sku.trim(); - const name = body.name.trim(); const todayIso = new Date().toISOString().slice(0, 10); const existingSku = db @@ -40,9 +44,9 @@ productsRouter.post("/products", (req, res) => { try { const tx = db.transaction(() => { let strainId = body.strainId ?? null; - const strainName = body.strainName?.trim() || name; - if (!strainId && strainName) { + if (!strainId) { + const strainName = body.strainName!.trim(); const found = db .prepare<[string], { id: string }>( `SELECT id FROM strains WHERE name = ? COLLATE NOCASE`, @@ -70,9 +74,9 @@ productsRouter.post("/products", (req, res) => { } db.prepare( - `INSERT INTO products (id, sku, strain_id, name, type, kind, created_at) + `INSERT INTO products (id, sku, strain_id, brand_id, type, kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).run(id, sku, strainId, name, body.type, body.kind, todayIso); + ).run(id, sku, strainId, body.brandId ?? null, body.type, body.kind, todayIso); }); tx(); } catch (err) { @@ -87,10 +91,11 @@ productsRouter.post("/products", (req, res) => { }); type UpdateBody = Partial<{ - name: string; type: string; kind: "bulk" | "discrete"; strainId: string | null; + strainName: string; + brandId: string | null; }>; productsRouter.patch("/products/:id", (req, res) => { @@ -100,24 +105,47 @@ productsRouter.patch("/products/:id", (req, res) => { const existing = db .prepare< [string], - { id: string; name: string; type: string; kind: string; strain_id: string | null } + { id: string; type: string; kind: string; strain_id: string; brand_id: string | null } >( - `SELECT id, name, type, kind, strain_id FROM products WHERE id = ?`, + `SELECT id, type, kind, strain_id, brand_id FROM products WHERE id = ?`, ) .get(id); if (!existing) return res.status(404).json({ error: "product not found" }); - const nextName = - typeof body.name === "string" && body.name.trim() ? body.name.trim() : existing.name; const nextType = typeof body.type === "string" && body.type ? body.type : existing.type; const nextKind: "bulk" | "discrete" = - body.kind === "bulk" || body.kind === "discrete" ? body.kind : (existing.kind as "bulk" | "discrete"); - const nextStrainId = - body.strainId === undefined ? existing.strain_id : body.strainId; + body.kind === "bulk" || body.kind === "discrete" + ? body.kind + : (existing.kind as "bulk" | "discrete"); + const nextBrandId = + body.brandId === undefined ? existing.brand_id : body.brandId; + + // Strain handling: prefer explicit strainId; else if strainName given, + // match-by-name (case-insensitive) or create a new strain. + let nextStrainId = existing.strain_id; + const todayIso = new Date().toISOString().slice(0, 10); + if (body.strainId !== undefined && body.strainId) { + nextStrainId = body.strainId; + } else if (typeof body.strainName === "string" && body.strainName.trim()) { + const name = body.strainName.trim(); + const found = db + .prepare<[string], { id: string }>( + `SELECT id FROM strains WHERE name = ? COLLATE NOCASE`, + ) + .get(name); + if (found) { + nextStrainId = found.id; + } else { + nextStrainId = nextId("str", "strains"); + db.prepare( + `INSERT INTO strains (id, name, created_at) VALUES (?, ?, ?)`, + ).run(nextStrainId, name, todayIso); + } + } db.prepare( - `UPDATE products SET name = ?, type = ?, kind = ?, strain_id = ? WHERE id = ?`, - ).run(nextName, nextType, nextKind, nextStrainId, id); + `UPDATE products SET type = ?, kind = ?, strain_id = ?, brand_id = ? WHERE id = ?`, + ).run(nextType, nextKind, nextStrainId, nextBrandId, id); res.json({ ok: true }); }); diff --git a/server/src/schema.sql b/server/src/schema.sql index 240c2db..b56f9a7 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -30,30 +30,33 @@ CREATE TABLE IF NOT EXISTS strains ( created_at TEXT NOT NULL ); --- Products: catalog entries. One row per (sku, strain) combination — --- a "kind of thing you can scan". Type/kind describe the physical form --- (e.g. Flower/bulk, Pre-roll/discrete) since the same strain can ship in --- different forms with different SKUs. +-- Products: catalog entries. One row per SKU. The strain (required) +-- supplies the display name — there is no separate product name field. +-- Brand sits here too: a SKU is brand-specific (UPC barcodes belong to +-- a single brand), so all instances of a product share the brand. +-- Type/kind describe the physical form (e.g. Flower/bulk, Pre-roll/discrete); +-- the same strain can ship in different forms with different SKUs. CREATE TABLE IF NOT EXISTS products ( id TEXT PRIMARY KEY, sku TEXT NOT NULL UNIQUE, - strain_id TEXT REFERENCES strains(id), - name TEXT NOT NULL, + strain_id TEXT NOT NULL REFERENCES strains(id), + brand_id TEXT REFERENCES brands(id), type TEXT NOT NULL, kind TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_products_strain ON products(strain_id); +CREATE INDEX IF NOT EXISTS idx_products_brand ON products(brand_id); -- Inventory items: physical things you bought. One row per acquired --- jar/pack/cart, with its own short-UUID asset id (label-printed). --- Per-batch values (price, THC, weight or count, brand, shop, bin) --- live here, since they vary between purchases of the same product. +-- jar/pack/cart, with a user-supplied 6-digit asset id (the label number +-- on a roll of pre-printed asset tags). Per-batch values (price, THC, +-- weight/count, shop, bin) live here, since they vary between purchases +-- of the same product. Brand is on the product, not here. CREATE TABLE IF NOT EXISTS inventory_items ( id TEXT PRIMARY KEY, asset_id TEXT NOT NULL UNIQUE, product_id TEXT NOT NULL REFERENCES products(id), - brand_id TEXT REFERENCES brands(id), shop_id TEXT REFERENCES shops(id), bin_id TEXT REFERENCES bins(id), price REAL NOT NULL, diff --git a/web/src/api.ts b/web/src/api.ts index e035aed..4f4cc10 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -18,11 +18,11 @@ export const api = { // Catalog: products createProduct: (body: { sku: string; - name: string; type: string; kind: "bulk" | "discrete"; strainId?: string | null; strainName?: string; + brandId?: string | null; defaultThc?: number; defaultCbd?: number; defaultTotalCannabinoids?: number; @@ -31,10 +31,11 @@ export const api = { updateProduct: ( id: string, body: Partial<{ - name: string; type: string; kind: "bulk" | "discrete"; strainId: string | null; + strainName: string; + brandId: string | null; }>, ) => request<{ ok: true }>(`/products/${id}`, { @@ -62,8 +63,8 @@ export const api = { // Inventory items (instances) createInventoryItem: (body: { + assetId: string; productId: string; - brandId?: string | null; shopId?: string | null; binId?: string | null; price: number; @@ -83,7 +84,6 @@ export const api = { updateInventoryItem: ( id: string, body: Partial<{ - brandId: string | null; shopId: string | null; binId: string | null; price: number; diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index 9698c04..6de3081 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -46,7 +46,7 @@ export function ProductDetail({ ["Asset id", {item.assetId}], ["SKU", {item.sku}], ["Type", `${item.type} · ${item.kind}`], - ["Strain", item.strainName ?? Unlinked], + ["Strain", item.name], ["Brand", helpers.brandName(data, item.brandId)], ["Shop", helpers.shopName(data, item.shopId)], ["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`], diff --git a/web/src/components/ScanField.tsx b/web/src/components/ScanField.tsx index 2eabd39..a9ea2f7 100644 --- a/web/src/components/ScanField.tsx +++ b/web/src/components/ScanField.tsx @@ -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); diff --git a/web/src/components/modals/AddInventoryFlow.tsx b/web/src/components/modals/AddInventoryFlow.tsx index be6e8c6..bce5d51 100644 --- a/web/src/components/modals/AddInventoryFlow.tsx +++ b/web/src/components/modals/AddInventoryFlow.tsx @@ -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 && ( { 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(""); // empty = match-by-name / create + const [newBrandId, setNewBrandId] = useState( + data.brands[0]?.id ?? NEW_BRAND, + ); + const [newBrandName, setNewBrandName] = useState(""); const [error, setError] = useState(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 ( <>
@@ -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) => ( - - ))} + {data.products.map((p) => { + const strainName = + data.strains.find((s) => s.id === p.strainId)?.name ?? "?"; + return ( + + ); + })}
@@ -227,6 +261,7 @@ function SelectProductStep({ value={newSku} placeholder="SKU-XXXXXX" onChange={(e) => setNewSku(e.target.value)} + autoFocus={!newSku} /> @@ -238,41 +273,43 @@ function SelectProductStep({ ))} - - setNewName(e.target.value)} - /> - - setNewStrain(e.target.value)} - disabled={!!newStrainId} + value={newName} + placeholder="e.g. Garden Ghost" + onChange={(e) => setNewName(e.target.value)} /> + + + + {isNewBrand && ( + + setNewBrandName(e.target.value)} + placeholder="e.g. Foxglove Farms" + /> + + )} {error && (
{error}
@@ -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({ }} > - {product.name} · {product.type} ·{" "} + {productName} · {brandName} · {product.type} ·{" "} {product.sku} {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."}
+ Asset tag +
+ + setAssetId(e.target.value.replace(/\D/g, "").slice(0, 6))} + style={{ + fontFamily: "var(--mono)", + letterSpacing: "0.1em", + borderColor: assetIdConflict ? "var(--terracotta)" : undefined, + }} + /> + + +
Source
- - - - {isNewBrand && ( - - setNewBrand(e.target.value)} - placeholder="e.g. Foxglove Farms" - /> - - )} + + + {isNewShop && ( <> @@ -516,16 +572,6 @@ function InstanceDetailsStep({ )} - - - {isNewBin && ( <> @@ -672,7 +718,7 @@ function InstanceDetailsStep({ save.mutate()} > {save.isPending ? "Saving…" : "Save inventory item"} @@ -712,9 +758,9 @@ function DonePane({ {assetId}
- Label this {productName} with{" "} + Stick label{" "} {assetId}{" "} - so you can scan it later. + on the {productName}.
diff --git a/web/src/components/modals/EditInventoryFlow.tsx b/web/src/components/modals/EditInventoryFlow.tsx index 6a15425..7b2e684 100644 --- a/web/src/components/modals/EditInventoryFlow.tsx +++ b/web/src/components/modals/EditInventoryFlow.tsx @@ -7,7 +7,6 @@ import { api } from "../../api.js"; import { Btn, Field, Input, Select } from "../primitives/index.js"; import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; -const NEW_BRAND = "__new_brand__"; const NEW_SHOP = "__new_shop__"; const NEW_BIN = "__new_bin__"; @@ -30,7 +29,6 @@ export function EditInventoryFlow({ : item.price; const [form, setForm] = useState({ - brandId: item.brandId ?? NEW_BRAND, shopId: item.shopId ?? NEW_SHOP, binId: item.binId ?? NEW_BIN, weight: item.weight, @@ -42,7 +40,6 @@ export function EditInventoryFlow({ totalCannabinoids: item.totalCannabinoids, purchaseDate: item.purchaseDate, }); - const [newBrand, setNewBrand] = useState(""); const [newShopName, setNewShopName] = useState(""); const [newShopLocation, setNewShopLocation] = useState(""); const [newBinName, setNewBinName] = useState(""); @@ -58,12 +55,7 @@ export function EditInventoryFlow({ 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; - } + let { shopId, binId } = form; if (shopId === NEW_SHOP) { if (!newShopName.trim()) throw new Error("New shop name required"); const s = await api.createShop({ @@ -81,7 +73,6 @@ export function EditInventoryFlow({ binId = b.id; } return api.updateInventoryItem(item.id, { - brandId, shopId, binId, weight: isDiscrete ? undefined : form.weight, @@ -101,7 +92,6 @@ export function EditInventoryFlow({ onError: (e: Error) => setError(e.message), }); - const isNewBrand = form.brandId === NEW_BRAND; const isNewShop = form.shopId === NEW_SHOP; const isNewBin = form.binId === NEW_BIN; @@ -159,16 +149,6 @@ export function EditInventoryFlow({ marginBottom: 28, }} > - - - - {isNewBrand && ( - - setNewBrand(e.target.value)} - placeholder="e.g. Foxglove Farms" - /> - - )} + + + {isNewShop && ( <> @@ -206,16 +187,6 @@ export function EditInventoryFlow({ )} - - - {isNewBin && ( <> diff --git a/web/src/components/modals/EditProductFlow.tsx b/web/src/components/modals/EditProductFlow.tsx index e0b0ced..f9ea6be 100644 --- a/web/src/components/modals/EditProductFlow.tsx +++ b/web/src/components/modals/EditProductFlow.tsx @@ -1,15 +1,17 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Bootstrap, Product } from "../../types.js"; +import type { Bootstrap, Product, Strain } from "../../types.js"; import { TYPES } from "../../types.js"; import { TYPE_GLYPHS } from "../../format.js"; import { api } from "../../api.js"; import { Btn, Field, Input, Select } from "../primitives/index.js"; import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; -// Catalog-level edit. SKU is immutable (it's a barcode). Type and kind are -// editable but should rarely change after first instances exist — we don't -// block since data integrity isn't really at risk (kind only flips behavior). +const NEW_BRAND = "__new_brand__"; + +// Catalog-level edit. SKU is immutable (it's a barcode). Type can change but +// rarely should. Name is the strain name — typing it either matches an +// existing strain or creates a new one. Brand can be reassigned at any time. export function EditProductFlow({ data, product, @@ -21,21 +23,45 @@ export function EditProductFlow({ }) { const qc = useQueryClient(); - const [name, setName] = useState(product.name); + const initialStrain = data.strains.find((s) => s.id === product.strainId); + + const [name, setName] = useState(initialStrain?.name ?? ""); const [type, setType] = useState(product.type); - const [strainId, setStrainId] = useState(product.strainId ?? ""); + const [brandId, setBrandId] = useState(product.brandId ?? NEW_BRAND); + const [newBrandName, setNewBrandName] = useState(""); const [error, setError] = useState(null); const cfg = TYPES.find((t) => t.id === type); + const matchedStrain: Strain | null = useMemo(() => { + const q = name.trim().toLowerCase(); + if (!q) return null; + return data.strains.find((s) => s.name.trim().toLowerCase() === q) ?? null; + }, [name, data.strains]); + const save = useMutation({ - mutationFn: () => - api.updateProduct(product.id, { - name: name.trim(), + mutationFn: async () => { + if (!name.trim()) throw new Error("Name (strain) required"); + let nextBrandId: string | null = null; + if (brandId === NEW_BRAND) { + if (newBrandName.trim()) { + const b = await api.createBrand(newBrandName.trim()); + nextBrandId = b.id; + } else { + nextBrandId = null; + } + } else { + nextBrandId = brandId || null; + } + return api.updateProduct(product.id, { type, kind: cfg?.kind ?? product.kind, - strainId: strainId || null, - }), + // Server resolves matched-by-name to existing strain, else creates one + strainId: matchedStrain?.id, + strainName: matchedStrain ? undefined : name.trim(), + brandId: nextBrandId, + }); + }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); onClose(); @@ -43,6 +69,8 @@ export function EditProductFlow({ onError: (e: Error) => setError(e.message), }); + const isNewBrand = brandId === NEW_BRAND; + return (
SKU {product.sku}{" "} - is locked. Edit individual purchases (price, brand, batch THC) from the + is locked. Edit individual purchases (price, batch THC) from the inventory drawer.
- + setName(e.target.value)} - placeholder="e.g. Garden Ghost 3.5g" + placeholder="e.g. Garden Ghost" /> @@ -103,16 +139,25 @@ export function EditProductFlow({ ))} - - setBrandId(e.target.value)}> + {data.brands.map((b) => ( + ))} + + {isNewBrand && ( + + setNewBrandName(e.target.value)} + placeholder="e.g. Foxglove Farms" + /> + + )}
{error && ( diff --git a/web/src/types.ts b/web/src/types.ts index 559b8c1..58f580d 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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; diff --git a/web/src/views/BrandsView.tsx b/web/src/views/BrandsView.tsx index 27eb26c..e092f46 100644 --- a/web/src/views/BrandsView.tsx +++ b/web/src/views/BrandsView.tsx @@ -68,7 +68,13 @@ export function BrandsView({ }} > {data.brands.map((b) => { - const itemCount = data.inventoryItems.filter((i) => i.brandId === b.id).length; + // Count instances whose product points at this brand. + const productIds = new Set( + data.products.filter((p) => p.brandId === b.id).map((p) => p.id), + ); + const itemCount = data.inventoryItems.filter((i) => + productIds.has(i.productId), + ).length; return (
diff --git a/web/src/views/Inventory.tsx b/web/src/views/Inventory.tsx index 9527744..2ad1851 100644 --- a/web/src/views/Inventory.tsx +++ b/web/src/views/Inventory.tsx @@ -63,8 +63,7 @@ export function Inventory({ brand.includes(q) || shop.includes(q) || i.sku.toLowerCase().includes(q) || - i.assetId.toLowerCase().includes(q) || - (i.strainName?.toLowerCase().includes(q) ?? false) + i.assetId.toLowerCase().includes(q) ); }); }