From 02dc6e523f474c9cda7cb85328624403c01bc315 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 4 May 2026 05:59:46 -0400 Subject: [PATCH] Track inventory at the instance level, not by product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- server/src/db.ts | 66 +- server/src/index.ts | 2 + server/src/routes/bootstrap.ts | 92 ++- server/src/routes/catalog.ts | 17 +- server/src/routes/inventory.ts | 313 ++++++++ server/src/routes/products.ts | 462 ++++------- server/src/schema.sql | 99 ++- web/src/App.tsx | 66 +- web/src/api.ts | 113 ++- web/src/components/ProductDetail.tsx | 183 +++-- web/src/components/ScanField.tsx | 73 +- .../components/modals/AddInventoryFlow.tsx | 730 ++++++++++++++++++ web/src/components/modals/AuditFlow.tsx | 98 ++- web/src/components/modals/CatalogModals.tsx | 2 +- web/src/components/modals/ConsumeFlow.tsx | 59 +- ...dProductFlow.tsx => EditInventoryFlow.tsx} | 362 ++++----- web/src/components/modals/EditProductFlow.tsx | 317 ++------ web/src/components/modals/MarkGoneFlow.tsx | 31 +- web/src/components/modals/ModalChrome.tsx | 84 ++ web/src/stats.ts | 66 +- web/src/types.ts | 98 ++- web/src/views/BinsView.tsx | 54 +- web/src/views/BrandsView.tsx | 16 +- web/src/views/ChartsView.tsx | 12 +- web/src/views/Dashboard.tsx | 44 +- web/src/views/Inventory.tsx | 203 +++-- web/src/views/SettingsView.tsx | 6 +- web/src/views/ShopsView.tsx | 2 +- 28 files changed, 2315 insertions(+), 1355 deletions(-) create mode 100644 server/src/routes/inventory.ts create mode 100644 web/src/components/modals/AddInventoryFlow.tsx rename web/src/components/modals/{AddProductFlow.tsx => EditInventoryFlow.tsx} (52%) create mode 100644 web/src/components/modals/ModalChrome.tsx diff --git a/server/src/db.ts b/server/src/db.ts index cbe4a58..0bda155 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -11,18 +11,48 @@ export const db = new Database(DB_PATH); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); +archiveLegacyIfPresent(); + const schema = readFileSync(join(__dirname, "schema.sql"), "utf8"); db.exec(schema); -// Add strain_id to products table if missing — older DBs predate the column. -const productCols = db - .prepare(`PRAGMA table_info(products)`) - .all() as { name: string }[]; -if (!productCols.some((c) => c.name === "strain_id")) { - db.exec(`ALTER TABLE products ADD COLUMN strain_id TEXT REFERENCES strains(id);`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_products_strain ON products(strain_id);`); +// 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. +function archiveLegacyIfPresent(): void { + const productCols = db + .prepare(`PRAGMA table_info(products)`) + .all() as { name: string }[]; + const looksLikeOldSchema = + productCols.length > 0 && productCols.some((c) => c.name === "weight"); + if (!looksLikeOldSchema) return; + + const legacyExists = db + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='products_legacy'`, + ) + .get(); + if (legacyExists) return; + + db.exec(` + ALTER TABLE products RENAME TO products_legacy; + ALTER TABLE audits RENAME TO audits_legacy; + ALTER TABLE strains RENAME TO strains_legacy; + `); } +const PAD: Record = { + prd: 4, + pdt: 4, + inv: 4, + str: 4, + brd: 2, + shp: 2, + bin: 2, +}; + export function nextId(prefix: string, table: string): string { const row = db .prepare<[string], { id: string }>( @@ -30,11 +60,31 @@ export function nextId(prefix: string, table: string): string { ) .get(`${prefix}-%`); const last = row ? Number(row.id.slice(prefix.length + 1)) : 0; - const pad = prefix === "prd" || prefix === "str" ? 4 : 2; + const pad = PAD[prefix] ?? 2; const n = String(last + 1).padStart(pad, "0"); 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(); } diff --git a/server/src/index.ts b/server/src/index.ts index e2cb42c..3e2d735 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ import express from "express"; import { seedIfEmpty } from "./seed.js"; import { bootstrapRouter } from "./routes/bootstrap.js"; import { productsRouter } from "./routes/products.js"; +import { inventoryRouter } from "./routes/inventory.js"; import { catalogRouter } from "./routes/catalog.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -18,6 +19,7 @@ app.use(express.json({ limit: "1mb" })); app.use("/api", bootstrapRouter); app.use("/api", productsRouter); +app.use("/api", inventoryRouter); app.use("/api", catalogRouter); app.use(express.static(PUBLIC_DIR)); diff --git a/server/src/routes/bootstrap.ts b/server/src/routes/bootstrap.ts index ce0b963..a1f33d3 100644 --- a/server/src/routes/bootstrap.ts +++ b/server/src/routes/bootstrap.ts @@ -6,36 +6,40 @@ export const bootstrapRouter: Router = Router(); type ProductRow = { id: string; sku: string; - asset_tag: string | null; + strain_id: string | null; name: string; + type: string; + kind: string; + created_at: string; +}; + +type InventoryRow = { + id: string; + asset_id: string; + product_id: string; brand_id: string | null; shop_id: string | null; bin_id: string | null; - type: string; - kind: string; + price: number; + thc: number; + cbd: number; + total_cannabinoids: number; weight: number; last_audit_weight: number | null; count_original: number; count_last_audit: number | null; unit_weight: number; - price: number; - thc: number; - cbd: number; - total_cannabinoids: number; purchase_date: string; status: string; consumed_date: string | null; gone_date: string | null; rating: number | null; notes: string | null; - strain_id: string | null; }; type StrainRow = { id: string; name: string; - brand_id: string | null; - type: string; default_thc: number | null; default_cbd: number | null; default_total_cannabinoids: number | null; @@ -44,7 +48,7 @@ type StrainRow = { type AuditRow = { id: number; - product_id: string; + inventory_id: string; date: string; mode: string; value: number; @@ -53,9 +57,14 @@ type AuditRow = { }; bootstrapRouter.get("/bootstrap", (_req, res) => { - const products = db.prepare<[], ProductRow>("SELECT * FROM products ORDER BY id").all(); + const products = db + .prepare<[], ProductRow>("SELECT * FROM products ORDER BY id") + .all(); + const inventory = db + .prepare<[], InventoryRow>("SELECT * FROM inventory_items ORDER BY id") + .all(); const audits = db - .prepare<[], AuditRow>("SELECT * FROM audits ORDER BY product_id, date") + .prepare<[], AuditRow>("SELECT * FROM audits ORDER BY inventory_id, date") .all(); const shops = db.prepare("SELECT * FROM shops ORDER BY id").all(); const brands = db.prepare("SELECT * FROM brands ORDER BY id").all(); @@ -64,40 +73,46 @@ bootstrapRouter.get("/bootstrap", (_req, res) => { .prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE") .all(); - const auditsByProduct = new Map(); + const auditsByInventory = new Map(); for (const a of audits) { - const arr = auditsByProduct.get(a.product_id) ?? []; + const arr = auditsByInventory.get(a.inventory_id) ?? []; arr.push(a); - auditsByProduct.set(a.product_id, arr); + auditsByInventory.set(a.inventory_id, arr); } const productsOut = products.map((p) => ({ id: p.id, sku: p.sku, - assetTag: p.asset_tag, + strainId: p.strain_id, name: p.name, - brandId: p.brand_id, - shopId: p.shop_id, - binId: p.bin_id, type: p.type, kind: p.kind, - weight: p.weight, - lastAuditWeight: p.last_audit_weight, - countOriginal: p.count_original, - countLastAudit: p.count_last_audit, - unitWeight: p.unit_weight, - price: p.price, - thc: p.thc, - cbd: p.cbd, - totalCannabinoids: p.total_cannabinoids, - purchaseDate: p.purchase_date, - status: p.status, - consumedDate: p.consumed_date, - goneDate: p.gone_date, - rating: p.rating, - notes: p.notes, - strainId: p.strain_id, - audits: (auditsByProduct.get(p.id) ?? []).map((a) => ({ + createdAt: p.created_at, + })); + + const inventoryOut = inventory.map((i) => ({ + 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, + thc: i.thc, + cbd: i.cbd, + totalCannabinoids: i.total_cannabinoids, + weight: i.weight, + lastAuditWeight: i.last_audit_weight, + countOriginal: i.count_original, + countLastAudit: i.count_last_audit, + unitWeight: i.unit_weight, + purchaseDate: i.purchase_date, + status: i.status, + consumedDate: i.consumed_date, + goneDate: i.gone_date, + rating: i.rating, + notes: i.notes, + audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({ date: a.date, mode: a.mode, value: a.value, @@ -109,8 +124,6 @@ bootstrapRouter.get("/bootstrap", (_req, res) => { const strainsOut = strains.map((s) => ({ id: s.id, name: s.name, - brandId: s.brand_id, - type: s.type, defaultThc: s.default_thc, defaultCbd: s.default_cbd, defaultTotalCannabinoids: s.default_total_cannabinoids, @@ -119,6 +132,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => { res.json({ products: productsOut, + inventoryItems: inventoryOut, shops, brands, bins, diff --git a/server/src/routes/catalog.ts b/server/src/routes/catalog.ts index b2173b2..7a0b339 100644 --- a/server/src/routes/catalog.ts +++ b/server/src/routes/catalog.ts @@ -35,13 +35,12 @@ catalogRouter.patch("/brands/:id", (req, res) => { } }); -// Deleting a brand unparents any products and strains that reference it -// (brand_id → NULL), so users never lose products when reorganizing. +// Deleting a brand unparents any inventory items that reference it +// (brand_id → NULL), so users never lose inventory when reorganizing. catalogRouter.delete("/brands/:id", (req, res) => { const { id } = req.params; const tx = db.transaction(() => { - db.prepare("UPDATE products SET brand_id = NULL WHERE brand_id = ?").run(id); - db.prepare("UPDATE strains SET brand_id = NULL WHERE brand_id = ?").run(id); + db.prepare("UPDATE inventory_items 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"); }); @@ -87,11 +86,11 @@ catalogRouter.patch("/shops/:id", (req, res) => { res.json({ id, name: nextName, location: nextLocation }); }); -// Deleting a shop unparents any products that reference it (shop_id → NULL). +// Deleting a shop unparents any inventory items that reference it (shop_id → NULL). catalogRouter.delete("/shops/:id", (req, res) => { const { id } = req.params; const tx = db.transaction(() => { - db.prepare("UPDATE products SET shop_id = NULL WHERE shop_id = ?").run(id); + db.prepare("UPDATE inventory_items SET shop_id = NULL WHERE shop_id = ?").run(id); const result = db.prepare("DELETE FROM shops WHERE id = ?").run(id); if (result.changes === 0) throw new Error("not found"); }); @@ -132,12 +131,12 @@ catalogRouter.patch("/bins/:id", (req, res) => { res.json({ id, name: nextName, capacity: nextCapacity }); }); -// Deleting a bin unassigns any products that reference it (bin_id → NULL), -// so users never lose products when reorganizing storage. +// Deleting a bin unassigns any inventory items that reference it (bin_id → NULL), +// so users never lose inventory when reorganizing storage. catalogRouter.delete("/bins/:id", (req, res) => { const { id } = req.params; const tx = db.transaction(() => { - db.prepare("UPDATE products SET bin_id = NULL WHERE bin_id = ?").run(id); + db.prepare("UPDATE inventory_items SET bin_id = NULL WHERE bin_id = ?").run(id); const result = db.prepare("DELETE FROM bins 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 new file mode 100644 index 0000000..b29a98b --- /dev/null +++ b/server/src/routes/inventory.ts @@ -0,0 +1,313 @@ +import { Router } from "express"; +import { db, generateAssetId, nextId } from "../db.js"; + +export const inventoryRouter: Router = Router(); + +type CreateBody = { + productId: string; + brandId?: string | null; + shopId?: string | null; + binId?: string | null; + price: number; + thc?: number; + cbd?: number; + totalCannabinoids?: number; + weight?: number; + countOriginal?: number; + unitWeight?: number; + purchaseDate: string; +}; + +inventoryRouter.post("/inventory", (req, res) => { + const body = req.body as CreateBody; + if (!body.productId) return res.status(400).json({ error: "productId required" }); + if (!body.purchaseDate) return res.status(400).json({ error: "purchaseDate required" }); + if (!Number.isFinite(body.price) || body.price < 0) { + return res.status(400).json({ error: "price required" }); + } + + const product = db + .prepare<[string], { id: string; kind: string }>( + `SELECT id, kind FROM products WHERE id = ?`, + ) + .get(body.productId); + if (!product) return res.status(404).json({ error: "product not found" }); + + 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, + 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, + @price, @thc, @cbd, @totalCannabinoids, + @weight, @lastAuditWeight, + @countOriginal, @countLastAudit, @unitWeight, + @purchaseDate, 'active' + )`, + ).run({ + id, + assetId, + productId: body.productId, + brandId: body.brandId ?? null, + shopId: body.shopId ?? null, + binId: body.binId ?? null, + price: body.price, + thc: body.thc ?? 0, + cbd: body.cbd ?? 0, + totalCannabinoids: body.totalCannabinoids ?? 0, + weight: isDiscrete ? 0 : body.weight ?? 0, + lastAuditWeight: isDiscrete ? null : body.weight ?? 0, + countOriginal: isDiscrete ? body.countOriginal ?? 0 : 0, + countLastAudit: isDiscrete ? body.countOriginal ?? 0 : null, + unitWeight: isDiscrete ? body.unitWeight ?? 0 : 0, + purchaseDate: body.purchaseDate, + }); + + res.json({ id, assetId }); +}); + +type UpdateBody = Partial<{ + brandId: string | null; + shopId: string | null; + binId: string | null; + price: number; + thc: number; + cbd: number; + totalCannabinoids: number; + weight: number; + countOriginal: number; + unitWeight: number; + purchaseDate: string; +}>; + +inventoryRouter.patch("/inventory/:id", (req, res) => { + const { id } = req.params; + const body = req.body as UpdateBody; + + type Row = { + id: string; + brand_id: string | null; + shop_id: string | null; + bin_id: string | null; + product_id: string; + price: number; + thc: number; + cbd: number; + total_cannabinoids: number; + weight: number; + last_audit_weight: number | null; + count_original: number; + count_last_audit: number | null; + unit_weight: number; + purchase_date: string; + }; + + const existing = db + .prepare<[string], Row>( + `SELECT id, brand_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 = ?`, + ) + .get(id); + if (!existing) return res.status(404).json({ error: "inventory item not found" }); + + const product = db + .prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`) + .get(existing.product_id); + const isDiscrete = product?.kind === "discrete"; + + const auditCount = db + .prepare<[string], { n: number }>( + `SELECT COUNT(*) AS n FROM audits WHERE inventory_id = ?`, + ) + .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 = + body.binId === undefined ? existing.bin_id : body.binId || null; + const nextPrice = + Number.isFinite(body.price) && (body.price as number) >= 0 + ? (body.price as number) + : existing.price; + const nextPurchaseDate = + typeof body.purchaseDate === "string" && body.purchaseDate.trim() + ? body.purchaseDate.trim() + : existing.purchase_date; + const nextThc = Number.isFinite(body.thc) ? (body.thc as number) : existing.thc; + const nextCbd = Number.isFinite(body.cbd) ? (body.cbd as number) : existing.cbd; + const nextTotalCanna = Number.isFinite(body.totalCannabinoids) + ? (body.totalCannabinoids as number) + : existing.total_cannabinoids; + + const nextWeight = + !isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0 + ? (body.weight as number) + : existing.weight; + const nextCountOriginal = + isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0 + ? Math.floor(body.countOriginal as number) + : existing.count_original; + const nextUnitWeight = + isDiscrete && Number.isFinite(body.unitWeight) && (body.unitWeight as number) >= 0 + ? (body.unitWeight as number) + : existing.unit_weight; + + // Mirror the original size into the "last audit" field while no audits + // exist — keeps the next audit's prev_value accurate after an edit. + const nextLastAuditWeight = + !isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight; + const nextCountLastAudit = + isDiscrete && auditCount === 0 ? nextCountOriginal : existing.count_last_audit; + + db.prepare( + `UPDATE inventory_items SET + brand_id = @brandId, + shop_id = @shopId, + bin_id = @binId, + price = @price, + thc = @thc, + cbd = @cbd, + total_cannabinoids = @totalCannabinoids, + weight = @weight, + last_audit_weight = @lastAuditWeight, + count_original = @countOriginal, + count_last_audit = @countLastAudit, + unit_weight = @unitWeight, + purchase_date = @purchaseDate + WHERE id = @id`, + ).run({ + id, + brandId: nextBrandId, + shopId: nextShopId, + binId: nextBinId, + price: nextPrice, + thc: nextThc, + cbd: nextCbd, + totalCannabinoids: nextTotalCanna, + weight: nextWeight, + lastAuditWeight: nextLastAuditWeight, + countOriginal: nextCountOriginal, + countLastAudit: nextCountLastAudit, + unitWeight: nextUnitWeight, + purchaseDate: nextPurchaseDate, + }); + + res.json({ ok: true }); +}); + +inventoryRouter.post("/inventory/:id/finish", (req, res) => { + const { id } = req.params; + const { date, rating, notes } = req.body as { + date: string; + rating?: number; + notes?: string; + }; + const result = db + .prepare( + `UPDATE inventory_items + SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL + WHERE id = ? AND status = 'active'`, + ) + .run(date, rating ?? null, notes ?? null, id); + if (result.changes === 0) return res.status(404).json({ error: "not found or not active" }); + res.json({ ok: true }); +}); + +inventoryRouter.post("/inventory/:id/gone", (req, res) => { + const { id } = req.params; + const { date, reason, notes } = req.body as { + date: string; + reason: string; + notes?: string; + }; + const combinedNotes = notes ? `${reason}: ${notes}` : reason; + const tx = db.transaction(() => { + const result = db + .prepare( + `UPDATE inventory_items + SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL + WHERE id = ? AND status = 'active'`, + ) + .run(date, combinedNotes, id); + if (result.changes === 0) throw new Error("not found"); + db.prepare( + `INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by) + VALUES (?, ?, 'presence', 0, NULL, 'lost')`, + ).run(id, date); + }); + try { + tx(); + res.json({ ok: true }); + } catch { + res.status(404).json({ error: "not found or not active" }); + } +}); + +inventoryRouter.post("/inventory/:id/audit", (req, res) => { + const { id } = req.params; + const { date, mode, value, confirmedBy } = req.body as { + date: string; + mode: "weigh" | "estimate" | "presence"; + value: number; + confirmedBy?: string; + }; + + const item = db + .prepare< + [string], + { + product_id: string; + weight: number; + last_audit_weight: number | null; + count_original: number; + count_last_audit: number | null; + } + >( + `SELECT product_id, weight, last_audit_weight, count_original, count_last_audit + FROM inventory_items WHERE id = ?`, + ) + .get(id); + if (!item) return res.status(404).json({ error: "not found" }); + + const product = db + .prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`) + .get(item.product_id); + const isDiscrete = product?.kind === "discrete"; + + const prev = isDiscrete + ? item.count_last_audit ?? item.count_original + : item.last_audit_weight ?? item.weight; + + const tx = db.transaction(() => { + db.prepare( + `INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, date, mode, value, prev, confirmedBy ?? null); + if (isDiscrete) { + db.prepare(`UPDATE inventory_items SET count_last_audit = ? WHERE id = ?`).run( + value, + id, + ); + } else { + db.prepare(`UPDATE inventory_items SET last_audit_weight = ? WHERE id = ?`).run( + value, + id, + ); + } + }); + tx(); + res.json({ ok: true }); +}); diff --git a/server/src/routes/products.ts b/server/src/routes/products.ts index f3b4d91..05d70e1 100644 --- a/server/src/routes/products.ts +++ b/server/src/routes/products.ts @@ -1,375 +1,197 @@ import { Router } from "express"; -import { db, nextId, randomSku } from "../db.js"; +import { db, nextId } from "../db.js"; export const productsRouter: Router = Router(); type CreateBody = { + sku: string; name: string; - brandId: string; - shopId: string; - binId: string; type: string; kind: "bulk" | "discrete"; - weight?: number; - countOriginal?: number; - unitWeight?: number; - price: number; - thc: number; - cbd: number; - totalCannabinoids: number; - purchaseDate: string; - sku?: string; - assetTag?: string; + strainId?: string | null; + strainName?: string; + defaultThc?: number; + defaultCbd?: number; + defaultTotalCannabinoids?: number; }; productsRouter.post("/products", (req, res) => { const body = req.body as CreateBody; - if (!body.name) return res.status(400).json({ error: "name required" }); + 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" }); + } - const id = nextId("prd", "products"); - const sku = body.sku && body.sku.trim() ? body.sku.trim() : randomSku(); - const isDiscrete = body.kind === "discrete"; - const trimmedName = body.name.trim(); - const brandId = body.brandId ?? null; + const sku = body.sku.trim(); + const name = body.name.trim(); const todayIso = new Date().toISOString().slice(0, 10); - const tx = db.transaction(() => { - // Find-or-create the strain (case-insensitive name match scoped to brand+type). - const found = db - .prepare< - [string, string | null, string | null, string], - { id: string } - >( - `SELECT id FROM strains - WHERE name = ? COLLATE NOCASE - AND (brand_id IS ? OR brand_id = ?) - AND type = ?`, - ) - .get(trimmedName, brandId, brandId, body.type); + const existingSku = db + .prepare<[string], { id: string }>("SELECT id FROM products WHERE sku = ?") + .get(sku); + if (existingSku) { + return res.status(409).json({ error: "sku already exists", id: existingSku.id }); + } - let strainId: string; - if (found) { - strainId = found.id; - } else { - strainId = nextId("str", "strains"); - db.prepare(` - INSERT INTO strains ( - id, name, brand_id, type, - default_thc, default_cbd, default_total_cannabinoids, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run( - strainId, - trimmedName, - brandId, - body.type, - body.thc, - body.cbd, - body.totalCannabinoids, - todayIso, - ); - } + const id = nextId("pdt", "products"); - db.prepare(` - INSERT INTO products ( - id, sku, asset_tag, name, brand_id, shop_id, bin_id, - type, kind, weight, last_audit_weight, - count_original, count_last_audit, unit_weight, - price, thc, cbd, total_cannabinoids, - purchase_date, status, strain_id - ) VALUES ( - @id, @sku, @assetTag, @name, @brandId, @shopId, @binId, - @type, @kind, @weight, @lastAuditWeight, - @countOriginal, @countLastAudit, @unitWeight, - @price, @thc, @cbd, @totalCannabinoids, - @purchaseDate, 'active', @strainId - ) - `).run({ - id, - sku, - assetTag: body.assetTag?.trim() ? body.assetTag.trim() : null, - name: trimmedName, - brandId, - shopId: body.shopId, - binId: body.binId, - type: body.type, - kind: body.kind, - weight: isDiscrete ? 0 : body.weight ?? 0, - lastAuditWeight: isDiscrete ? null : body.weight ?? 0, - countOriginal: isDiscrete ? body.countOriginal ?? 0 : 0, - countLastAudit: isDiscrete ? body.countOriginal ?? 0 : null, - unitWeight: isDiscrete ? body.unitWeight ?? 0 : 0, - price: body.price, - thc: body.thc, - cbd: body.cbd, - totalCannabinoids: body.totalCannabinoids, - purchaseDate: body.purchaseDate, - strainId, + try { + const tx = db.transaction(() => { + let strainId = body.strainId ?? null; + const strainName = body.strainName?.trim() || name; + + if (!strainId && strainName) { + const found = db + .prepare<[string], { id: string }>( + `SELECT id FROM strains WHERE name = ? COLLATE NOCASE`, + ) + .get(strainName); + if (found) { + strainId = found.id; + } else { + strainId = nextId("str", "strains"); + db.prepare( + `INSERT INTO strains ( + id, name, + default_thc, default_cbd, default_total_cannabinoids, + created_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + strainId, + strainName, + body.defaultThc ?? null, + body.defaultCbd ?? null, + body.defaultTotalCannabinoids ?? null, + todayIso, + ); + } + } + + db.prepare( + `INSERT INTO products (id, sku, strain_id, name, type, kind, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(id, sku, strainId, name, body.type, body.kind, todayIso); }); - }); - tx(); + tx(); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + if (msg.includes("UNIQUE")) { + return res.status(409).json({ error: "sku already exists" }); + } + throw err; + } res.json({ id }); }); type UpdateBody = Partial<{ name: string; - brandId: string | null; - shopId: string | null; - binId: string | null; - assetTag: string | null; - weight: number; - countOriginal: number; - unitWeight: number; - price: number; - thc: number; - cbd: number; - totalCannabinoids: number; - purchaseDate: string; + type: string; + kind: "bulk" | "discrete"; + strainId: string | null; }>; productsRouter.patch("/products/:id", (req, res) => { const { id } = req.params; const body = req.body as UpdateBody; - type Row = { - id: string; - name: string; - brand_id: string | null; - shop_id: string | null; - bin_id: string | null; - asset_tag: string | null; - type: string; - kind: "bulk" | "discrete"; - weight: number; - last_audit_weight: number | null; - count_original: number; - count_last_audit: number | null; - unit_weight: number; - price: number; - thc: number; - cbd: number; - total_cannabinoids: number; - purchase_date: string; - strain_id: string | null; - }; - const existing = db - .prepare<[string], Row>( - `SELECT id, name, brand_id, shop_id, bin_id, asset_tag, type, kind, - weight, last_audit_weight, count_original, count_last_audit, unit_weight, - price, thc, cbd, total_cannabinoids, purchase_date, strain_id - FROM products WHERE id = ?`, + .prepare< + [string], + { id: string; name: string; type: string; kind: string; strain_id: string | null } + >( + `SELECT id, name, type, kind, strain_id FROM products WHERE id = ?`, ) .get(id); if (!existing) return res.status(404).json({ error: "product not found" }); - const auditCount = db - .prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM audits WHERE product_id = ?") - .get(id)!.n; - - const trimOrUndef = (v: unknown) => - typeof v === "string" ? v.trim() : v; - const nextName = - body.name !== undefined && (body.name as string).trim() - ? (body.name as string).trim() - : existing.name; - const nextBrandId = - body.brandId === undefined ? existing.brand_id : body.brandId || null; - const nextShopId = - body.shopId === undefined ? existing.shop_id : body.shopId || null; - const nextBinId = - body.binId === undefined ? existing.bin_id : body.binId || null; - const nextAssetTag = - body.assetTag === undefined - ? existing.asset_tag - : (trimOrUndef(body.assetTag) as string | null) || null; - const nextPrice = - Number.isFinite(body.price) && (body.price as number) >= 0 - ? (body.price as number) - : existing.price; - const nextPurchaseDate = - typeof body.purchaseDate === "string" && body.purchaseDate.trim() - ? body.purchaseDate.trim() - : existing.purchase_date; - const nextThc = - Number.isFinite(body.thc) ? (body.thc as number) : existing.thc; - const nextCbd = - Number.isFinite(body.cbd) ? (body.cbd as number) : existing.cbd; - const nextTotalCanna = Number.isFinite(body.totalCannabinoids) - ? (body.totalCannabinoids as number) - : existing.total_cannabinoids; + 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; - const isDiscrete = existing.kind === "discrete"; - const nextWeight = - !isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0 - ? (body.weight as number) - : existing.weight; - const nextCountOriginal = - isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0 - ? Math.floor(body.countOriginal as number) - : existing.count_original; - const nextUnitWeight = - isDiscrete && Number.isFinite(body.unitWeight) && (body.unitWeight as number) >= 0 - ? (body.unitWeight as number) - : existing.unit_weight; - - // If no audits exist yet, keep the "last audit" mirror in lock-step with - // the original size — otherwise the next audit's prev_value would be stale. - const nextLastAuditWeight = - !isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight; - const nextCountLastAudit = - isDiscrete && auditCount === 0 ? nextCountOriginal : existing.count_last_audit; - - const tx = db.transaction(() => { - let nextStrainId = existing.strain_id; - const nameChanged = nextName !== existing.name; - const brandChanged = nextBrandId !== existing.brand_id; - if (nameChanged || brandChanged) { - const todayIso = new Date().toISOString().slice(0, 10); - const found = db - .prepare< - [string, string | null, string | null, string], - { id: string } - >( - `SELECT id FROM strains - WHERE name = ? COLLATE NOCASE - AND (brand_id IS ? OR brand_id = ?) - AND type = ?`, - ) - .get(nextName, nextBrandId, nextBrandId, existing.type); - if (found) { - nextStrainId = found.id; - } else { - nextStrainId = nextId("str", "strains"); - db.prepare(` - INSERT INTO strains ( - id, name, brand_id, type, - default_thc, default_cbd, default_total_cannabinoids, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run( - nextStrainId, - nextName, - nextBrandId, - existing.type, - nextThc, - nextCbd, - nextTotalCanna, - todayIso, - ); - } - } - - db.prepare(` - UPDATE products SET - name = @name, - brand_id = @brandId, - shop_id = @shopId, - bin_id = @binId, - asset_tag = @assetTag, - weight = @weight, - last_audit_weight = @lastAuditWeight, - count_original = @countOriginal, - count_last_audit = @countLastAudit, - unit_weight = @unitWeight, - price = @price, - thc = @thc, - cbd = @cbd, - total_cannabinoids = @totalCannabinoids, - purchase_date = @purchaseDate, - strain_id = @strainId - WHERE id = @id - `).run({ - id, - name: nextName, - brandId: nextBrandId, - shopId: nextShopId, - binId: nextBinId, - assetTag: nextAssetTag, - weight: nextWeight, - lastAuditWeight: nextLastAuditWeight, - countOriginal: nextCountOriginal, - countLastAudit: nextCountLastAudit, - unitWeight: nextUnitWeight, - price: nextPrice, - thc: nextThc, - cbd: nextCbd, - totalCannabinoids: nextTotalCanna, - purchaseDate: nextPurchaseDate, - strainId: nextStrainId, - }); - }); - tx(); + db.prepare( + `UPDATE products SET name = ?, type = ?, kind = ?, strain_id = ? WHERE id = ?`, + ).run(nextName, nextType, nextKind, nextStrainId, id); res.json({ ok: true }); }); -productsRouter.post("/products/:id/finish", (req, res) => { +productsRouter.delete("/products/:id", (req, res) => { const { id } = req.params; - const { date, rating, notes } = req.body as { date: string; rating?: number; notes?: string }; - const result = db - .prepare( - "UPDATE products SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL WHERE id = ? AND status = 'active'", + const inUse = db + .prepare<[string], { n: number }>( + `SELECT COUNT(*) AS n FROM inventory_items WHERE product_id = ?`, ) - .run(date, rating ?? null, notes ?? null, id); - if (result.changes === 0) return res.status(404).json({ error: "not found or not active" }); + .get(id)!.n; + if (inUse > 0) { + return res + .status(409) + .json({ error: `product has ${inUse} inventory item${inUse === 1 ? "" : "s"}` }); + } + const result = db.prepare(`DELETE FROM products WHERE id = ?`).run(id); + if (result.changes === 0) return res.status(404).json({ error: "product not found" }); res.json({ ok: true }); }); -productsRouter.post("/products/:id/gone", (req, res) => { +// Strain management — kept here since strains are catalog cousins of products. +productsRouter.patch("/strains/:id", (req, res) => { const { id } = req.params; - const { date, reason, notes } = req.body as { date: string; reason: string; notes?: string }; - const combinedNotes = notes ? `${reason}: ${notes}` : reason; - const tx = db.transaction(() => { - const result = db - .prepare( - "UPDATE products SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL WHERE id = ? AND status = 'active'", - ) - .run(date, combinedNotes, id); - if (result.changes === 0) throw new Error("not found"); - db.prepare( - "INSERT INTO audits (product_id, date, mode, value, prev_value, confirmed_by) VALUES (?, ?, 'presence', 0, NULL, 'lost')", - ).run(id, date); - }); - try { - tx(); - res.json({ ok: true }); - } catch { - res.status(404).json({ error: "not found or not active" }); - } -}); + const body = req.body as Partial<{ + name: string; + defaultThc: number | null; + defaultCbd: number | null; + defaultTotalCannabinoids: number | null; + notes: string | null; + }>; -productsRouter.post("/products/:id/audit", (req, res) => { - const { id } = req.params; - const { date, mode, value, confirmedBy } = req.body as { - date: string; - mode: "weigh" | "estimate" | "presence"; - value: number; - confirmedBy?: string; - }; - - const product = db - .prepare<[string], { kind: string; weight: number; last_audit_weight: number | null; count_original: number; count_last_audit: number | null }>( - "SELECT kind, weight, last_audit_weight, count_original, count_last_audit FROM products WHERE id = ?", + const existing = db + .prepare< + [string], + { + id: string; + name: string; + default_thc: number | null; + default_cbd: number | null; + default_total_cannabinoids: number | null; + notes: string | null; + } + >( + `SELECT id, name, default_thc, default_cbd, default_total_cannabinoids, notes + FROM strains WHERE id = ?`, ) .get(id); - if (!product) return res.status(404).json({ error: "not found" }); + if (!existing) return res.status(404).json({ error: "strain not found" }); - const prev = - product.kind === "discrete" - ? product.count_last_audit ?? product.count_original - : product.last_audit_weight ?? product.weight; + const nextName = + typeof body.name === "string" && body.name.trim() ? body.name.trim() : existing.name; + const nextThc = + body.defaultThc === undefined ? existing.default_thc : body.defaultThc; + const nextCbd = + body.defaultCbd === undefined ? existing.default_cbd : body.defaultCbd; + const nextTotal = + body.defaultTotalCannabinoids === undefined + ? existing.default_total_cannabinoids + : body.defaultTotalCannabinoids; + const nextNotes = body.notes === undefined ? existing.notes : body.notes; - const tx = db.transaction(() => { + try { db.prepare( - "INSERT INTO audits (product_id, date, mode, value, prev_value, confirmed_by) VALUES (?, ?, ?, ?, ?, ?)", - ).run(id, date, mode, value, prev, confirmedBy ?? null); - if (product.kind === "discrete") { - db.prepare("UPDATE products SET count_last_audit = ? WHERE id = ?").run(value, id); - } else { - db.prepare("UPDATE products SET last_audit_weight = ? WHERE id = ?").run(value, id); + `UPDATE strains SET + name = ?, default_thc = ?, default_cbd = ?, default_total_cannabinoids = ?, notes = ? + WHERE id = ?`, + ).run(nextName, nextThc, nextCbd, nextTotal, nextNotes, id); + res.json({ ok: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + if (msg.includes("UNIQUE")) { + return res.status(409).json({ error: "another strain already uses that name" }); } - }); - tx(); - res.json({ ok: true }); + throw err; + } }); diff --git a/server/src/schema.sql b/server/src/schema.sql index c229b22..240c2db 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -1,3 +1,5 @@ +-- Catalog tables (brands, shops, bins) — pure name/metadata records. + CREATE TABLE IF NOT EXISTS shops ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -16,57 +18,72 @@ CREATE TABLE IF NOT EXISTS bins ( capacity INTEGER NOT NULL DEFAULT 10 ); +-- Strains: one row per cannabis strain (catalog-level). UNIQUE on name only, +-- since brand and physical form (type) are now per-product / per-instance. CREATE TABLE IF NOT EXISTS strains ( id TEXT PRIMARY KEY, - name TEXT NOT NULL, - brand_id TEXT REFERENCES brands(id), - type TEXT NOT NULL, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, default_thc REAL, default_cbd REAL, default_total_cannabinoids REAL, notes TEXT, - created_at TEXT NOT NULL, - UNIQUE(name, brand_id, type) + created_at TEXT NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_strains_brand_type ON strains(brand_id, type); - +-- 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. CREATE TABLE IF NOT EXISTS products ( - id TEXT PRIMARY KEY, - sku TEXT NOT NULL, - asset_tag TEXT, - name TEXT NOT NULL, - brand_id TEXT REFERENCES brands(id), - shop_id TEXT REFERENCES shops(id), - bin_id TEXT REFERENCES bins(id), - type TEXT NOT NULL, - kind TEXT NOT NULL, - weight REAL DEFAULT 0, - last_audit_weight REAL, - count_original INTEGER DEFAULT 0, - count_last_audit INTEGER, - unit_weight REAL DEFAULT 0, - price REAL NOT NULL, - thc REAL DEFAULT 0, - cbd REAL DEFAULT 0, - total_cannabinoids REAL DEFAULT 0, - purchase_date TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - consumed_date TEXT, - gone_date TEXT, - rating INTEGER, - notes TEXT + id TEXT PRIMARY KEY, + sku TEXT NOT NULL UNIQUE, + strain_id TEXT REFERENCES strains(id), + name TEXT NOT NULL, + 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); +-- 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. +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, + thc REAL DEFAULT 0, + cbd REAL DEFAULT 0, + total_cannabinoids REAL DEFAULT 0, + weight REAL DEFAULT 0, + last_audit_weight REAL, + count_original INTEGER DEFAULT 0, + count_last_audit INTEGER, + unit_weight REAL DEFAULT 0, + purchase_date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + consumed_date TEXT, + gone_date TEXT, + rating INTEGER, + notes TEXT +); +CREATE INDEX IF NOT EXISTS idx_inv_product ON inventory_items(product_id); +CREATE INDEX IF NOT EXISTS idx_inv_status ON inventory_items(status); +CREATE INDEX IF NOT EXISTS idx_inv_bin ON inventory_items(bin_id); + +-- Audits: append-only history, keyed on inventory item. CREATE TABLE IF NOT EXISTS audits ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE, - date TEXT NOT NULL, - mode TEXT NOT NULL, - value REAL NOT NULL, - prev_value REAL, - confirmed_by TEXT + id INTEGER PRIMARY KEY AUTOINCREMENT, + inventory_id TEXT NOT NULL REFERENCES inventory_items(id) ON DELETE CASCADE, + date TEXT NOT NULL, + mode TEXT NOT NULL, + value REAL NOT NULL, + prev_value REAL, + confirmed_by TEXT ); - -CREATE INDEX IF NOT EXISTS idx_products_status ON products(status); -CREATE INDEX IF NOT EXISTS idx_audits_product ON audits(product_id, date); +CREATE INDEX IF NOT EXISTS idx_audits_inventory ON audits(inventory_id, date); diff --git a/web/src/App.tsx b/web/src/App.tsx index 327bac5..ad847d4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { api } from "./api.js"; -import type { Bin, Bootstrap, Brand, Product, Shop } from "./types.js"; +import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js"; +import { enrichItems } from "./types.js"; import { computeStats } from "./stats.js"; import { Sidebar } from "./components/Sidebar.js"; import type { ViewKey } from "./components/Sidebar.js"; @@ -14,7 +15,8 @@ import { ChartsView } from "./views/ChartsView.js"; import { SettingsView } from "./views/SettingsView.js"; import type { ThemeKey } from "./views/SettingsView.js"; import { ProductDetail } from "./components/ProductDetail.js"; -import { AddProductFlow } from "./components/modals/AddProductFlow.js"; +import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js"; +import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js"; import { EditProductFlow } from "./components/modals/EditProductFlow.js"; import { ConsumeFlow } from "./components/modals/ConsumeFlow.js"; import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js"; @@ -31,6 +33,7 @@ import { type ModalKey = | "add" | "edit" + | "editProduct" | "consume" | "gone" | "audit" @@ -44,8 +47,9 @@ type ModalKey = export function App() { const [view, setView] = useState("dashboard"); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState(null); const [modal, setModal] = useState(null); + const [modalItem, setModalItem] = useState(null); const [modalProduct, setModalProduct] = useState(null); const [modalBin, setModalBin] = useState(null); const [modalBrand, setModalBrand] = useState(null); @@ -66,40 +70,46 @@ export function App() { }); const stats = useMemo(() => (data ? computeStats(data) : null), [data]); + const items = useMemo(() => (data ? enrichItems(data) : []), [data]); - // Re-sync the selected product reference whenever bootstrap refetches + // Re-sync the selected item reference whenever bootstrap refetches // — otherwise the drawer keeps showing stale audit history after a // mutation invalidates the cache. useEffect(() => { if (selected && data) { - const fresh = data.products.find((p) => p.id === selected.id); + const fresh = items.find((i) => i.id === selected.id); if (fresh && fresh !== selected) setSelected(fresh); } - }, [data]); // eslint-disable-line react-hooks/exhaustive-deps + }, [data, items]); // eslint-disable-line react-hooks/exhaustive-deps const openAdd = () => { - setModalProduct(null); + setModalItem(null); setModal("add"); }; - const openConsume = (p?: Product) => { - setModalProduct(p ?? null); + const openConsume = (i?: Item) => { + setModalItem(i ?? null); setSelected(null); setModal("consume"); }; - const openMarkGone = (p?: Product) => { - setModalProduct(p ?? null); + const openMarkGone = (i?: Item) => { + setModalItem(i ?? null); setSelected(null); setModal("gone"); }; - const openAudit = (p?: Product) => { - setModalProduct(p ?? null); + const openAudit = (i?: Item) => { + setModalItem(i ?? null); setModal("audit"); }; - const openEdit = (p: Product) => { - setModalProduct(p); + const openEdit = (i: Item) => { + setModalItem(i); setSelected(null); setModal("edit"); }; + const openEditProduct = (p: Product) => { + setModalProduct(p); + setSelected(null); + setModal("editProduct"); + }; if (isLoading) { return ( @@ -131,22 +141,22 @@ export function App() { )} {view === "inventory" && ( openAudit()} /> )} {view === "bins" && ( setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); @@ -182,28 +192,32 @@ export function App() { {selected && ( setSelected(null)} onConsume={openConsume} onMarkGone={openMarkGone} onAudit={openAudit} onEdit={openEdit} + onEditProduct={openEditProduct} /> )} - {modal === "add" && setModal(null)} />} - {modal === "edit" && modalProduct && ( + {modal === "add" && setModal(null)} />} + {modal === "edit" && modalItem && ( + setModal(null)} /> + )} + {modal === "editProduct" && modalProduct && ( setModal(null)} /> )} {modal === "consume" && ( - setModal(null)} product={modalProduct} /> + setModal(null)} item={modalItem} /> )} {modal === "gone" && ( - setModal(null)} product={modalProduct} /> + setModal(null)} item={modalItem} /> )} {modal === "audit" && ( - setModal(null)} product={modalProduct} /> + setModal(null)} item={modalItem} /> )} {modal === "addBrand" && setModal(null)} />} {modal === "addShop" && setModal(null)} />} diff --git a/web/src/api.ts b/web/src/api.ts index 45f2126..e035aed 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -15,41 +15,26 @@ async function request(path: string, init?: RequestInit): Promise { export const api = { bootstrap: () => request("/bootstrap"), + // Catalog: products createProduct: (body: { + sku: string; name: string; - brandId: string; - shopId: string; - binId: string; type: string; kind: "bulk" | "discrete"; - weight?: number; - countOriginal?: number; - unitWeight?: number; - price: number; - thc: number; - cbd: number; - totalCannabinoids: number; - purchaseDate: string; - sku?: string; - assetTag?: string; + strainId?: string | null; + strainName?: string; + defaultThc?: number; + defaultCbd?: number; + defaultTotalCannabinoids?: number; }) => request<{ id: string }>("/products", { method: "POST", body: JSON.stringify(body) }), updateProduct: ( id: string, body: Partial<{ name: string; - brandId: string | null; - shopId: string | null; - binId: string | null; - assetTag: string | null; - weight: number; - countOriginal: number; - unitWeight: number; - price: number; - thc: number; - cbd: number; - totalCannabinoids: number; - purchaseDate: string; + type: string; + kind: "bulk" | "discrete"; + strainId: string | null; }>, ) => request<{ ok: true }>(`/products/${id}`, { @@ -57,27 +42,93 @@ export const api = { body: JSON.stringify(body), }), - finishProduct: (id: string, body: { date: string; rating?: number; notes?: string }) => - request<{ ok: true }>(`/products/${id}/finish`, { + deleteProduct: (id: string) => + request<{ ok: true }>(`/products/${id}`, { method: "DELETE" }), + + updateStrain: ( + id: string, + body: Partial<{ + name: string; + defaultThc: number | null; + defaultCbd: number | null; + defaultTotalCannabinoids: number | null; + notes: string | null; + }>, + ) => + request<{ ok: true }>(`/strains/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }), + + // Inventory items (instances) + createInventoryItem: (body: { + productId: string; + brandId?: string | null; + shopId?: string | null; + binId?: string | null; + price: number; + thc?: number; + cbd?: number; + totalCannabinoids?: number; + weight?: number; + countOriginal?: number; + unitWeight?: number; + purchaseDate: string; + }) => + request<{ id: string; assetId: string }>("/inventory", { method: "POST", body: JSON.stringify(body), }), - markGone: (id: string, body: { date: string; reason: string; notes?: string }) => - request<{ ok: true }>(`/products/${id}/gone`, { + updateInventoryItem: ( + id: string, + body: Partial<{ + brandId: string | null; + shopId: string | null; + binId: string | null; + price: number; + thc: number; + cbd: number; + totalCannabinoids: number; + weight: number; + countOriginal: number; + unitWeight: number; + purchaseDate: string; + }>, + ) => + request<{ ok: true }>(`/inventory/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }), + + finishInventoryItem: ( + id: string, + body: { date: string; rating?: number; notes?: string }, + ) => + request<{ ok: true }>(`/inventory/${id}/finish`, { method: "POST", body: JSON.stringify(body), }), - auditProduct: ( + markInventoryItemGone: ( + id: string, + body: { date: string; reason: string; notes?: string }, + ) => + request<{ ok: true }>(`/inventory/${id}/gone`, { + method: "POST", + body: JSON.stringify(body), + }), + + auditInventoryItem: ( id: string, body: { date: string; mode: AuditMode; value: number; confirmedBy?: string }, ) => - request<{ ok: true }>(`/products/${id}/audit`, { + request<{ ok: true }>(`/inventory/${id}/audit`, { method: "POST", body: JSON.stringify(body), }), + // Catalog tables (brand/shop/bin) — unchanged createBrand: (name: string) => request<{ id: string; name: string }>("/brands", { method: "POST", diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index 806f570..9698c04 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -1,76 +1,82 @@ -import type { Bootstrap, Product } from "../types.js"; +import type { Bootstrap, Item, Product } from "../types.js"; import { TYPES, helpers, TODAY_STR } from "../types.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; import { Btn, Pill, Icon } from "./primitives/index.js"; +// Right-side drawer for an inventory instance. Shows the asset id and +// product context up top, then per-batch fields (price, THC, weight), +// audit history, and full detail rows. export function ProductDetail({ - product, + item, data, onClose, onConsume, onMarkGone, onAudit, onEdit, + onEditProduct, }: { - product: Product; + item: Item; data: Bootstrap; onClose: () => void; - onConsume: (p: Product) => void; - onMarkGone: (p: Product) => void; - onAudit: (p: Product) => void; - onEdit: (p: Product) => void; + onConsume: (i: Item) => void; + onMarkGone: (i: Item) => void; + onAudit: (i: Item) => void; + onEdit: (i: Item) => void; + onEditProduct: (p: Product) => void; }) { - const bin = data.bins.find((b) => b.id === product.binId); - const cfg = TYPES.find((t) => t.id === product.type); - const pctRemaining = helpers.pctRemaining(product, TODAY_STR); - const est = helpers.estimatedRemaining(product, TODAY_STR); - const last = helpers.lastAudit(product); - const overdue = helpers.auditOverdue(product, TODAY_STR); - const sinceCheck = helpers.daysSinceCheck(product, TODAY_STR); + const bin = data.bins.find((b) => b.id === item.binId); + const cfg = TYPES.find((t) => t.id === item.type); + const product = data.products.find((p) => p.id === item.productId); + const pctRemaining = helpers.pctRemaining(item, TODAY_STR); + const est = helpers.estimatedRemaining(item, TODAY_STR); + const last = helpers.lastAudit(item); + const overdue = helpers.auditOverdue(item, TODAY_STR); + const sinceCheck = helpers.daysSinceCheck(item, TODAY_STR); - const isActive = product.status === "active"; + const isActive = item.status === "active"; + + // Sibling instances of the same product (excluding this one) — useful for + // seeing previous purchases of the same SKU. + const siblings = data.inventoryItems.filter( + (i) => i.productId === item.productId && i.id !== item.id, + ); const detailRows: [string, React.ReactNode][] = [ - ["SKU", {product.sku}], - [ - "Asset tag", - product.assetTag ? ( - {product.assetTag} - ) : ( - None - ), - ], - ["Type", `${product.type} · ${product.kind}`], - ["Brand", helpers.brandName(data, product.brandId)], - ["Shop", helpers.shopName(data, product.shopId)], - ["Total cannabinoids", `${product.totalCannabinoids.toFixed(1)}%`], - ["Purchase date", fmt.date(product.purchaseDate)], + ["Asset id", {item.assetId}], + ["SKU", {item.sku}], + ["Type", `${item.type} · ${item.kind}`], + ["Strain", item.strainName ?? Unlinked], + ["Brand", helpers.brandName(data, item.brandId)], + ["Shop", helpers.shopName(data, item.shopId)], + ["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`], + ["Purchase date", fmt.date(item.purchaseDate)], ["Bin", bin ? bin.name : ], ["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`], [ "Cost per gram", - product.kind === "bulk" && product.weight > 0 - ? fmt.money(product.price / product.weight) - : product.kind === "discrete" && product.unitWeight > 0 - ? `${fmt.money(product.price / (product.countOriginal * product.unitWeight))} (effective)` + item.kind === "bulk" && item.weight > 0 + ? fmt.money(item.price / item.weight) + : item.kind === "discrete" && item.unitWeight > 0 + ? `${fmt.money(item.price / (item.countOriginal * item.unitWeight))} (effective)` : "—", ], ]; - if (product.status === "consumed") { + if (item.status === "consumed") { detailRows.push( - ["Date finished", fmt.date(product.consumedDate)], + ["Date finished", fmt.date(item.consumedDate)], [ "Lasted", - `${Math.round((+new Date(product.consumedDate!) - +new Date(product.purchaseDate)) / 86_400_000)} days`, + `${Math.round((+new Date(item.consumedDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`, ], ); } - if (product.status === "gone") { + if (item.status === "gone") { detailRows.push( - ["Date gone", fmt.date(product.goneDate)], + ["Date gone", fmt.date(item.goneDate)], [ "After", - `${Math.round((+new Date(product.goneDate!) - +new Date(product.purchaseDate)) / 86_400_000)} days`, + `${Math.round((+new Date(item.goneDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`, ], ); } @@ -112,25 +118,25 @@ export function ProductDetail({ }} >
- Product · {product.sku} + Inventory · {item.assetId}
{isActive && ( - onAudit(product)}> + onAudit(item)}> Audit )} {isActive && ( - onConsume(product)}> + onConsume(item)}> Mark consumed )} {isActive && ( - onMarkGone(product)}> + onMarkGone(item)}> Mark gone )} - onEdit(product)}> + onEdit(item)}> Edit @@ -140,13 +146,13 @@ export function ProductDetail({
- {TYPE_GLYPHS[product.type]} {product.type} + {TYPE_GLYPHS[item.type]} {item.type}
- {product.status === "consumed" && ( - Consumed · {fmt.daysAgo(product.consumedDate)} + {item.status === "consumed" && ( + Consumed · {fmt.daysAgo(item.consumedDate)} )} - {product.status === "gone" && ( - Gone · {fmt.daysAgo(product.goneDate)} + {item.status === "gone" && ( + Gone · {fmt.daysAgo(item.goneDate)} )} {isActive && overdue && Audit overdue · {sinceCheck}d}
@@ -160,11 +166,34 @@ export function ProductDetail({ lineHeight: 1.1, }} > - {product.name} + {item.name}
- {helpers.brandName(data, product.brandId)} · from {helpers.shopName(data, product.shopId)} + {helpers.brandName(data, item.brandId)} · from {helpers.shopName(data, item.shopId)}
+ {product && ( +
+ + {siblings.length > 0 && ( + + · {siblings.length} other instance{siblings.length === 1 ? "" : "s"} on file + + )} +
+ )}
0 ? ( + item.kind === "discrete" && item.countOriginal > 0 ? ( <> - {fmt.money(product.price / product.countOriginal)} + {fmt.money(item.price / item.countOriginal)} /unit @@ -198,21 +227,21 @@ export function ProductDetail({ letterSpacing: 0, }} > - {fmt.money(product.price)} total + {fmt.money(item.price)} total
) : ( - fmt.money(product.price) + fmt.money(item.price) ), ], [ - product.kind === "discrete" ? "Quantity" : "Size", - product.kind === "discrete" - ? `${product.countOriginal} ${cfg?.unit ?? "ct"}` - : `${product.weight} ${cfg?.unit ?? "g"}`, + item.kind === "discrete" ? "Quantity" : "Size", + item.kind === "discrete" + ? `${item.countOriginal} ${cfg?.unit ?? "ct"}` + : `${item.weight} ${cfg?.unit ?? "g"}`, ], - ["THC", `${product.thc.toFixed(1)}%`], - ["CBD", `${product.cbd.toFixed(1)}%`], + ["THC", `${item.thc.toFixed(1)}%`], + ["CBD", `${item.cbd.toFixed(1)}%`], ] as [string, React.ReactNode][] ).map(([l, v], i) => (
@@ -233,12 +262,12 @@ export function ProductDetail({ }} >
- {product.kind === "discrete" ? "Units remaining" : "Estimated remaining"} + {item.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
- {product.kind === "discrete" - ? `${product.countLastAudit ?? product.countOriginal} of ${product.countOriginal}` - : `${est.toFixed(2)} of ${product.weight} ${cfg?.unit ?? "g"}`} + {item.kind === "discrete" + ? `${item.countLastAudit ?? item.countOriginal} of ${item.countOriginal}` + : `${est.toFixed(2)} of ${item.weight} ${cfg?.unit ?? "g"}`} {Math.round(pctRemaining * 100)}% @@ -258,7 +287,7 @@ export function ProductDetail({ }} />
- {product.kind === "bulk" && last && ( + {item.kind === "bulk" && last && (
Audit history
{isActive && ( )}
- {product.audits.length === 0 ? ( + {item.audits.length === 0 ? (
- No audits recorded. Cadence for {product.type}: every {cfg?.cadenceDays ?? "—"} days. + No audits recorded. Cadence for {item.type}: every {cfg?.cadenceDays ?? "—"} days.
) : (
- {[...product.audits].reverse().map((a, i, arr) => ( + {[...item.audits].reverse().map((a, idx, arr) => (
- {(product.status === "consumed" || product.status === "gone") && ( + {(item.status === "consumed" || item.status === "gone") && (
- {product.status === "gone" ? "Why it's gone" : "Final notes"} + {item.status === "gone" ? "Why it's gone" : "Final notes"}
- {product.status === "consumed" && ( + {item.status === "consumed" && (
{[1, 2, 3, 4, 5].map((n) => ( ))}
@@ -422,7 +451,7 @@ export function ProductDetail({ className="serif" style={{ fontSize: 18, lineHeight: 1.5, color: "var(--ink-2)", fontStyle: "italic" }} > - "{product.notes ?? "No notes recorded."}" + "{item.notes ?? "No notes recorded."}"
)} diff --git a/web/src/components/ScanField.tsx b/web/src/components/ScanField.tsx index 712fe94..2eabd39 100644 --- a/web/src/components/ScanField.tsx +++ b/web/src/components/ScanField.tsx @@ -1,19 +1,25 @@ import { useEffect, useState } from "react"; -import type { Product } from "../types.js"; +import type { Item, Product } from "../types.js"; import { Icon, Field, inputStyle } from "./primitives/index.js"; -// Scan-friendly product picker. Auto-focuses on mount so a barcode scanner -// can fire immediately. Exact (case-insensitive) match against sku or -// assetTag → calls onMatch and clears itself for the next scan. Falls back -// to a "no match" hint when the value doesn't resolve. +export type ScanResult = + | { kind: "item"; item: Item } + | { kind: "product"; product: Product }; + +// Scan-friendly picker. Auto-focuses on mount so a barcode scanner +// can fire immediately. Exact (case-insensitive) match against assetId +// (per-instance) or sku (per-product) → calls onMatch and clears itself. +// Asset id wins ties since it's the more specific identifier. export function ScanField({ + items, products, onMatch, - matchedProduct, + matchedLabel, }: { - products: Product[]; - onMatch: (productId: string) => void; - matchedProduct: Product | null; + items: Item[]; + products?: Product[]; + onMatch: (result: ScanResult) => void; + matchedLabel: string | null; }) { const [scan, setScan] = useState(""); const [feedback, setFeedback] = useState<{ type: "matched" | "miss"; text: string } | null>(null); @@ -24,15 +30,13 @@ export function ScanField({ setFeedback(null); return; } - const match = products.find( - (p) => - p.sku.toLowerCase() === trimmed || - (p.assetTag != null && p.assetTag.toLowerCase() === trimmed), - ); - if (match) { - onMatch(match.id); + const hit = lookup(trimmed, items, products); + if (hit) { + onMatch(hit); setScan(""); - setFeedback({ type: "matched", text: `Matched ${match.name}` }); + const name = + hit.kind === "item" ? hit.item.name : hit.product.name; + setFeedback({ type: "matched", text: `Matched ${name}` }); } }, [scan]); // eslint-disable-line react-hooks/exhaustive-deps @@ -42,18 +46,15 @@ export function ScanField({ if (!scan.trim() || feedback?.type === "matched") return; const timer = setTimeout(() => { const trimmed = scan.trim().toLowerCase(); - const match = products.find( - (p) => - p.sku.toLowerCase() === trimmed || - (p.assetTag != null && p.assetTag.toLowerCase() === trimmed), - ); - if (!match) setFeedback({ type: "miss", text: "No SKU or asset tag matches that." }); + if (!lookup(trimmed, items, products)) { + setFeedback({ type: "miss", text: "No asset id or SKU matches that." }); + } }, 400); return () => clearTimeout(timer); - }, [scan, products]); // eslint-disable-line react-hooks/exhaustive-deps + }, [scan, items, products]); // eslint-disable-line react-hooks/exhaustive-deps return ( - +
setScan(e.target.value)} onFocus={(e) => e.currentTarget.select()} - placeholder="SKU-XXXXXX or AT-0000" + placeholder="K3F9X2 or SKU-XXXXXX" style={{ border: "none", outline: "none", @@ -81,7 +82,7 @@ export function ScanField({ fontFamily: "var(--mono)", }} /> - {matchedProduct && !scan && ( + {matchedLabel && !scan && ( - ✓ {matchedProduct.name} + ✓ {matchedLabel} )}
@@ -108,3 +109,19 @@ export function ScanField({
); } + +function lookup( + trimmed: string, + items: Item[], + products?: Product[], +): ScanResult | null { + const itemHit = items.find((i) => i.assetId.toLowerCase() === trimmed); + if (itemHit) return { kind: "item", item: itemHit }; + const skuHitItem = items.find((i) => i.sku.toLowerCase() === trimmed); + if (skuHitItem) return { kind: "item", item: skuHitItem }; + if (products) { + const productHit = products.find((p) => p.sku.toLowerCase() === trimmed); + if (productHit) return { kind: "product", product: productHit }; + } + return null; +} diff --git a/web/src/components/modals/AddInventoryFlow.tsx b/web/src/components/modals/AddInventoryFlow.tsx new file mode 100644 index 0000000..be6e8c6 --- /dev/null +++ b/web/src/components/modals/AddInventoryFlow.tsx @@ -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("select"); + const [productId, setProductId] = useState(null); + const [savedAssetId, setSavedAssetId] = useState(null); + + const product = productId + ? data.products.find((p) => p.id === productId) ?? null + : null; + + const goToDetails = (id: string) => { + setProductId(id); + setStep("details"); + }; + + return ( + +
+ + + {step === "select" && ( + goToDetails(id)} + onClose={onClose} + /> + )} + + {step === "details" && product && ( + setStep("select")} + onSaved={(assetId) => { + setSavedAssetId(assetId); + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + setStep("done"); + }} + /> + )} + + {step === "done" && savedAssetId && product && ( + { + setSavedAssetId(null); + setProductId(null); + setStep("select"); + }} + onClose={onClose} + /> + )} +
+
+ ); +} + +// ─── 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( + 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(""); // empty = match-by-name / create + const [error, setError] = useState(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 ( + <> +
+ + + {data.products.length > 0 && !creating && ( +
+ + + +
+ )} + + {!creating && ( +
+ setCreating(true)}> + Create a new product + +
+ )} + + {creating && ( +
+
+ New product (catalog entry) +
+
+ + setNewSku(e.target.value)} + /> + + + + + + setNewName(e.target.value)} + /> + + + + setNewStrain(e.target.value)} + disabled={!!newStrainId} + /> + +
+ {error && ( +
{error}
+ )} +
+ )} +
+ + +
+ {creating + ? "Create the product, then we'll capture this batch's details." + : "Scan a SKU, pick a product, or create one."} +
+
+ + Cancel + + {creating ? ( + <> + setCreating(false)}> + Back + + create.mutate()} + > + {create.isPending ? "Creating…" : "Create product"} + + + ) : ( + data.products.length > 0 && ( + onPickProduct(pickedProductId)} + > + Add inventory + + ) + )} +
+
+ + ); +} + +// ─── 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(null); + + const update = (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 ( + <> +
+
+ + {product.name} · {product.type} ·{" "} + {product.sku} + + + {priorCount > 0 + ? `${priorCount} prior instance${priorCount === 1 ? "" : "s"} — fields autofilled from most recent.` + : "First instance of this product."} + +
+ +
+ Source +
+
+ + + + + + + {isNewBrand && ( + + setNewBrand(e.target.value)} + placeholder="e.g. Foxglove Farms" + /> + + )} + {isNewShop && ( + <> + + setNewShopName(e.target.value)} + placeholder="e.g. Greenleaf Co-op" + /> + + + setNewShopLocation(e.target.value)} + placeholder="e.g. Capitol Hill" + /> + + + )} + + + + {isNewBin && ( + <> + + setNewBinName(e.target.value)} + placeholder="e.g. A1" + /> + + + + setNewBinCapacity(Math.max(1, Math.floor(+e.target.value || 1))) + } + /> + + + )} +
+ +
+ Acquisition +
+
+ {isDiscrete ? ( + <> + + update("countOriginal", +e.target.value)} + /> + + + update("unitWeight", +e.target.value)} + /> + + + ) : ( + + update("weight", +e.target.value)} + /> + + )} + + update("price", +e.target.value)} + /> + + + update("purchaseDate", e.target.value)} + /> + +
+ + {!isDiscrete && cpg > 0 && ( +
+ Cost per {cfg?.unit ?? "g"}:{" "} + + {fmt.money(cpg)} + +
+ )} + {isDiscrete && form.price > 0 && form.countOriginal > 0 && ( +
+ Total:{" "} + + {fmt.money(totalPrice)} + + + ({form.countOriginal} × {fmt.money(form.price)}) + +
+ )} + +
+ Cannabinoid profile +
+
+ + update("thc", +e.target.value)} + /> + + + update("cbd", +e.target.value)} + /> + + + update("totalCannabinoids", +e.target.value)} + /> + +
+ + {error && ( +
{error}
+ )} +
+ + + + ← Back + + save.mutate()} + > + {save.isPending ? "Saving…" : "Save inventory item"} + + + + ); +} + +// ─── Step 3 ───────────────────────────────────────────────────────── + +function DonePane({ + assetId, + productName, + onAddAnother, + onClose, +}: { + assetId: string; + productName: string; + onAddAnother: () => void; + onClose: () => void; +}) { + return ( + <> +
+
Asset id
+
+ {assetId} +
+
+ Label this {productName} with{" "} + {assetId}{" "} + so you can scan it later. +
+
+ + + + Add another + + + Done + + + + ); +} diff --git a/web/src/components/modals/AuditFlow.tsx b/web/src/components/modals/AuditFlow.tsx index d7324ee..8ab8b1f 100644 --- a/web/src/components/modals/AuditFlow.tsx +++ b/web/src/components/modals/AuditFlow.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Bootstrap, Product } from "../../types.js"; -import { TYPES, helpers, TODAY_STR } from "../../types.js"; +import type { Bootstrap, Item } from "../../types.js"; +import { TYPES, helpers, TODAY_STR, enrichItems } from "../../types.js"; import { api } from "../../api.js"; import { Btn, Field, Input, Select } from "../primitives/index.js"; -import { ScanField } from "../ScanField.js"; -import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js"; +import { ScanField, type ScanResult } from "../ScanField.js"; +import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; const AUDIT_MODE_LABELS: Record = { weigh: { @@ -25,40 +25,41 @@ const AUDIT_MODE_LABELS: Record = { export function AuditFlow({ data, onClose, - product: initialProduct, + item: initialItem, }: { data: Bootstrap; onClose: () => void; - product: Product | null; + item: Item | null; }) { const qc = useQueryClient(); - const overdueFirst = [...data.products] - .filter((p) => p.status === "active") + const allItems = enrichItems(data); + const overdueFirst = [...allItems] + .filter((i) => i.status === "active") .sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a)); - const [productId, setProductId] = useState(initialProduct?.id ?? overdueFirst[0]?.id ?? ""); + const [itemId, setItemId] = useState(initialItem?.id ?? overdueFirst[0]?.id ?? ""); const [date, setDate] = useState(TODAY_STR); - const [confirmedBy, setConfirmedBy] = useState<"SKU" | "asset" | "visual">("SKU"); + const [confirmedBy, setConfirmedBy] = useState<"asset" | "SKU" | "visual">("asset"); - const product = data.products.find((p) => p.id === productId); - const cfg = product ? TYPES.find((t) => t.id === product.type) : undefined; + const item = allItems.find((i) => i.id === itemId); + const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined; - const initialValueFor = (p: Product | undefined): string => { - if (!p) return "0"; - if (p.kind === "discrete") { - return String(p.countLastAudit ?? p.countOriginal); + const initialValueFor = (i: Item | undefined): string => { + if (!i) return "0"; + if (i.kind === "discrete") { + return String(i.countLastAudit ?? i.countOriginal); } - return helpers.estimatedRemaining(p, TODAY_STR).toFixed(2); + return helpers.estimatedRemaining(i, TODAY_STR).toFixed(2); }; - const [value, setValue] = useState(initialValueFor(product)); + const [value, setValue] = useState(initialValueFor(item)); useEffect(() => { - setValue(initialValueFor(product)); - }, [productId]); // eslint-disable-line react-hooks/exhaustive-deps + setValue(initialValueFor(item)); + }, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps const audit = useMutation({ mutationFn: () => - api.auditProduct(productId, { + api.auditInventoryItem(itemId, { date, mode: cfg?.auditMode ?? "weigh", value: Number(value), @@ -70,17 +71,29 @@ export function AuditFlow({ }, }); - if (!product) return null; + const handleScan = (result: ScanResult) => { + if (result.kind === "item") { + setItemId(result.item.id); + } else { + // SKU scan — pick the most recent active instance of that product. + const candidate = overdueFirst + .filter((i) => i.productId === result.product.id) + .sort((a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate))[0]; + if (candidate) setItemId(candidate.id); + } + }; + + if (!item) return null; const auditMode = cfg?.auditMode ?? "weigh"; const ml = AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.weigh!; - const last = helpers.lastAudit(product); + const last = helpers.lastAudit(item); const prevValue = - product.kind === "discrete" - ? product.countLastAudit ?? product.countOriginal + item.kind === "discrete" + ? item.countLastAudit ?? item.countOriginal : last ? last.value - : product.weight; + : item.weight; const delta = Number(value) - prevValue; @@ -100,21 +113,22 @@ export function AuditFlow({
- setItemId(e.target.value)}> + {overdueFirst.map((i) => { + const od = helpers.auditOverdue(i); + const sc = helpers.daysSinceCheck(i); return ( - ); })} @@ -134,16 +148,16 @@ export function AuditFlow({
- {product.name} + {item.name}
- {product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d + {item.assetId} · {item.type} · {item.kind} · cadence every {cfg?.cadenceDays}d
LAST CHECKED
- {last ? `${helpers.daysSinceCheck(product)}d ago` : "Never"} + {last ? `${helpers.daysSinceCheck(item)}d ago` : "Never"}
@@ -162,7 +176,7 @@ export function AuditFlow({ > setValue(e.target.value)} /> @@ -185,8 +199,8 @@ export function AuditFlow({ value={confirmedBy} onChange={(e) => setConfirmedBy(e.target.value as typeof confirmedBy)} > + - @@ -226,7 +240,7 @@ export function AuditFlow({ color: delta < 0 ? "var(--terracotta)" : "var(--ink)", }} > - {delta.toFixed(product.kind === "discrete" ? 0 : 2)} {cfg?.unit} + {delta.toFixed(item.kind === "discrete" ? 0 : 2)} {cfg?.unit}
diff --git a/web/src/components/modals/CatalogModals.tsx b/web/src/components/modals/CatalogModals.tsx index 19c3afc..2a06639 100644 --- a/web/src/components/modals/CatalogModals.tsx +++ b/web/src/components/modals/CatalogModals.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../../api.js"; import { Btn, Field, Input } from "../primitives/index.js"; -import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js"; +import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; export function AddBrandModal({ onClose }: { onClose: () => void }) { const qc = useQueryClient(); diff --git a/web/src/components/modals/ConsumeFlow.tsx b/web/src/components/modals/ConsumeFlow.tsx index f097b39..2511018 100644 --- a/web/src/components/modals/ConsumeFlow.tsx +++ b/web/src/components/modals/ConsumeFlow.tsx @@ -1,43 +1,55 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Bootstrap, Product } from "../../types.js"; -import { helpers, TODAY_STR } from "../../types.js"; +import type { Bootstrap, Item } from "../../types.js"; +import { helpers, TODAY_STR, enrichItems } from "../../types.js"; import { remainingShort } from "../../stats.js"; import { fmt } from "../../format.js"; import { api } from "../../api.js"; import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js"; -import { ScanField } from "../ScanField.js"; -import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js"; +import { ScanField, type ScanResult } from "../ScanField.js"; +import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; export function ConsumeFlow({ data, onClose, - product: initialProduct, + item: initialItem, }: { data: Bootstrap; onClose: () => void; - product: Product | null; + item: Item | null; }) { const qc = useQueryClient(); - const active = data.products.filter((p) => p.status === "active"); - const [productId, setProductId] = useState(initialProduct?.id ?? active[0]?.id ?? ""); + const allItems = enrichItems(data); + const active = allItems.filter((i) => i.status === "active"); + const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? ""); const [rating, setRating] = useState(4); const [notes, setNotes] = useState(""); const [date, setDate] = useState(TODAY_STR); - const product = data.products.find((p) => p.id === productId); + const item = allItems.find((i) => i.id === itemId); const finish = useMutation({ - mutationFn: () => api.finishProduct(productId, { date, rating, notes }), + mutationFn: () => api.finishInventoryItem(itemId, { date, rating, notes }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); onClose(); }, }); - if (!product) return null; - const bin = data.bins.find((b) => b.id === product.binId); - const lifespan = Math.round((+new Date(date) - +new Date(product.purchaseDate)) / 86_400_000); + const handleScan = (result: ScanResult) => { + if (result.kind === "item") { + setItemId(result.item.id); + } else { + const candidate = active + .filter((i) => i.productId === result.product.id) + .sort((a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate))[0]; + if (candidate) setItemId(candidate.id); + } + }; + + if (!item) return null; + const bin = data.bins.find((b) => b.id === item.binId); + const lifespan = Math.round((+new Date(date) - +new Date(item.purchaseDate)) / 86_400_000); return ( @@ -55,17 +67,18 @@ export function ConsumeFlow({
- setItemId(e.target.value)}> + {active.map((i) => ( + ))} @@ -86,11 +99,11 @@ export function ConsumeFlow({ >
- {product.name} + {item.name}
- {helpers.brandName(data, product.brandId)} · {bin?.name} · purchased{" "} - {fmt.dateShort(product.purchaseDate)} + {item.assetId} · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "} + {fmt.dateShort(item.purchaseDate)}
diff --git a/web/src/components/modals/AddProductFlow.tsx b/web/src/components/modals/EditInventoryFlow.tsx similarity index 52% rename from web/src/components/modals/AddProductFlow.tsx rename to web/src/components/modals/EditInventoryFlow.tsx index 2cc7f27..6a15425 100644 --- a/web/src/components/modals/AddProductFlow.tsx +++ b/web/src/components/modals/EditInventoryFlow.tsx @@ -1,34 +1,46 @@ -import { useEffect, useMemo, useState } from "react"; +import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Bootstrap, Strain } from "../../types.js"; -import { TYPES, TODAY_STR } from "../../types.js"; -import { fmt } from "../../format.js"; +import type { Bootstrap, Item } from "../../types.js"; +import { TYPES } from "../../types.js"; +import { fmt, 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"; const NEW_BRAND = "__new_brand__"; const NEW_SHOP = "__new_shop__"; const NEW_BIN = "__new_bin__"; -export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () => void }) { +export function EditInventoryFlow({ + data, + item, + onClose, +}: { + data: Bootstrap; + item: Item; + onClose: () => void; +}) { const qc = useQueryClient(); + const isDiscrete = item.kind === "discrete"; + // form.price is total for bulk, per-unit for discrete. Convert at I/O boundaries. + const initialPrice = + isDiscrete && item.countOriginal > 0 + ? item.price / item.countOriginal + : item.price; + const [form, setForm] = useState({ - name: "", - brandId: data.brands[0]?.id ?? NEW_BRAND, - shopId: data.shops[0]?.id ?? NEW_SHOP, - type: "Flower", - weight: 3.5, - countOriginal: 1, - unitWeight: 0.7, - price: 45, - thc: 22, - cbd: 0.4, - totalCannabinoids: 26, - purchaseDate: TODAY_STR, - binId: data.bins[0]?.id ?? NEW_BIN, - sku: "", - assetTag: "", + brandId: item.brandId ?? NEW_BRAND, + shopId: item.shopId ?? NEW_SHOP, + binId: item.binId ?? NEW_BIN, + weight: item.weight, + countOriginal: item.countOriginal, + unitWeight: item.unitWeight, + price: initialPrice, + thc: item.thc, + cbd: item.cbd, + totalCannabinoids: item.totalCannabinoids, + purchaseDate: item.purchaseDate, }); const [newBrand, setNewBrand] = useState(""); const [newShopName, setNewShopName] = useState(""); @@ -37,49 +49,13 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () const [newBinCapacity, setNewBinCapacity] = useState(10); const [error, setError] = useState(null); - // Track which cannabinoid fields the user has touched. Pre-fill from a - // matched strain only writes into untouched fields, so we never overwrite - // numbers the user just typed. - const [edited, setEdited] = useState({ thc: false, cbd: false, total: false }); - const update = (k: K, v: (typeof form)[K]) => setForm((f) => ({ ...f, [k]: v })); - const cfg = TYPES.find((t) => t.id === form.type); - const isDiscrete = cfg?.kind === "discrete"; - // form.price is total for bulk, per-unit for discrete. + const cfg = TYPES.find((t) => t.id === item.type); const totalPrice = isDiscrete ? form.price * form.countOriginal : form.price; const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0; - // Find an existing strain matching the current name + brand + type. - // Case-insensitive + trimmed, brand can be NEW_BRAND (no match) or null. - const matchedStrain: Strain | null = useMemo(() => { - const name = form.name.trim().toLowerCase(); - if (!name) return null; - if (form.brandId === NEW_BRAND) return null; - return ( - data.strains.find( - (s) => - s.name.trim().toLowerCase() === name && - (s.brandId ?? null) === (form.brandId ?? null) && - s.type === form.type, - ) ?? null - ); - }, [data.strains, form.name, form.brandId, form.type]); - - // Pre-fill cannabinoids from the matched strain into untouched fields. - useEffect(() => { - if (!matchedStrain) return; - setForm((f) => ({ - ...f, - thc: edited.thc ? f.thc : matchedStrain.defaultThc ?? f.thc, - cbd: edited.cbd ? f.cbd : matchedStrain.defaultCbd ?? f.cbd, - totalCannabinoids: edited.total - ? f.totalCannabinoids - : matchedStrain.defaultTotalCannabinoids ?? f.totalCannabinoids, - })); - }, [matchedStrain]); // eslint-disable-line react-hooks/exhaustive-deps - const save = useMutation({ mutationFn: async () => { let { brandId, shopId, binId } = form; @@ -90,7 +66,10 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () } 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() }); + const s = await api.createShop({ + name: newShopName.trim(), + location: newShopLocation.trim(), + }); shopId = s.id; } if (binId === NEW_BIN) { @@ -101,13 +80,18 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () }); binId = b.id; } - return api.createProduct({ - ...form, + return api.updateInventoryItem(item.id, { brandId, shopId, binId, - kind: isDiscrete ? "discrete" : "bulk", + 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: () => { @@ -133,30 +117,54 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () boxShadow: "var(--shadow-lg)", }} > - +
-
Identity
-
- - update("name", e.target.value)} - /> - +
+ + {TYPE_GLYPHS[item.type]} + + + Editing this physical instance of{" "} + {item.name} ({item.type} ·{" "} + {item.kind}). To change the product (SKU, name, type), edit the catalog entry. + +
+ +
+ Source +
+
@@ -164,20 +172,30 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () {isNewBrand && ( - setNewBrand(e.target.value)} placeholder="e.g. Foxglove Farms" /> + setNewBrand(e.target.value)} + placeholder="e.g. Foxglove Farms" + /> )} {isNewShop && ( <> - setNewShopName(e.target.value)} placeholder="e.g. Greenleaf Co-op" /> + setNewShopName(e.target.value)} + placeholder="e.g. Greenleaf Co-op" + /> )} - - - @@ -225,20 +238,19 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () )} - - update("sku", e.target.value)} /> - - - update("assetTag", e.target.value)} - /> -
-
Acquisition
-
+
+ Acquisition +
+
{isDiscrete ? ( <> @@ -288,30 +300,36 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () {!isDiscrete && cpg > 0 && (
Cost per {cfg?.unit ?? "g"}:{" "} - {fmt.money(cpg)} + + {fmt.money(cpg)} +
)} {isDiscrete && form.price > 0 && form.countOriginal > 0 && (
Total:{" "} - {fmt.money(totalPrice)} + + {fmt.money(totalPrice)} + ({form.countOriginal} × {fmt.money(form.price)})
)} -
Cannabinoid profile
+
+ Cannabinoid profile +
{ - setEdited((p) => ({ ...p, thc: true })); - update("thc", +e.target.value); - }} + onChange={(e) => update("thc", +e.target.value)} /> @@ -319,10 +337,7 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () type="number" step="0.1" value={form.cbd} - onChange={(e) => { - setEdited((p) => ({ ...p, cbd: true })); - update("cbd", +e.target.value); - }} + onChange={(e) => update("cbd", +e.target.value)} /> @@ -330,38 +345,44 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () type="number" step="0.1" value={form.totalCannabinoids} - onChange={(e) => { - setEdited((p) => ({ ...p, total: true })); - update("totalCannabinoids", +e.target.value); - }} + onChange={(e) => update("totalCannabinoids", +e.target.value)} />
+ {item.audits.length > 0 && ( +
+ {item.audits.length} audit{item.audits.length === 1 ? "" : "s"} on file — + audit history is preserved unchanged. Editing the original size only updates + the percent-remaining math going forward. +
+ )} + {error && (
{error}
)}
-
- {form.name - ? `"${form.name}" → ${ - isNewBin - ? newBinName.trim() || "new bin" - : data.bins.find((b) => b.id === form.binId)?.name ?? "—" - }.` - : "Fill in the name to continue."} -
+
- Cancel + + Cancel + save.mutate()} > - {save.isPending ? "Saving…" : "Save product"} + {save.isPending ? "Saving…" : "Save changes"}
@@ -369,84 +390,3 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () ); } - -// ─── Shared modal chrome ────────────────────────────────────────── -export function ModalBackdrop({ - children, - onClose, -}: { - children: React.ReactNode; - onClose: () => void; -}) { - return ( -
-
e.stopPropagation()} style={{ width: "100%", display: "flex", justifyContent: "center" }}> - {children} -
-
- ); -} - -export function ModalHeader({ - title, - eyebrow, - eyebrowColor, - onClose, -}: { - title: string; - eyebrow: string; - eyebrowColor?: string; - onClose: () => void; -}) { - return ( -
-
-
- {eyebrow} -
-

- {title} -

-
- -
- ); -} - -export function ModalFooter({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} diff --git a/web/src/components/modals/EditProductFlow.tsx b/web/src/components/modals/EditProductFlow.tsx index 46f5b3c..e0b0ced 100644 --- a/web/src/components/modals/EditProductFlow.tsx +++ b/web/src/components/modals/EditProductFlow.tsx @@ -2,15 +2,14 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Product } from "../../types.js"; import { TYPES } from "../../types.js"; -import { fmt, TYPE_GLYPHS } from "../../format.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 "./AddProductFlow.js"; - -const NEW_BRAND = "__new_brand__"; -const NEW_SHOP = "__new_shop__"; -const NEW_BIN = "__new_bin__"; +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). export function EditProductFlow({ data, product, @@ -22,82 +21,21 @@ export function EditProductFlow({ }) { const qc = useQueryClient(); - const isDiscrete = product.kind === "discrete"; - // form.price is total for bulk, per-unit for discrete. Convert at I/O boundaries. - const initialPrice = - isDiscrete && product.countOriginal > 0 - ? product.price / product.countOriginal - : product.price; - - const [form, setForm] = useState({ - name: product.name, - brandId: product.brandId ?? NEW_BRAND, - shopId: product.shopId ?? NEW_SHOP, - binId: product.binId ?? NEW_BIN, - weight: product.weight, - countOriginal: product.countOriginal, - unitWeight: product.unitWeight, - price: initialPrice, - thc: product.thc, - cbd: product.cbd, - totalCannabinoids: product.totalCannabinoids, - purchaseDate: product.purchaseDate, - assetTag: product.assetTag ?? "", - }); - const [newBrand, setNewBrand] = useState(""); - const [newShopName, setNewShopName] = useState(""); - const [newShopLocation, setNewShopLocation] = useState(""); - const [newBinName, setNewBinName] = useState(""); - const [newBinCapacity, setNewBinCapacity] = useState(10); + const [name, setName] = useState(product.name); + const [type, setType] = useState(product.type); + const [strainId, setStrainId] = useState(product.strainId ?? ""); const [error, setError] = useState(null); - const update = (k: K, v: (typeof form)[K]) => - setForm((f) => ({ ...f, [k]: v })); - - const cfg = TYPES.find((t) => t.id === product.type); - const totalPrice = isDiscrete ? form.price * form.countOriginal : form.price; - const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0; + const cfg = TYPES.find((t) => t.id === type); 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.updateProduct(product.id, { - name: form.name.trim(), - brandId, - shopId, - binId, - assetTag: form.assetTag.trim() || null, - 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, - }); - }, + mutationFn: () => + api.updateProduct(product.id, { + name: name.trim(), + type, + kind: cfg?.kind ?? product.kind, + strainId: strainId || null, + }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); onClose(); @@ -105,15 +43,11 @@ export function EditProductFlow({ onError: (e: Error) => setError(e.message), }); - const isNewBrand = form.brandId === NEW_BRAND; - const isNewShop = form.shopId === NEW_SHOP; - const isNewBin = form.binId === NEW_BIN; - return (
- +
- Type {product.type} ({product.kind}) is locked. To change type, mark this product gone and add a new one. + SKU {product.sku}{" "} + is locked. Edit individual purchases (price, brand, batch THC) from the + inventory drawer.
-
Identity
-
- +
+ update("name", e.target.value)} + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="e.g. Garden Ghost 3.5g" /> - - setType(e.target.value)}> + {TYPES.map((t) => ( + ))} - - - setStrainId(e.target.value)}> + + {data.strains.map((s) => ( + ))} - - {isNewBrand && ( - - setNewBrand(e.target.value)} placeholder="e.g. Foxglove Farms" /> - - )} - {isNewShop && ( - <> - - setNewShopName(e.target.value)} placeholder="e.g. Greenleaf Co-op" /> - - - setNewShopLocation(e.target.value)} - placeholder="e.g. Capitol Hill" - /> - - - )} - - - - - update("assetTag", e.target.value)} - /> - - {isNewBin && ( - <> - - setNewBinName(e.target.value)} - placeholder="e.g. A1" - /> - - - - setNewBinCapacity(Math.max(1, Math.floor(+e.target.value || 1))) - } - /> - - - )}
-
Acquisition
-
- {isDiscrete ? ( - <> - - update("countOriginal", +e.target.value)} - /> - - - update("unitWeight", +e.target.value)} - /> - - - ) : ( - - update("weight", +e.target.value)} - /> - - )} - - update("price", +e.target.value)} - /> - - - update("purchaseDate", e.target.value)} - /> - -
- - {!isDiscrete && cpg > 0 && ( -
- Cost per {cfg?.unit ?? "g"}:{" "} - {fmt.money(cpg)} -
- )} - {isDiscrete && form.price > 0 && form.countOriginal > 0 && ( -
- Total:{" "} - {fmt.money(totalPrice)} - - ({form.countOriginal} × {fmt.money(form.price)}) - -
- )} - -
Cannabinoid profile
-
- - update("thc", +e.target.value)} - /> - - - update("cbd", +e.target.value)} - /> - - - update("totalCannabinoids", +e.target.value)} - /> - -
- - {product.audits.length > 0 && ( -
- {product.audits.length} audit{product.audits.length === 1 ? "" : "s"} on file — - audit history is preserved unchanged. Editing the original size only updates - the percent-remaining math going forward. -
- )} - {error && (
{error}
)} @@ -344,14 +123,16 @@ export function EditProductFlow({
- Cancel + + Cancel + save.mutate()} > - {save.isPending ? "Saving…" : "Save changes"} + {save.isPending ? "Saving…" : "Save"}
diff --git a/web/src/components/modals/MarkGoneFlow.tsx b/web/src/components/modals/MarkGoneFlow.tsx index 7bd06c1..040367e 100644 --- a/web/src/components/modals/MarkGoneFlow.tsx +++ b/web/src/components/modals/MarkGoneFlow.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Bootstrap, Product } from "../../types.js"; -import { helpers, TODAY_STR } from "../../types.js"; +import type { Bootstrap, Item } from "../../types.js"; +import { helpers, TODAY_STR, enrichItems } from "../../types.js"; import { remainingShort } from "../../stats.js"; import { api } from "../../api.js"; import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js"; -import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js"; +import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; const REASONS: [string, string][] = [ ["lost", "Lost / misplaced"], @@ -18,29 +18,30 @@ const REASONS: [string, string][] = [ export function MarkGoneFlow({ data, onClose, - product: initialProduct, + item: initialItem, }: { data: Bootstrap; onClose: () => void; - product: Product | null; + item: Item | null; }) { const qc = useQueryClient(); - const active = data.products.filter((p) => p.status === "active"); - const [productId, setProductId] = useState(initialProduct?.id ?? active[0]?.id ?? ""); + const allItems = enrichItems(data); + const active = allItems.filter((i) => i.status === "active"); + const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? ""); const [reason, setReason] = useState("lost"); const [notes, setNotes] = useState(""); const [date, setDate] = useState(TODAY_STR); - const product = data.products.find((p) => p.id === productId); + const item = allItems.find((i) => i.id === itemId); const mark = useMutation({ - mutationFn: () => api.markGone(productId, { date, reason, notes }), + mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); onClose(); }, }); - if (!product) return null; + if (!item) return null; return ( @@ -76,11 +77,11 @@ export function MarkGoneFlow({ spend but not as consumption, so daily averages stay accurate.
- - setItemId(e.target.value)}> + {active.map((i) => ( + ))} diff --git a/web/src/components/modals/ModalChrome.tsx b/web/src/components/modals/ModalChrome.tsx new file mode 100644 index 0000000..ced3ac7 --- /dev/null +++ b/web/src/components/modals/ModalChrome.tsx @@ -0,0 +1,84 @@ +import { Btn } from "../primitives/index.js"; + +export function ModalBackdrop({ + children, + onClose, +}: { + children: React.ReactNode; + onClose: () => void; +}) { + return ( +
+
e.stopPropagation()} + style={{ width: "100%", display: "flex", justifyContent: "center" }} + > + {children} +
+
+ ); +} + +export function ModalHeader({ + title, + eyebrow, + eyebrowColor, + onClose, +}: { + title: string; + eyebrow: string; + eyebrowColor?: string; + onClose: () => void; +}) { + return ( +
+
+
+ {eyebrow} +
+

+ {title} +

+
+ +
+ ); +} + +export function ModalFooter({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/web/src/stats.ts b/web/src/stats.ts index 542fffa..dfe2bf2 100644 --- a/web/src/stats.ts +++ b/web/src/stats.ts @@ -1,10 +1,10 @@ -// computeStats — ported from primitives.jsx:41-235 -// Derives daily/weekly/monthly grams from purchase + audit history, -// using estimated remaining for active items and full weight for consumed. -// Gone items contribute spend but NOT grams (so daily averages stay clean). +// computeStats — derives daily/weekly/monthly grams from purchase + audit +// history, using estimated remaining for active items and full weight for +// consumed. Gone items contribute spend but NOT grams (so daily averages +// stay clean). Operates on the enriched Item[] view, not raw products. -import type { Bootstrap, Product } from "./types.js"; -import { TYPES, TODAY_STR, helpers } from "./types.js"; +import type { Bootstrap, Item } from "./types.js"; +import { TYPES, TODAY_STR, helpers, enrichItems } from "./types.js"; export interface Stats { dailyAvg: number; @@ -34,14 +34,15 @@ export interface Stats { consumedCount: number; goneCount: number; archivedCount: number; - overdueAudits: Product[]; - lowStockBulk: Product[]; + purchaseCount: number; + overdueAudits: Item[]; + lowStockBulk: Item[]; lowStockDiscreteGroups: { key: string; name: string; type: string; brandId: string | null; - items: Product[]; + items: Item[]; totalCount: number; }[]; } @@ -49,33 +50,33 @@ export interface Stats { export function computeStats(data: Bootstrap): Stats { const today = new Date(data.today || TODAY_STR); const todayStr = today.toISOString().slice(0, 10); - const products = data.products; + const items = enrichItems(data); const dayKey = (d: Date) => d.toISOString().slice(0, 10); - const active = products.filter((p) => p.status === "active"); - const consumed = products.filter((p) => p.status === "consumed" && p.consumedDate); - const gone = products.filter((p) => p.status === "gone"); + const active = items.filter((p) => p.status === "active"); + const consumed = items.filter((p) => p.status === "consumed" && p.consumedDate); + const gone = items.filter((p) => p.status === "gone"); const purchasesIn = (days: number) => { const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - days); - return products.filter((p) => new Date(p.purchaseDate) >= cutoff); + return items.filter((p) => new Date(p.purchaseDate) >= cutoff); }; const last7p = purchasesIn(7); const last30p = purchasesIn(30); const last90p = purchasesIn(90); - const bulkGrams = (p: Product): number => { + const bulkGrams = (p: Item): number => { if (p.type === "Tincture" || p.type === "Edible") return 0; if (p.kind === "bulk") return p.weight; return (p.countOriginal || 0) * (p.unitWeight || 0); }; - const bulkGramsConsumed = (p: Product): number => { + const bulkGramsConsumed = (p: Item): number => { if (p.type === "Tincture" || p.type === "Edible") return 0; if (p.kind === "bulk") return p.weight; return (p.countOriginal || 0) * (p.unitWeight || 0); }; - const bulkGramsUsedSoFar = (p: Product): number => { + const bulkGramsUsedSoFar = (p: Item): number => { if (p.type === "Tincture" || p.type === "Edible") return 0; if (p.kind === "bulk") { const est = helpers.estimatedRemaining(p, todayStr); @@ -133,9 +134,9 @@ export function computeStats(data: Bootstrap): Stats { const weeklyAvg = sumG(series30) / (30 / 7); const monthlyAvg = sumG(series90) / 3; - const totalSpend = products.reduce((s, p) => s + p.price, 0); + const totalSpend = items.reduce((s, p) => s + p.price, 0); const goneSpend = gone.reduce((s, p) => s + p.price, 0); - const totalGrams = products.reduce((s, p) => s + bulkGrams(p), 0); + const totalGrams = items.reduce((s, p) => s + bulkGrams(p), 0); const avgPerGram = totalGrams ? totalSpend / totalGrams : 0; const spend30 = last30p.reduce((s, p) => s + p.price, 0); const spend7 = last7p.reduce((s, p) => s + p.price, 0); @@ -157,7 +158,7 @@ export function computeStats(data: Bootstrap): Stats { }, 0); const avgThc = - products.length > 0 ? products.reduce((s, p) => s + p.thc, 0) / products.length : 20; + items.length > 0 ? items.reduce((s, p) => s + p.thc, 0) / items.length : 20; const thcLast7 = Math.round(sumG(series7) * avgThc * 10); const thcLast30 = Math.round(sumG(series30) * avgThc * 10); @@ -172,7 +173,7 @@ export function computeStats(data: Bootstrap): Stats { const shopCount: Record = {}; const brandCount: Record = {}; - products.forEach((p) => { + items.forEach((p) => { if (p.shopId) shopCount[p.shopId] = (shopCount[p.shopId] || 0) + 1; if (p.brandId) brandCount[p.brandId] = (brandCount[p.brandId] || 0) + 1; }); @@ -204,7 +205,7 @@ export function computeStats(data: Bootstrap): Stats { }, 0); const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0; - const sortedDates = [...products] + const sortedDates = [...items] .sort((a, b) => +new Date(a.purchaseDate) - +new Date(b.purchaseDate)) .map((p) => new Date(p.purchaseDate)); const gaps: number[] = []; @@ -219,16 +220,18 @@ export function computeStats(data: Bootstrap): Stats { (p) => p.kind === "bulk" && helpers.pctRemaining(p, todayStr) < 0.25, ); - const discreteBrandGroups: Record< + // Group discrete instances by product so multiple jars of the same + // pre-roll/edible product collapse into a single "running low" row. + const discreteGroups: Record< string, - { key: string; name: string; type: string; brandId: string | null; items: Product[]; totalCount: number } + { key: string; name: string; type: string; brandId: string | null; items: Item[]; totalCount: number } > = {}; active .filter((p) => p.kind === "discrete") .forEach((p) => { - const k = `${p.brandId}|${p.type}|${p.name}`; - if (!discreteBrandGroups[k]) { - discreteBrandGroups[k] = { + const k = p.productId; + if (!discreteGroups[k]) { + discreteGroups[k] = { key: k, name: p.name, type: p.type, @@ -237,10 +240,10 @@ export function computeStats(data: Bootstrap): Stats { totalCount: 0, }; } - discreteBrandGroups[k].items.push(p); - discreteBrandGroups[k].totalCount += p.countLastAudit ?? p.countOriginal; + discreteGroups[k].items.push(p); + discreteGroups[k].totalCount += p.countLastAudit ?? p.countOriginal; }); - const lowStockDiscreteGroups = Object.values(discreteBrandGroups).filter( + const lowStockDiscreteGroups = Object.values(discreteGroups).filter( (g) => g.totalCount <= 2, ); @@ -272,6 +275,7 @@ export function computeStats(data: Bootstrap): Stats { consumedCount: consumed.length, goneCount: gone.length, archivedCount: consumed.length + gone.length, + purchaseCount: items.length, overdueAudits, lowStockBulk, lowStockDiscreteGroups, @@ -279,7 +283,7 @@ export function computeStats(data: Bootstrap): Stats { } // Display helpers used throughout the UI -export function remainingShort(p: Product): string { +export function remainingShort(p: Item): string { const cfg = TYPES.find((t) => t.id === p.type); if (p.kind === "discrete") { const cur = p.countLastAudit ?? p.countOriginal; diff --git a/web/src/types.ts b/web/src/types.ts index 8271aa9..559b8c1 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -10,40 +10,62 @@ export interface Audit { confirmedBy: string | null; } +// Product = catalog entry. Identified by SKU. One row per "kind of thing +// you can scan" — strain + form factor (Flower/bulk, Pre-roll/discrete, …). export interface Product { id: string; sku: string; - assetTag: string | null; + strainId: string | null; name: string; + 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. +export interface InventoryItem { + id: string; + assetId: string; + productId: string; brandId: string | null; shopId: string | null; binId: string | null; - type: string; - kind: ProductKind; + price: number; + thc: number; + cbd: number; + totalCannabinoids: number; weight: number; lastAuditWeight: number | null; countOriginal: number; countLastAudit: number | null; unitWeight: number; - price: number; - thc: number; - cbd: number; - totalCannabinoids: number; purchaseDate: string; status: ProductStatus; consumedDate: string | null; goneDate: string | null; rating: number | null; notes: string | null; - strainId: string | null; audits: Audit[]; } +// 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. +export interface Item extends InventoryItem { + name: string; + sku: string; + type: string; + kind: ProductKind; + strainId: string | null; + strainName: string | null; +} + export interface Strain { id: string; name: string; - brandId: string | null; - type: string; defaultThc: number | null; defaultCbd: number | null; defaultTotalCannabinoids: number | null; @@ -78,6 +100,7 @@ export interface TypeConfig { export interface Bootstrap { products: Product[]; + inventoryItems: InventoryItem[]; brands: Brand[]; shops: Shop[]; bins: Bin[]; @@ -86,7 +109,6 @@ export interface Bootstrap { } // Type config lives client-side — static, not user data. -// Mirrors data.js TYPES array. export const TYPES: TypeConfig[] = [ { id: "Flower", kind: "bulk", auditMode: "weigh", cadenceDays: 14, unit: "g", weighable: true }, { id: "Concentrate", kind: "bulk", auditMode: "estimate", cadenceDays: 21, unit: "g", weighable: true }, @@ -106,7 +128,49 @@ export const TODAY_STR = (() => { return `${y}-${m}-${day}`; })(); -// Helpers — match data.js DATA_HELPERS API +// Build the joined Item[] view from bootstrap. Inventory items are dropped +// silently if they reference a missing product — that shouldn't happen in +// practice (server enforces the FK) and skipping is safer than crashing. +export function enrichItems(data: Bootstrap): Item[] { + const productMap = new Map(data.products.map((p) => [p.id, p])); + const strainMap = new Map(data.strains.map((s) => [s.id, s])); + const out: 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; + out.push({ + ...inv, + name: product.name, + sku: product.sku, + type: product.type, + kind: product.kind, + strainId: product.strainId, + strainName: strain?.name ?? null, + }); + } + return out; +} + +// Find the most recent inventory item for a product — used to autofill brand, +// shop, price, and cannabinoids when scanning a SKU we've bought before. +// Sorted by purchaseDate desc, then id desc as tiebreaker. +export function getLastInstance( + items: InventoryItem[], + productId: string, +): InventoryItem | null { + const matches = items + .filter((i) => i.productId === productId) + .sort((a, b) => { + const d = +new Date(b.purchaseDate) - +new Date(a.purchaseDate); + if (d !== 0) return d; + return b.id.localeCompare(a.id); + }); + return matches[0] ?? null; +} + +// Helpers — match data.js DATA_HELPERS API. Operate on Item (the joined +// inventory + product view) so views and stats can call them as `helpers.x(p)`. export const helpers = { shopName(data: { shops: Shop[] }, id: string | null): string { return data.shops.find((s) => s.id === id)?.name ?? "—"; @@ -121,20 +185,20 @@ export const helpers = { if (!iso) return Infinity; return Math.floor((+new Date(today) - +new Date(iso)) / 86_400_000); }, - lastAudit(p: Product): Audit | null { + lastAudit(p: Item): Audit | null { return p.audits.length > 0 ? p.audits[p.audits.length - 1]! : null; }, - daysSinceCheck(p: Product, today = TODAY_STR): number { + daysSinceCheck(p: Item, today = TODAY_STR): number { const last = p.audits.length > 0 ? p.audits[p.audits.length - 1]!.date : p.purchaseDate; return Math.floor((+new Date(today) - +new Date(last)) / 86_400_000); }, - auditOverdue(p: Product, today = TODAY_STR): boolean { + auditOverdue(p: Item, today = TODAY_STR): boolean { if (p.status !== "active") return false; const cfg = TYPES.find((t) => t.id === p.type); if (!cfg) return false; return this.daysSinceCheck(p, today) >= cfg.cadenceDays; }, - estimatedRemaining(p: Product, today = TODAY_STR): number { + estimatedRemaining(p: Item, today = TODAY_STR): number { if (p.status !== "active") return 0; if (p.kind === "discrete") { return p.countLastAudit ?? p.countOriginal; @@ -151,7 +215,7 @@ export const helpers = { const dailyBurn = p.weight / expectedLifespan; return Math.max(0, baseValue - dailyBurn * daysSinceBase); }, - pctRemaining(p: Product, today = TODAY_STR): number { + pctRemaining(p: Item, today = TODAY_STR): number { if (p.kind === "discrete") { const cur = p.countLastAudit ?? p.countOriginal; return p.countOriginal > 0 ? cur / p.countOriginal : 0; diff --git a/web/src/views/BinsView.tsx b/web/src/views/BinsView.tsx index f6a3afc..da19428 100644 --- a/web/src/views/BinsView.tsx +++ b/web/src/views/BinsView.tsx @@ -1,6 +1,7 @@ +import { useMemo } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Bootstrap, Bin, Product } from "../types.js"; -import { helpers, TODAY_STR } from "../types.js"; +import type { Bootstrap, Bin, Item } from "../types.js"; +import { helpers, TODAY_STR, enrichItems } from "../types.js"; import { remainingShort } from "../stats.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; import { api } from "../api.js"; @@ -38,16 +39,18 @@ function groupBins(bins: Bin[]): [string, Bin[]][] { export function BinsView({ data, - onSelectProduct, + onSelectItem, onAddBin, onEditBin, }: { data: Bootstrap; - onSelectProduct: (p: Product) => void; + onSelectItem: (i: Item) => void; onAddBin: () => void; onEditBin: (bin: Bin) => void; }) { const qc = useQueryClient(); + const items = useMemo(() => enrichItems(data), [data]); + const remove = useMutation({ mutationFn: (id: string) => api.deleteBin(id), onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }), @@ -56,7 +59,7 @@ export function BinsView({ const handleDelete = (binId: string, binName: string, activeCount: number) => { const msg = activeCount > 0 - ? `Delete "${binName}"? ${activeCount} active product${activeCount === 1 ? "" : "s"} will be moved to Unassigned.` + ? `Delete "${binName}"? ${activeCount} active item${activeCount === 1 ? "" : "s"} will be moved to Unassigned.` : `Delete "${binName}"?`; if (window.confirm(msg)) remove.mutate(binId); }; @@ -84,14 +87,14 @@ export function BinsView({ New bin
- Where each active product physically lives. Archived items aren't assigned to a bin. + Where each active inventory item physically lives. Archived items aren't assigned to a bin.
{data.bins.length === 0 && (
No bins yet
- Add a bin to start placing products somewhere. + Add a bin to start placing items somewhere.
Add your first bin
@@ -107,19 +110,19 @@ export function BinsView({ }} > {bins.map((bin) => { - const items = data.products.filter( - (p) => p.binId === bin.id && p.status === "active", + const binItems = items.filter( + (i) => i.binId === bin.id && i.status === "active", ); - // Discrete products (pre-rolls, edibles, vapes) take a slot per unit; - // bulk products take one slot per jar/container. - const slotsUsed = items.reduce( - (s, p) => - s + (p.kind === "discrete" ? (p.countLastAudit ?? p.countOriginal) : 1), + // Discrete items (pre-rolls, edibles, vapes) take a slot per unit; + // bulk items take one slot per jar/container. + const slotsUsed = binItems.reduce( + (s, i) => + s + (i.kind === "discrete" ? (i.countLastAudit ?? i.countOriginal) : 1), 0, ); const fillPct = slotsUsed / bin.capacity; - const totalValue = items.reduce( - (s, p) => s + p.price * helpers.pctRemaining(p, TODAY_STR), + const totalValue = binItems.reduce( + (s, i) => s + i.price * helpers.pctRemaining(i, TODAY_STR), 0, ); return ( @@ -156,7 +159,7 @@ export function BinsView({
- {items.length === 0 && ( + {binItems.length === 0 && (
)} - {items.map((p) => ( + {binItems.map((i) => (
onSelectProduct(p)} + key={i.id} + onClick={() => onSelectItem(i)} style={{ display: "flex", alignItems: "center", @@ -242,7 +245,7 @@ export function BinsView({ width: 18, }} > - {TYPE_GLYPHS[p.type]} + {TYPE_GLYPHS[i.type]}
- {p.name} + {i.name}
- {helpers.brandName(data, p.brandId)} + {i.assetId} ·{" "} + {helpers.brandName(data, i.brandId)}
- {remainingShort(p)} + {remainingShort(i)}
))} diff --git a/web/src/views/BrandsView.tsx b/web/src/views/BrandsView.tsx index d737e78..27eb26c 100644 --- a/web/src/views/BrandsView.tsx +++ b/web/src/views/BrandsView.tsx @@ -18,12 +18,9 @@ export function BrandsView({ onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }), }); - const handleDelete = (brandId: string, brandName: string, productCount: number, strainCount: number) => { - const parts: string[] = []; - if (productCount > 0) parts.push(`${productCount} product${productCount === 1 ? "" : "s"}`); - if (strainCount > 0) parts.push(`${strainCount} strain${strainCount === 1 ? "" : "s"}`); - const tail = parts.length > 0 - ? ` ${parts.join(" and ")} will be unbranded.` + const handleDelete = (brandId: string, brandName: string, itemCount: number) => { + const tail = itemCount > 0 + ? ` ${itemCount} inventory item${itemCount === 1 ? "" : "s"} will be unbranded.` : ""; if (window.confirm(`Delete "${brandName}"?${tail}`)) remove.mutate(brandId); }; @@ -71,8 +68,7 @@ export function BrandsView({ }} > {data.brands.map((b) => { - const productCount = data.products.filter((p) => p.brandId === b.id).length; - const strainCount = data.strains.filter((s) => s.brandId === b.id).length; + const itemCount = data.inventoryItems.filter((i) => i.brandId === b.id).length; return (
@@ -81,7 +77,7 @@ export function BrandsView({
- {productCount} purchase{productCount === 1 ? "" : "s"} + {itemCount} purchase{itemCount === 1 ? "" : "s"}
@@ -282,7 +290,7 @@ export function Dashboard({ {stats.favShop[0]}
- {stats.favShop[1]} of {data.products.length} purchases + {stats.favShop[1]} of {stats.purchaseCount} purchases
@@ -315,12 +323,12 @@ export function Dashboard({ Nothing running low.
)} - {lowBulk.slice(0, 3).map((p) => { - const pct = helpers.pctRemaining(p, TODAY_STR); + {lowBulk.slice(0, 3).map((i) => { + const pct = helpers.pctRemaining(i, TODAY_STR); return (
onSelectProduct(p)} + key={i.id} + onClick={() => onSelectItem(i)} style={{ padding: "12px 24px", borderTop: "1px solid var(--line)", @@ -340,10 +348,10 @@ export function Dashboard({ textOverflow: "ellipsis", }} > - {p.name} + {i.name}
- {helpers.brandName(data, p.brandId)} · {p.type} + {helpers.brandName(data, i.brandId)} · {i.type}
- {remainingShort(p)} + {remainingShort(i)}
); @@ -375,7 +383,7 @@ export function Dashboard({ {lowDiscrete.slice(0, 2).map((g) => (
onSelectProduct(g.items[0]!)} + onClick={() => onSelectItem(g.items[0]!)} style={{ padding: "12px 24px", borderTop: "1px solid var(--line)", diff --git a/web/src/views/Inventory.tsx b/web/src/views/Inventory.tsx index 71db037..9527744 100644 --- a/web/src/views/Inventory.tsx +++ b/web/src/views/Inventory.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; -import type { Bootstrap, Product } from "../types.js"; -import { TYPES, helpers, TODAY_STR } from "../types.js"; +import type { Bootstrap, Item } from "../types.js"; +import { TYPES, helpers, TODAY_STR, enrichItems } from "../types.js"; import { remainingShort } from "../stats.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; import { Btn, Card, Pill, Icon, Select, inputStyle } from "../components/primitives/index.js"; @@ -13,15 +13,17 @@ const GRID_COLS = "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr"; export function Inventory({ data, - onSelectProduct, - onAddProduct, + onSelectItem, + onAddInventory, onAuditNew, }: { data: Bootstrap; - onSelectProduct: (p: Product) => void; - onAddProduct: () => void; + onSelectItem: (i: Item) => void; + onAddInventory: () => void; onAuditNew: () => void; }) { + const items = useMemo(() => enrichItems(data), [data]); + const [filter, setFilter] = useState("active"); const [typeFilter, setTypeFilter] = useState("all"); const [sortBy, setSortBy] = useState("recent"); @@ -33,7 +35,7 @@ export function Inventory({ localStorage.setItem("apothecary.inventoryView", view); }, [view]); - const sortFn = (a: Product, b: Product) => { + const sortFn = (a: Item, b: Item) => { if (sortBy === "recent") return +new Date(b.purchaseDate) - +new Date(a.purchaseDate); if (sortBy === "name") return a.name.localeCompare(b.name); if (sortBy === "thc") return b.thc - a.thc; @@ -45,73 +47,66 @@ export function Inventory({ return 0; }; - const filteredProducts = useMemo(() => { - let products = data.products; - if (filter === "active") products = products.filter((p) => p.status === "active"); - else if (filter === "consumed") products = products.filter((p) => p.status === "consumed"); - else if (filter === "gone") products = products.filter((p) => p.status === "gone"); - if (typeFilter !== "all") products = products.filter((p) => p.type === typeFilter); + const filtered = useMemo(() => { + let out = items; + if (filter === "active") out = out.filter((i) => i.status === "active"); + else if (filter === "consumed") out = out.filter((i) => i.status === "consumed"); + else if (filter === "gone") out = out.filter((i) => i.status === "gone"); + if (typeFilter !== "all") out = out.filter((i) => i.type === typeFilter); if (search) { const q = search.toLowerCase(); - products = products.filter((p) => { - const brand = helpers.brandName(data, p.brandId).toLowerCase(); - const shop = helpers.shopName(data, p.shopId).toLowerCase(); + out = out.filter((i) => { + const brand = helpers.brandName(data, i.brandId).toLowerCase(); + const shop = helpers.shopName(data, i.shopId).toLowerCase(); return ( - p.name.toLowerCase().includes(q) || + i.name.toLowerCase().includes(q) || brand.includes(q) || shop.includes(q) || - p.sku.toLowerCase().includes(q) + i.sku.toLowerCase().includes(q) || + i.assetId.toLowerCase().includes(q) || + (i.strainName?.toLowerCase().includes(q) ?? false) ); }); } - return products; - }, [data, filter, typeFilter, search]); + return out; + }, [items, data, filter, typeFilter, search]); - const sortedProducts = useMemo( - () => [...filteredProducts].sort(sortFn), - [filteredProducts, sortBy], - ); + const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy]); - // For grouped mode: bucket by strainId. Products without a strainId fall - // into an "Unlinked" bucket at the end. Within each group, sort by sortFn. + // Grouped mode: bucket by productId. Same-product instances collapse under + // a header that shows total count + total remaining + last purchase. type Group = { - strainId: string | null; + productId: string; label: string; - brand: string; + sku: string; type: string; - products: Product[]; + items: Item[]; }; const groups: Group[] = useMemo(() => { - const byStrain = new Map(); - for (const p of filteredProducts) { - const arr = byStrain.get(p.strainId) ?? []; - arr.push(p); - byStrain.set(p.strainId, arr); + const byProduct = new Map(); + for (const i of filtered) { + const arr = byProduct.get(i.productId) ?? []; + arr.push(i); + byProduct.set(i.productId, arr); } const out: Group[] = []; - for (const [strainId, products] of byStrain.entries()) { - const first = products[0]!; - // Prefer the strain's canonical name when available so casing is - // consistent regardless of which product was added first. - const strain = strainId ? data.strains.find((s) => s.id === strainId) : null; + for (const [productId, list] of byProduct.entries()) { + const first = list[0]!; out.push({ - strainId, - label: strain?.name ?? first.name, - brand: helpers.brandName(data, first.brandId), + productId, + label: first.name, + sku: first.sku, type: first.type, - products: [...products].sort(sortFn), + items: [...list].sort(sortFn), }); } - // Order groups by their most-recent purchase date desc so newest strains float up. out.sort((a, b) => { - if (a.strainId === null) return 1; - if (b.strainId === null) return -1; - const aMax = Math.max(...a.products.map((p) => +new Date(p.purchaseDate))); - const bMax = Math.max(...b.products.map((p) => +new Date(p.purchaseDate))); + const aMax = Math.max(...a.items.map((p) => +new Date(p.purchaseDate))); + const bMax = Math.max(...b.items.map((p) => +new Date(p.purchaseDate))); return bMax - aMax; }); return out; - }, [filteredProducts, data, sortBy]); + }, [filtered, sortBy]); return (
- {sortedProducts.length} item{sortedProducts.length === 1 ? "" : "s"} + {sorted.length} item{sorted.length === 1 ? "" : "s"}

Audit - New product + Add inventory

@@ -156,7 +151,7 @@ export function Inventory({ value={view} options={[ ["flat", "Flat"], - ["grouped", "Grouped"], + ["grouped", "By product"], ]} onChange={setView} /> @@ -176,7 +171,7 @@ export function Inventory({ > setSearch(e.target.value)} style={{ @@ -219,21 +214,21 @@ export function Inventory({ - {sortedProducts.length === 0 && ( + {sorted.length === 0 && (
No items match these filters.
)} {view === "flat" && - sortedProducts.map((p) => ( - + sorted.map((i) => ( + ))} {view === "grouped" && groups.map((g) => ( -
+
- {g.products.map((p) => ( - + {g.items.map((i) => ( + ))}
))} @@ -301,7 +296,7 @@ function HeaderRow() { }} >
-
Product
+
Item
Brand
Shop
THC %
@@ -317,24 +312,23 @@ function GroupHeader({ group, }: { group: { - strainId: string | null; + productId: string; label: string; - brand: string; + sku: string; type: string; - products: Product[]; + items: Item[]; }; }) { // Aggregate remaining: bulk uses estimatedRemaining; discrete uses unitWeight × count. // Counts use status === "active" only — archived rows shouldn't inflate "on hand." - const active = group.products.filter((p) => p.status === "active"); - const totalRemaining = active.reduce((s, p) => { - if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, TODAY_STR); - const cur = p.countLastAudit ?? p.countOriginal; - return s + cur * (p.unitWeight || 0); + const active = group.items.filter((i) => i.status === "active"); + const totalRemaining = active.reduce((s, i) => { + if (i.kind === "bulk") return s + helpers.estimatedRemaining(i, TODAY_STR); + const cur = i.countLastAudit ?? i.countOriginal; + return s + cur * (i.unitWeight || 0); }, 0); - const totalCount = active.length; - const lastBuy = group.products.reduce((max, p) => { - const t = +new Date(p.purchaseDate); + const lastBuy = group.items.reduce((max, i) => { + const t = +new Date(i.purchaseDate); return t > max ? t : max; }, 0); const cfg = TYPES.find((t) => t.id === group.type); @@ -356,20 +350,17 @@ function GroupHeader({ {TYPE_GLYPHS[group.type]}
- {group.strainId === null ? "Unlinked" : group.label} + {group.label}
- {group.brand} · {group.type} + {group.sku} · {group.type}
- - {totalCount} - {" "} - {totalCount === 1 ? "active" : "active"} - {group.products.length !== totalCount && ( - / {group.products.length} total + {active.length} active + {group.items.length !== active.length && ( + / {group.items.length} total )}
@@ -380,7 +371,10 @@ function GroupHeader({
{lastBuy > 0 && (
- last buy {fmt.dateShort(new Date(lastBuy).toISOString())} + last buy{" "} + + {fmt.dateShort(new Date(lastBuy).toISOString())} +
)}
@@ -388,26 +382,26 @@ function GroupHeader({ ); } -function ProductRow({ - p, +function ItemRow({ + i, data, onSelect, indented = false, }: { - p: Product; + i: Item; data: Bootstrap; - onSelect: (p: Product) => void; + onSelect: (i: Item) => void; indented?: boolean; }) { - const bin = data.bins.find((b) => b.id === p.binId); - const pctRemaining = helpers.pctRemaining(p, TODAY_STR); - const overdue = helpers.auditOverdue(p, TODAY_STR); - const sinceCheck = helpers.daysSinceCheck(p, TODAY_STR); - const last = helpers.lastAudit(p); - const isInactive = p.status !== "active"; + const bin = data.bins.find((b) => b.id === i.binId); + const pctRemaining = helpers.pctRemaining(i, TODAY_STR); + const overdue = helpers.auditOverdue(i, TODAY_STR); + const sinceCheck = helpers.daysSinceCheck(i, TODAY_STR); + const last = helpers.lastAudit(i); + const isInactive = i.status !== "active"; return (
onSelect(p)} + onClick={() => onSelect(i)} className="inv-row" style={{ display: "grid", @@ -430,7 +424,7 @@ function ProductRow({ opacity: indented ? 0.5 : 1, }} > - {TYPE_GLYPHS[p.type]} + {TYPE_GLYPHS[i.type]}
- {p.name} - {p.status === "consumed" && ( + {i.name} + {i.status === "consumed" && ( Consumed )} - {p.status === "gone" && ( + {i.status === "gone" && ( Gone )} - {p.status === "active" && overdue && ( + {i.status === "active" && overdue && ( Audit due )}
- {p.sku} - {p.assetTag ? ` · ${p.assetTag}` : ""} + {i.assetId} · {i.sku}
-
{helpers.brandName(data, p.brandId)}
-
{helpers.shopName(data, p.shopId)}
-
{p.thc.toFixed(1)}
-
{fmt.money(p.price)}
+
{helpers.brandName(data, i.brandId)}
+
{helpers.shopName(data, i.shopId)}
+
{i.thc.toFixed(1)}
+
{fmt.money(i.price)}
-
{remainingShort(p)}
- {p.status === "active" && p.kind === "bulk" && ( +
{remainingShort(i)}
+ {i.status === "active" && i.kind === "bulk" && (
- {p.status !== "active" ? ( + {i.status !== "active" ? ( archived ) : last ? ( diff --git a/web/src/views/SettingsView.tsx b/web/src/views/SettingsView.tsx index c79adf1..c76313e 100644 --- a/web/src/views/SettingsView.tsx +++ b/web/src/views/SettingsView.tsx @@ -70,9 +70,9 @@ export function SettingsView({
Library
- p.status === "active").length} /> - p.status === "consumed").length} /> - p.status === "gone").length} /> + i.status === "active").length} /> + i.status === "consumed").length} /> + i.status === "gone").length} />
diff --git a/web/src/views/ShopsView.tsx b/web/src/views/ShopsView.tsx index f524c41..efd8871 100644 --- a/web/src/views/ShopsView.tsx +++ b/web/src/views/ShopsView.tsx @@ -68,7 +68,7 @@ export function ShopsView({ }} > {data.shops.map((s) => { - const count = data.products.filter((p) => p.shopId === s.id).length; + const count = data.inventoryItems.filter((i) => i.shopId === s.id).length; return (