Track inventory at the instance level, not by product
Build and push image / build (push) Successful in 46s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 05:59:46 -04:00
parent 1abfda7989
commit 02dc6e523f
28 changed files with 2315 additions and 1355 deletions
+58 -8
View File
@@ -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<string, number> = {
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();
}
+2
View File
@@ -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));
+53 -39
View File
@@ -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<string, AuditRow[]>();
const auditsByInventory = new Map<string, AuditRow[]>();
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,
+8 -9
View File
@@ -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");
});
+313
View File
@@ -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 });
});
+142 -320
View File
@@ -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;
}
});
+58 -41
View File
@@ -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);
+40 -26
View File
@@ -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<ViewKey>("dashboard");
const [selected, setSelected] = useState<Product | null>(null);
const [selected, setSelected] = useState<Item | null>(null);
const [modal, setModal] = useState<ModalKey>(null);
const [modalItem, setModalItem] = useState<Item | null>(null);
const [modalProduct, setModalProduct] = useState<Product | null>(null);
const [modalBin, setModalBin] = useState<Bin | null>(null);
const [modalBrand, setModalBrand] = useState<Brand | null>(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() {
<Dashboard
data={data}
stats={stats}
onAuditProduct={openAudit}
onSelectProduct={setSelected}
onAuditItem={openAudit}
onSelectItem={setSelected}
/>
)}
{view === "inventory" && (
<Inventory
data={data}
onSelectProduct={setSelected}
onAddProduct={openAdd}
onSelectItem={setSelected}
onAddInventory={openAdd}
onAuditNew={() => openAudit()}
/>
)}
{view === "bins" && (
<BinsView
data={data}
onSelectProduct={setSelected}
onSelectItem={setSelected}
onAddBin={() => setModal("addBin")}
onEditBin={(bin) => {
setModalBin(bin);
@@ -182,28 +192,32 @@ export function App() {
{selected && (
<ProductDetail
product={selected}
item={selected}
data={data}
onClose={() => setSelected(null)}
onConsume={openConsume}
onMarkGone={openMarkGone}
onAudit={openAudit}
onEdit={openEdit}
onEditProduct={openEditProduct}
/>
)}
{modal === "add" && <AddProductFlow data={data} onClose={() => setModal(null)} />}
{modal === "edit" && modalProduct && (
{modal === "add" && <AddInventoryFlow data={data} onClose={() => setModal(null)} />}
{modal === "edit" && modalItem && (
<EditInventoryFlow data={data} item={modalItem} onClose={() => setModal(null)} />
)}
{modal === "editProduct" && modalProduct && (
<EditProductFlow data={data} product={modalProduct} onClose={() => setModal(null)} />
)}
{modal === "consume" && (
<ConsumeFlow data={data} onClose={() => setModal(null)} product={modalProduct} />
<ConsumeFlow data={data} onClose={() => setModal(null)} item={modalItem} />
)}
{modal === "gone" && (
<MarkGoneFlow data={data} onClose={() => setModal(null)} product={modalProduct} />
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
)}
{modal === "audit" && (
<AuditFlow data={data} onClose={() => setModal(null)} product={modalProduct} />
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} />
)}
{modal === "addBrand" && <AddBrandModal onClose={() => setModal(null)} />}
{modal === "addShop" && <AddShopModal onClose={() => setModal(null)} />}
+82 -31
View File
@@ -15,41 +15,26 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
bootstrap: () => request<Bootstrap>("/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",
+106 -77
View File
@@ -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", <span className="mono">{product.sku}</span>],
[
"Asset tag",
product.assetTag ? (
<span className="mono">{product.assetTag}</span>
) : (
<span style={{ color: "var(--ink-3)" }}>None</span>
),
],
["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", <span className="mono">{item.assetId}</span>],
["SKU", <span className="mono">{item.sku}</span>],
["Type", `${item.type} · ${item.kind}`],
["Strain", item.strainName ?? <span style={{ color: "var(--ink-3)" }}>Unlinked</span>],
["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 : <span style={{ color: "var(--ink-3)" }}></span>],
["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({
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Product · {product.sku}
Inventory · <span className="mono">{item.assetId}</span>
</div>
<div style={{ display: "flex", gap: 6 }}>
{isActive && (
<Btn variant="ghost" icon="check" onClick={() => onAudit(product)}>
<Btn variant="ghost" icon="check" onClick={() => onAudit(item)}>
Audit
</Btn>
)}
{isActive && (
<Btn variant="secondary" icon="check" onClick={() => onConsume(product)}>
<Btn variant="secondary" icon="check" onClick={() => onConsume(item)}>
Mark consumed
</Btn>
)}
{isActive && (
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(product)}>
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)}>
Mark gone
</Btn>
)}
<Btn variant="ghost" icon="edit" onClick={() => onEdit(product)}>
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)}>
Edit
</Btn>
<Btn variant="ghost" icon="close" onClick={onClose} />
@@ -140,13 +146,13 @@ export function ProductDetail({
<div style={{ padding: "32px 32px 60px" }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8 }}>
<div className="serif" style={{ fontSize: 18, color: "var(--ink-3)" }}>
{TYPE_GLYPHS[product.type]} {product.type}
{TYPE_GLYPHS[item.type]} {item.type}
</div>
{product.status === "consumed" && (
<Pill tone="terra">Consumed · {fmt.daysAgo(product.consumedDate)}</Pill>
{item.status === "consumed" && (
<Pill tone="terra">Consumed · {fmt.daysAgo(item.consumedDate)}</Pill>
)}
{product.status === "gone" && (
<Pill tone="amber">Gone · {fmt.daysAgo(product.goneDate)}</Pill>
{item.status === "gone" && (
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate)}</Pill>
)}
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
</div>
@@ -160,11 +166,34 @@ export function ProductDetail({
lineHeight: 1.1,
}}
>
{product.name}
{item.name}
</h1>
<div style={{ fontSize: 16, color: "var(--ink-2)" }}>
{helpers.brandName(data, product.brandId)} · from {helpers.shopName(data, product.shopId)}
{helpers.brandName(data, item.brandId)} · from {helpers.shopName(data, item.shopId)}
</div>
{product && (
<div style={{ marginTop: 8 }}>
<button
onClick={() => onEditProduct(product)}
style={{
background: "none",
border: "none",
fontSize: 12,
color: "var(--ink-3)",
cursor: "pointer",
textDecoration: "underline",
padding: 0,
}}
>
Edit product (catalog)
</button>
{siblings.length > 0 && (
<span style={{ fontSize: 12, color: "var(--ink-3)", marginLeft: 12 }}>
· {siblings.length} other instance{siblings.length === 1 ? "" : "s"} on file
</span>
)}
</div>
)}
<div
style={{
@@ -182,9 +211,9 @@ export function ProductDetail({
[
[
"Price",
product.kind === "discrete" && product.countOriginal > 0 ? (
item.kind === "discrete" && item.countOriginal > 0 ? (
<>
{fmt.money(product.price / product.countOriginal)}
{fmt.money(item.price / item.countOriginal)}
<span style={{ fontSize: 14, color: "var(--ink-3)", marginLeft: 4 }}>
/unit
</span>
@@ -198,21 +227,21 @@ export function ProductDetail({
letterSpacing: 0,
}}
>
{fmt.money(product.price)} total
{fmt.money(item.price)} total
</div>
</>
) : (
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) => (
<div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
@@ -233,12 +262,12 @@ export function ProductDetail({
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{product.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
{item.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
</div>
<div style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
{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"}`}
<span style={{ color: "var(--ink-3)", marginLeft: 8 }}>
{Math.round(pctRemaining * 100)}%
</span>
@@ -258,7 +287,7 @@ export function ProductDetail({
}}
/>
</div>
{product.kind === "bulk" && last && (
{item.kind === "bulk" && last && (
<div
style={{
fontSize: 11,
@@ -286,7 +315,7 @@ export function ProductDetail({
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Audit history</div>
{isActive && (
<button
onClick={() => onAudit(product)}
onClick={() => onAudit(item)}
style={{
background: "none",
border: "none",
@@ -300,9 +329,9 @@ export function ProductDetail({
</button>
)}
</div>
{product.audits.length === 0 ? (
{item.audits.length === 0 ? (
<div style={{ fontSize: 13, color: "var(--ink-3)", fontStyle: "italic", padding: "12px 0" }}>
No audits recorded. Cadence for {product.type}: every {cfg?.cadenceDays ?? "—"} days.
No audits recorded. Cadence for {item.type}: every {cfg?.cadenceDays ?? "—"} days.
</div>
) : (
<div
@@ -315,12 +344,12 @@ export function ProductDetail({
overflow: "hidden",
}}
>
{[...product.audits].reverse().map((a, i, arr) => (
{[...item.audits].reverse().map((a, idx, arr) => (
<div
key={i}
key={idx}
style={{
padding: "12px 16px",
borderBottom: i < arr.length - 1 ? "1px solid var(--line)" : "none",
borderBottom: idx < arr.length - 1 ? "1px solid var(--line)" : "none",
display: "flex",
alignItems: "center",
gap: 12,
@@ -384,7 +413,7 @@ export function ProductDetail({
</div>
</div>
{(product.status === "consumed" || product.status === "gone") && (
{(item.status === "consumed" || item.status === "gone") && (
<div
style={{
marginTop: 36,
@@ -403,16 +432,16 @@ export function ProductDetail({
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{product.status === "gone" ? "Why it's gone" : "Final notes"}
{item.status === "gone" ? "Why it's gone" : "Final notes"}
</div>
{product.status === "consumed" && (
{item.status === "consumed" && (
<div style={{ display: "flex", gap: 2 }}>
{[1, 2, 3, 4, 5].map((n) => (
<Icon
key={n}
name="star"
size={14}
color={n <= (product.rating ?? 0) ? "var(--amber)" : "var(--ink-4)"}
color={n <= (item.rating ?? 0) ? "var(--amber)" : "var(--ink-4)"}
/>
))}
</div>
@@ -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."}"
</div>
</div>
)}
+45 -28
View File
@@ -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 (
<Field label="Scan SKU or asset tag" hint="Or pick from the list below.">
<Field label="Scan asset id or SKU" hint="Or pick from the list below.">
<div
style={{
display: "flex",
@@ -69,7 +70,7 @@ export function ScanField({
value={scan}
onChange={(e) => 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 && (
<span
className="mono"
style={{
@@ -90,7 +91,7 @@ export function ScanField({
whiteSpace: "nowrap",
}}
>
{matchedProduct.name}
{matchedLabel}
</span>
)}
</div>
@@ -108,3 +109,19 @@ export function ScanField({
</Field>
);
}
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;
}
@@ -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<Step>("select");
const [productId, setProductId] = useState<string | null>(null);
const [savedAssetId, setSavedAssetId] = useState<string | null>(null);
const product = productId
? data.products.find((p) => p.id === productId) ?? null
: null;
const goToDetails = (id: string) => {
setProductId(id);
setStep("details");
};
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(840px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader
title={
step === "select"
? "Add inventory"
: step === "details"
? `Add ${product?.name ?? ""}`
: "Saved"
}
eyebrow={
step === "select"
? "Step 1 · Scan or pick a product"
: step === "details"
? "Step 2 · This batch's details"
: "Inventory item created"
}
onClose={onClose}
/>
{step === "select" && (
<SelectProductStep
data={data}
items={items}
onPickProduct={(id) => goToDetails(id)}
onClose={onClose}
/>
)}
{step === "details" && product && (
<InstanceDetailsStep
data={data}
items={items}
product={product}
onBack={() => setStep("select")}
onSaved={(assetId) => {
setSavedAssetId(assetId);
qc.invalidateQueries({ queryKey: ["bootstrap"] });
setStep("done");
}}
/>
)}
{step === "done" && savedAssetId && product && (
<DonePane
assetId={savedAssetId}
productName={product.name}
onAddAnother={() => {
setSavedAssetId(null);
setProductId(null);
setStep("select");
}}
onClose={onClose}
/>
)}
</div>
</ModalBackdrop>
);
}
// ─── 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<string>(
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<string>(""); // empty = match-by-name / create
const [error, setError] = useState<string | null>(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 (
<>
<div style={{ padding: 32 }}>
<ScanField
items={items}
products={data.products}
onMatch={handleScan}
matchedLabel={null}
/>
{data.products.length > 0 && !creating && (
<div style={{ marginTop: 16 }}>
<Field label="Or pick an existing product">
<Select
value={pickedProductId}
onChange={(e) => setPickedProductId(e.target.value)}
>
{data.products.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · {p.sku} ({p.type})
</option>
))}
</Select>
</Field>
</div>
)}
{!creating && (
<div style={{ marginTop: 18 }}>
<Btn variant="ghost" icon="plus" onClick={() => setCreating(true)}>
Create a new product
</Btn>
</div>
)}
{creating && (
<div
style={{
marginTop: 24,
padding: 20,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
New product (catalog entry)
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16 }}>
<Field label="SKU" hint="The barcode you'll scan">
<Input
value={newSku}
placeholder="SKU-XXXXXX"
onChange={(e) => setNewSku(e.target.value)}
/>
</Field>
<Field label="Type">
<Select value={newType} onChange={(e) => setNewType(e.target.value)}>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.id} ({t.kind})
</option>
))}
</Select>
</Field>
<Field label="Product name" span={2}>
<Input
value={newName}
placeholder="e.g. Garden Ghost 3.5g"
onChange={(e) => setNewName(e.target.value)}
/>
</Field>
<Field
label="Strain"
span={2}
hint={
matchedStrain
? `Will link to existing strain "${matchedStrain.name}".`
: "Will create a new strain entry from this name (defaults blank — link from product later)."
}
>
<Select
value={newStrainId}
onChange={(e) => setNewStrainId(e.target.value)}
style={{ marginBottom: 8 }}
>
<option value=""> Match by name typed below </option>
{data.strains.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
<Input
value={newStrain}
placeholder={`Strain name (defaults to product name if blank)`}
onChange={(e) => setNewStrain(e.target.value)}
disabled={!!newStrainId}
/>
</Field>
</div>
{error && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
)}
</div>
<ModalFooter>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{creating
? "Create the product, then we'll capture this batch's details."
: "Scan a SKU, pick a product, or create one."}
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>
Cancel
</Btn>
{creating ? (
<>
<Btn variant="ghost" onClick={() => setCreating(false)}>
Back
</Btn>
<Btn
variant="primary"
icon="check"
disabled={create.isPending}
onClick={() => create.mutate()}
>
{create.isPending ? "Creating…" : "Create product"}
</Btn>
</>
) : (
data.products.length > 0 && (
<Btn
variant="primary"
icon="plus"
disabled={!pickedProductId}
onClick={() => onPickProduct(pickedProductId)}
>
Add inventory
</Btn>
)
)}
</div>
</ModalFooter>
</>
);
}
// ─── 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<string | null>(null);
const update = <K extends keyof typeof form>(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 (
<>
<div style={{ padding: 32 }}>
<div
style={{
marginBottom: 20,
padding: "12px 16px",
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-sm)",
fontSize: 12,
color: "var(--ink-3)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
<strong style={{ color: "var(--ink-2)" }}>{product.name}</strong> · {product.type} ·{" "}
<span className="mono">{product.sku}</span>
</span>
<span>
{priorCount > 0
? `${priorCount} prior instance${priorCount === 1 ? "" : "s"} — fields autofilled from most recent.`
: "First instance of this product."}
</span>
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Source
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: 16,
marginBottom: 28,
}}
>
<Field label="Brand">
<Select value={form.brandId} onChange={(e) => update("brandId", e.target.value)}>
{data.brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
<option value={NEW_BRAND}>+ Add new brand</option>
</Select>
</Field>
<Field label="Shop">
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
{data.shops.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
<option value={NEW_SHOP}>+ Add new shop</option>
</Select>
</Field>
{isNewBrand && (
<Field label="New brand name" span={2}>
<Input
value={newBrand}
onChange={(e) => setNewBrand(e.target.value)}
placeholder="e.g. Foxglove Farms"
/>
</Field>
)}
{isNewShop && (
<>
<Field label="New shop name">
<Input
value={newShopName}
onChange={(e) => setNewShopName(e.target.value)}
placeholder="e.g. Greenleaf Co-op"
/>
</Field>
<Field label="Location (optional)">
<Input
value={newShopLocation}
onChange={(e) => setNewShopLocation(e.target.value)}
placeholder="e.g. Capitol Hill"
/>
</Field>
</>
)}
<Field label="Bin">
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
{data.bins.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
<option value={NEW_BIN}>+ Add new bin</option>
</Select>
</Field>
{isNewBin && (
<>
<Field label="New bin name">
<Input
value={newBinName}
onChange={(e) => setNewBinName(e.target.value)}
placeholder="e.g. A1"
/>
</Field>
<Field label="Capacity">
<Input
type="number"
min={1}
step={1}
value={newBinCapacity}
onChange={(e) =>
setNewBinCapacity(Math.max(1, Math.floor(+e.target.value || 1)))
}
/>
</Field>
</>
)}
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Acquisition
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 16,
marginBottom: 8,
}}
>
{isDiscrete ? (
<>
<Field label={`Quantity (${cfg!.unit})`}>
<Input
type="number"
step="1"
value={form.countOriginal}
onChange={(e) => update("countOriginal", +e.target.value)}
/>
</Field>
<Field label="Per-unit weight (g)" hint="For grams stats">
<Input
type="number"
step="0.1"
value={form.unitWeight}
onChange={(e) => update("unitWeight", +e.target.value)}
/>
</Field>
</>
) : (
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
<Input
type="number"
step="0.1"
value={form.weight}
onChange={(e) => update("weight", +e.target.value)}
/>
</Field>
)}
<Field label={isDiscrete ? "Price per unit ($)" : "Price ($)"}>
<Input
type="number"
step="0.01"
value={form.price}
onChange={(e) => update("price", +e.target.value)}
/>
</Field>
<Field label="Purchase date">
<Input
type="date"
value={form.purchaseDate}
onChange={(e) => update("purchaseDate", e.target.value)}
/>
</Field>
</div>
{!isDiscrete && cpg > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Cost per {cfg?.unit ?? "g"}:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.money(cpg)}
</span>
</div>
)}
{isDiscrete && form.price > 0 && form.countOriginal > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Total:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.money(totalPrice)}
</span>
<span style={{ marginLeft: 6 }}>
({form.countOriginal} × {fmt.money(form.price)})
</span>
</div>
)}
<div
className="smallcaps"
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
>
Cannabinoid profile
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
<Field label="THC %">
<Input
type="number"
step="0.1"
value={form.thc}
onChange={(e) => update("thc", +e.target.value)}
/>
</Field>
<Field label="CBD %">
<Input
type="number"
step="0.1"
value={form.cbd}
onChange={(e) => update("cbd", +e.target.value)}
/>
</Field>
<Field label="Total cannabinoids %">
<Input
type="number"
step="0.1"
value={form.totalCannabinoids}
onChange={(e) => update("totalCannabinoids", +e.target.value)}
/>
</Field>
</div>
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
<ModalFooter>
<Btn variant="ghost" onClick={onBack}>
Back
</Btn>
<Btn
variant="primary"
icon="check"
disabled={save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : "Save inventory item"}
</Btn>
</ModalFooter>
</>
);
}
// ─── Step 3 ─────────────────────────────────────────────────────────
function DonePane({
assetId,
productName,
onAddAnother,
onClose,
}: {
assetId: string;
productName: string;
onAddAnother: () => void;
onClose: () => void;
}) {
return (
<>
<div style={{ padding: "48px 32px 32px", textAlign: "center" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Asset id</div>
<div
className="mono"
style={{
fontSize: 64,
fontWeight: 600,
letterSpacing: "0.08em",
margin: "16px 0",
color: "var(--ink)",
}}
>
{assetId}
</div>
<div style={{ fontSize: 13, color: "var(--ink-2)", marginBottom: 8 }}>
Label this {productName} with{" "}
<span className="mono" style={{ color: "var(--ink)" }}>{assetId}</span>{" "}
so you can scan it later.
</div>
</div>
<ModalFooter>
<Btn variant="ghost" onClick={onAddAnother}>
+ Add another
</Btn>
<Btn variant="primary" icon="check" onClick={onClose}>
Done
</Btn>
</ModalFooter>
</>
);
}
+56 -42
View File
@@ -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<string, { title: string; desc: string }> = {
weigh: {
@@ -25,40 +25,41 @@ const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
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<string>(initialValueFor(product));
const [value, setValue] = useState<string>(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({
<div style={{ padding: 32 }}>
<ScanField
products={overdueFirst}
matchedProduct={product ?? null}
onMatch={setProductId}
items={overdueFirst}
products={data.products}
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
onMatch={handleScan}
/>
<div style={{ marginTop: 16 }}>
<Field label="Or pick from list">
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
{overdueFirst.map((p) => {
const od = helpers.auditOverdue(p);
const sc = helpers.daysSinceCheck(p);
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
{overdueFirst.map((i) => {
const od = helpers.auditOverdue(i);
const sc = helpers.daysSinceCheck(i);
return (
<option key={p.id} value={p.id}>
<option key={i.id} value={i.id}>
{od ? "⚠ " : ""}
{p.name} {helpers.brandName(data, p.brandId)} · {sc}d since check
{i.assetId} · {i.name} {helpers.brandName(data, i.brandId)} · {sc}d since check
</option>
);
})}
@@ -134,16 +148,16 @@ export function AuditFlow({
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div className="serif" style={{ fontSize: 20, fontWeight: 500 }}>
{product.name}
{item.name}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d
<span className="mono">{item.assetId}</span> · {item.type} · {item.kind} · cadence every {cfg?.cadenceDays}d
</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LAST CHECKED</div>
<div className="serif" style={{ fontSize: 18 }}>
{last ? `${helpers.daysSinceCheck(product)}d ago` : "Never"}
{last ? `${helpers.daysSinceCheck(item)}d ago` : "Never"}
</div>
</div>
</div>
@@ -162,7 +176,7 @@ export function AuditFlow({
>
<Field
label={
product.kind === "discrete"
item.kind === "discrete"
? `Count now (${cfg?.unit})`
: auditMode === "weigh"
? `Weight now (${cfg?.unit})`
@@ -171,7 +185,7 @@ export function AuditFlow({
>
<Input
type="number"
step={product.kind === "discrete" ? "1" : "0.1"}
step={item.kind === "discrete" ? "1" : "0.1"}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
@@ -185,8 +199,8 @@ export function AuditFlow({
value={confirmedBy}
onChange={(e) => setConfirmedBy(e.target.value as typeof confirmedBy)}
>
<option value="asset">Asset id</option>
<option value="SKU">SKU label</option>
<option value="asset">Asset tag</option>
<option value="visual">Visual ID</option>
</Select>
</Field>
@@ -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}
</div>
</div>
</div>
+1 -1
View File
@@ -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();
+36 -23
View File
@@ -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 (
<ModalBackdrop onClose={onClose}>
@@ -55,17 +67,18 @@ export function ConsumeFlow({
<div style={{ padding: 32 }}>
<ScanField
products={active}
matchedProduct={product ?? null}
onMatch={setProductId}
items={active}
products={data.products}
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
onMatch={handleScan}
/>
<div style={{ marginTop: 16 }}>
<Field label="Or pick from list">
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
{active.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {helpers.brandName(data, p.brandId)} ({remainingShort(p)} left)
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
{active.map((i) => (
<option key={i.id} value={i.id}>
{i.assetId} · {i.name} {helpers.brandName(data, i.brandId)} ({remainingShort(i)} left)
</option>
))}
</Select>
@@ -86,11 +99,11 @@ export function ConsumeFlow({
>
<div>
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
{product.name}
{item.name}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{helpers.brandName(data, product.brandId)} · {bin?.name} · purchased{" "}
{fmt.dateShort(product.purchaseDate)}
<span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "}
{fmt.dateShort(item.purchaseDate)}
</div>
</div>
<div style={{ textAlign: "right" }}>
@@ -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<string | null>(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 extends keyof typeof form>(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)",
}}
>
<ModalHeader title="Add a product" eyebrow="New entry" onClose={onClose} />
<ModalHeader
title={`Edit ${item.name}`}
eyebrow={`Inventory · ${item.assetId}`}
onClose={onClose}
/>
<div style={{ padding: 32 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>Identity</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28 }}>
<Field
label="Strain"
span={2}
hint={
matchedStrain
? "Matched existing strain — cannabinoid defaults pre-filled."
: undefined
}
>
<Input
value={form.name}
placeholder="e.g. Garden Ghost"
onChange={(e) => update("name", e.target.value)}
/>
</Field>
<div
style={{
marginBottom: 24,
padding: "10px 14px",
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-sm)",
fontSize: 12,
color: "var(--ink-3)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<span style={{ fontFamily: "var(--serif)", fontSize: 16 }}>
{TYPE_GLYPHS[item.type]}
</span>
<span>
Editing this physical instance of{" "}
<strong style={{ color: "var(--ink-2)" }}>{item.name}</strong> ({item.type} ·{" "}
{item.kind}). To change the product (SKU, name, type), edit the catalog entry.
</span>
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Source
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: 16,
marginBottom: 28,
}}
>
<Field label="Brand">
<Select value={form.brandId} onChange={(e) => update("brandId", e.target.value)}>
{data.brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
<option value={NEW_BRAND}>+ Add new brand</option>
</Select>
@@ -164,20 +172,30 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
<Field label="Shop">
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
{data.shops.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
<option value={NEW_SHOP}>+ Add new shop</option>
</Select>
</Field>
{isNewBrand && (
<Field label="New brand name" span={2}>
<Input value={newBrand} onChange={(e) => setNewBrand(e.target.value)} placeholder="e.g. Foxglove Farms" />
<Input
value={newBrand}
onChange={(e) => setNewBrand(e.target.value)}
placeholder="e.g. Foxglove Farms"
/>
</Field>
)}
{isNewShop && (
<>
<Field label="New shop name">
<Input value={newShopName} onChange={(e) => setNewShopName(e.target.value)} placeholder="e.g. Greenleaf Co-op" />
<Input
value={newShopName}
onChange={(e) => setNewShopName(e.target.value)}
placeholder="e.g. Greenleaf Co-op"
/>
</Field>
<Field label="Location (optional)">
<Input
@@ -188,17 +206,12 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
</Field>
</>
)}
<Field label="Type">
<Select value={form.type} onChange={(e) => update("type", e.target.value)}>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>{t.id} ({t.kind})</option>
))}
</Select>
</Field>
<Field label="Bin">
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
{data.bins.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
<option value={NEW_BIN}>+ Add new bin</option>
</Select>
@@ -225,20 +238,19 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
</Field>
</>
)}
<Field label="SKU" hint="Leave blank — we'll generate one">
<Input value={form.sku} placeholder="SKU-…" onChange={(e) => update("sku", e.target.value)} />
</Field>
<Field label="Asset tag (optional)" hint="If you've physically tagged the item">
<Input
value={form.assetTag}
placeholder="AT-0000"
onChange={(e) => update("assetTag", e.target.value)}
/>
</Field>
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>Acquisition</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 8 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
Acquisition
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 16,
marginBottom: 8,
}}
>
{isDiscrete ? (
<>
<Field label={`Quantity (${cfg!.unit})`}>
@@ -288,30 +300,36 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
{!isDiscrete && cpg > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Cost per {cfg?.unit ?? "g"}:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.money(cpg)}</span>
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.money(cpg)}
</span>
</div>
)}
{isDiscrete && form.price > 0 && form.countOriginal > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Total:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.money(totalPrice)}</span>
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.money(totalPrice)}
</span>
<span style={{ marginLeft: 6 }}>
({form.countOriginal} × {fmt.money(form.price)})
</span>
</div>
)}
<div className="smallcaps" style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}>Cannabinoid profile</div>
<div
className="smallcaps"
style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}
>
Cannabinoid profile
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
<Field label="THC %">
<Input
type="number"
step="0.1"
value={form.thc}
onChange={(e) => {
setEdited((p) => ({ ...p, thc: true }));
update("thc", +e.target.value);
}}
onChange={(e) => update("thc", +e.target.value)}
/>
</Field>
<Field label="CBD %">
@@ -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)}
/>
</Field>
<Field label="Total cannabinoids %">
@@ -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)}
/>
</Field>
</div>
{item.audits.length > 0 && (
<div
style={{
marginTop: 18,
fontSize: 12,
color: "var(--ink-3)",
fontStyle: "italic",
}}
>
{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.
</div>
)}
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
<ModalFooter>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{form.name
? `"${form.name}" → ${
isNewBin
? newBinName.trim() || "new bin"
: data.bins.find((b) => b.id === form.binId)?.name ?? "—"
}.`
: "Fill in the name to continue."}
</div>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="ghost" onClick={onClose}>
Cancel
</Btn>
<Btn
variant="primary"
icon="check"
disabled={!form.name || save.isPending}
disabled={save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : "Save product"}
{save.isPending ? "Saving…" : "Save changes"}
</Btn>
</div>
</ModalFooter>
@@ -369,84 +390,3 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
</ModalBackdrop>
);
}
// ─── Shared modal chrome ──────────────────────────────────────────
export function ModalBackdrop({
children,
onClose,
}: {
children: React.ReactNode;
onClose: () => void;
}) {
return (
<div
style={{
position: "fixed",
inset: 0,
background: "oklch(20% 0.02 60 / 0.4)",
zIndex: 50,
display: "flex",
justifyContent: "center",
alignItems: "flex-start",
overflow: "auto",
}}
onClick={onClose}
>
<div onClick={(e) => e.stopPropagation()} style={{ width: "100%", display: "flex", justifyContent: "center" }}>
{children}
</div>
</div>
);
}
export function ModalHeader({
title,
eyebrow,
eyebrowColor,
onClose,
}: {
title: string;
eyebrow: string;
eyebrowColor?: string;
onClose: () => void;
}) {
return (
<div
style={{
padding: "20px 32px",
borderBottom: "1px solid var(--line)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div className="smallcaps" style={{ color: eyebrowColor ?? "var(--ink-3)" }}>
{eyebrow}
</div>
<h2 className="serif" style={{ fontSize: 28, margin: "4px 0 0", fontWeight: 500 }}>
{title}
</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
);
}
export function ModalFooter({ children }: { children: React.ReactNode }) {
return (
<div
style={{
padding: "16px 32px",
borderTop: "1px solid var(--line)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "var(--bg-2)",
borderRadius: "0 0 var(--r-lg) var(--r-lg)",
}}
>
{children}
</div>
);
}
+49 -268
View File
@@ -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<string>(product.strainId ?? "");
const [error, setError] = useState<string | null>(null);
const update = <K extends keyof typeof form>(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 (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(840px, 96vw)",
width: "min(640px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
@@ -121,12 +55,16 @@ export function EditProductFlow({
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader title={`Edit ${product.name}`} eyebrow={`Product · ${product.sku}`} onClose={onClose} />
<ModalHeader
title={`Edit product`}
eyebrow={`Catalog · ${product.sku}`}
onClose={onClose}
/>
<div style={{ padding: 32 }}>
<div
style={{
marginBottom: 24,
marginBottom: 20,
padding: "10px 14px",
background: "var(--bg-2)",
border: "1px solid var(--line)",
@@ -142,200 +80,41 @@ export function EditProductFlow({
{TYPE_GLYPHS[product.type]}
</span>
<span>
Type <strong style={{ color: "var(--ink-2)" }}>{product.type}</strong> ({product.kind}) is locked. To change type, mark this product gone and add a new one.
SKU <span className="mono" style={{ color: "var(--ink-2)" }}>{product.sku}</span>{" "}
is locked. Edit individual purchases (price, brand, batch THC) from the
inventory drawer.
</span>
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>Identity</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28 }}>
<Field label="Strain" span={2}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16 }}>
<Field label="Product name" span={2}>
<Input
value={form.name}
placeholder="e.g. Garden Ghost"
onChange={(e) => update("name", e.target.value)}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Garden Ghost 3.5g"
/>
</Field>
<Field label="Brand">
<Select value={form.brandId} onChange={(e) => update("brandId", e.target.value)}>
{data.brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
<Field label="Type">
<Select value={type} onChange={(e) => setType(e.target.value)}>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.id} ({t.kind})
</option>
))}
<option value={NEW_BRAND}>+ Add new brand</option>
</Select>
</Field>
<Field label="Shop">
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
{data.shops.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
<Field label="Strain">
<Select value={strainId} onChange={(e) => setStrainId(e.target.value)}>
<option value=""> Unlinked </option>
{data.strains.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
<option value={NEW_SHOP}>+ Add new shop</option>
</Select>
</Field>
{isNewBrand && (
<Field label="New brand name" span={2}>
<Input value={newBrand} onChange={(e) => setNewBrand(e.target.value)} placeholder="e.g. Foxglove Farms" />
</Field>
)}
{isNewShop && (
<>
<Field label="New shop name">
<Input value={newShopName} onChange={(e) => setNewShopName(e.target.value)} placeholder="e.g. Greenleaf Co-op" />
</Field>
<Field label="Location (optional)">
<Input
value={newShopLocation}
onChange={(e) => setNewShopLocation(e.target.value)}
placeholder="e.g. Capitol Hill"
/>
</Field>
</>
)}
<Field label="Bin">
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
{data.bins.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
<option value={NEW_BIN}>+ Add new bin</option>
</Select>
</Field>
<Field label="Asset tag (optional)" hint="If you've physically tagged the item">
<Input
value={form.assetTag}
placeholder="AT-0000"
onChange={(e) => update("assetTag", e.target.value)}
/>
</Field>
{isNewBin && (
<>
<Field label="New bin name">
<Input
value={newBinName}
onChange={(e) => setNewBinName(e.target.value)}
placeholder="e.g. A1"
/>
</Field>
<Field label="Capacity">
<Input
type="number"
min={1}
step={1}
value={newBinCapacity}
onChange={(e) =>
setNewBinCapacity(Math.max(1, Math.floor(+e.target.value || 1)))
}
/>
</Field>
</>
)}
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>Acquisition</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 8 }}>
{isDiscrete ? (
<>
<Field label={`Quantity (${cfg!.unit})`}>
<Input
type="number"
step="1"
value={form.countOriginal}
onChange={(e) => update("countOriginal", +e.target.value)}
/>
</Field>
<Field label="Per-unit weight (g)" hint="For grams stats">
<Input
type="number"
step="0.1"
value={form.unitWeight}
onChange={(e) => update("unitWeight", +e.target.value)}
/>
</Field>
</>
) : (
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
<Input
type="number"
step="0.1"
value={form.weight}
onChange={(e) => update("weight", +e.target.value)}
/>
</Field>
)}
<Field label={isDiscrete ? "Price per unit ($)" : "Price ($)"}>
<Input
type="number"
step="0.01"
value={form.price}
onChange={(e) => update("price", +e.target.value)}
/>
</Field>
<Field label="Purchase date">
<Input
type="date"
value={form.purchaseDate}
onChange={(e) => update("purchaseDate", e.target.value)}
/>
</Field>
</div>
{!isDiscrete && cpg > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Cost per {cfg?.unit ?? "g"}:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.money(cpg)}</span>
</div>
)}
{isDiscrete && form.price > 0 && form.countOriginal > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Total:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.money(totalPrice)}</span>
<span style={{ marginLeft: 6 }}>
({form.countOriginal} × {fmt.money(form.price)})
</span>
</div>
)}
<div className="smallcaps" style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}>Cannabinoid profile</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
<Field label="THC %">
<Input
type="number"
step="0.1"
value={form.thc}
onChange={(e) => update("thc", +e.target.value)}
/>
</Field>
<Field label="CBD %">
<Input
type="number"
step="0.1"
value={form.cbd}
onChange={(e) => update("cbd", +e.target.value)}
/>
</Field>
<Field label="Total cannabinoids %">
<Input
type="number"
step="0.1"
value={form.totalCannabinoids}
onChange={(e) => update("totalCannabinoids", +e.target.value)}
/>
</Field>
</div>
{product.audits.length > 0 && (
<div
style={{
marginTop: 18,
fontSize: 12,
color: "var(--ink-3)",
fontStyle: "italic",
}}
>
{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.
</div>
)}
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
@@ -344,14 +123,16 @@ export function EditProductFlow({
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="ghost" onClick={onClose}>
Cancel
</Btn>
<Btn
variant="primary"
icon="check"
disabled={!form.name.trim() || save.isPending}
disabled={!name.trim() || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : "Save changes"}
{save.isPending ? "Saving…" : "Save"}
</Btn>
</div>
</ModalFooter>
+16 -15
View File
@@ -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 (
<ModalBackdrop onClose={onClose}>
@@ -76,11 +77,11 @@ export function MarkGoneFlow({
<strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
</div>
<Field label="Product">
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
{active.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {helpers.brandName(data, p.brandId)} ({remainingShort(p)} left)
<Field label="Inventory item">
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
{active.map((i) => (
<option key={i.id} value={i.id}>
{i.assetId} · {i.name} {helpers.brandName(data, i.brandId)} ({remainingShort(i)} left)
</option>
))}
</Select>
+84
View File
@@ -0,0 +1,84 @@
import { Btn } from "../primitives/index.js";
export function ModalBackdrop({
children,
onClose,
}: {
children: React.ReactNode;
onClose: () => void;
}) {
return (
<div
style={{
position: "fixed",
inset: 0,
background: "oklch(20% 0.02 60 / 0.4)",
zIndex: 50,
display: "flex",
justifyContent: "center",
alignItems: "flex-start",
overflow: "auto",
}}
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{ width: "100%", display: "flex", justifyContent: "center" }}
>
{children}
</div>
</div>
);
}
export function ModalHeader({
title,
eyebrow,
eyebrowColor,
onClose,
}: {
title: string;
eyebrow: string;
eyebrowColor?: string;
onClose: () => void;
}) {
return (
<div
style={{
padding: "20px 32px",
borderBottom: "1px solid var(--line)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div className="smallcaps" style={{ color: eyebrowColor ?? "var(--ink-3)" }}>
{eyebrow}
</div>
<h2 className="serif" style={{ fontSize: 28, margin: "4px 0 0", fontWeight: 500 }}>
{title}
</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
);
}
export function ModalFooter({ children }: { children: React.ReactNode }) {
return (
<div
style={{
padding: "16px 32px",
borderTop: "1px solid var(--line)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "var(--bg-2)",
borderRadius: "0 0 var(--r-lg) var(--r-lg)",
}}
>
{children}
</div>
);
}
+35 -31
View File
@@ -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<string, number> = {};
const brandCount: Record<string, number> = {};
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;
+81 -17
View File
@@ -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;
+29 -25
View File
@@ -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({
<Btn variant="secondary" icon="plus" onClick={onAddBin}>New bin</Btn>
</div>
<div style={{ fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600 }}>
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.
</div>
{data.bins.length === 0 && (
<Card style={{ padding: 60, textAlign: "center" }}>
<div className="serif" style={{ fontSize: 22, marginBottom: 6 }}>No bins yet</div>
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
Add a bin to start placing products somewhere.
Add a bin to start placing items somewhere.
</div>
<Btn variant="primary" icon="plus" onClick={onAddBin}>Add your first bin</Btn>
</Card>
@@ -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({
<Icon name="edit" size={14} />
</button>
<button
onClick={() => handleDelete(bin.id, bin.name, items.length)}
onClick={() => handleDelete(bin.id, bin.name, binItems.length)}
title="Remove bin"
aria-label={`Remove bin ${bin.name}`}
disabled={remove.isPending}
@@ -208,7 +211,7 @@ export function BinsView({
</div>
</div>
<div style={{ padding: 8, flex: 1 }}>
{items.length === 0 && (
{binItems.length === 0 && (
<div
style={{
padding: 30,
@@ -221,10 +224,10 @@ export function BinsView({
Empty
</div>
)}
{items.map((p) => (
{binItems.map((i) => (
<div
key={p.id}
onClick={() => 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]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
@@ -254,14 +257,15 @@ export function BinsView({
textOverflow: "ellipsis",
}}
>
{p.name}
{i.name}
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
{helpers.brandName(data, p.brandId)}
<span className="mono">{i.assetId}</span> ·{" "}
{helpers.brandName(data, i.brandId)}
</div>
</div>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-2)" }}>
{remainingShort(p)}
{remainingShort(i)}
</div>
</div>
))}
+6 -10
View File
@@ -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 (
<Card key={b.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -81,7 +77,7 @@ export function BrandsView({
</div>
</div>
<Pill tone="outline">
{productCount} purchase{productCount === 1 ? "" : "s"}
{itemCount} purchase{itemCount === 1 ? "" : "s"}
</Pill>
<button
onClick={() => onEditBrand(b)}
@@ -100,7 +96,7 @@ export function BrandsView({
<Icon name="edit" size={14} />
</button>
<button
onClick={() => handleDelete(b.id, b.name, productCount, strainCount)}
onClick={() => handleDelete(b.id, b.name, itemCount)}
title="Remove brand"
aria-label={`Remove brand ${b.name}`}
disabled={remove.isPending}
+6 -6
View File
@@ -8,16 +8,16 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
const series = stats.series90.map((s) => ({ date: s.date, grams: s.grams }));
const spendByMonth: Record<string, number> = {};
data.products.forEach((p) => {
const k = p.purchaseDate.slice(0, 7);
spendByMonth[k] = (spendByMonth[k] ?? 0) + p.price;
data.inventoryItems.forEach((i) => {
const k = i.purchaseDate.slice(0, 7);
spendByMonth[k] = (spendByMonth[k] ?? 0) + i.price;
});
const months = Object.entries(spendByMonth).sort();
const spendByShop: Record<string, number> = {};
data.products.forEach((p) => {
const name = helpers.shopName(data, p.shopId);
spendByShop[name] = (spendByShop[name] ?? 0) + p.price;
data.inventoryItems.forEach((i) => {
const name = helpers.shopName(data, i.shopId);
spendByShop[name] = (spendByShop[name] ?? 0) + i.price;
});
const shopRanked = Object.entries(spendByShop).sort((a, b) => b[1] - a[1]);
const shopMax = shopRanked[0]?.[1] ?? 1;
+26 -18
View File
@@ -1,4 +1,4 @@
import type { Bootstrap, Product } from "../types.js";
import type { Bootstrap, Item } from "../types.js";
import { helpers, TODAY_STR } from "../types.js";
import type { Stats } from "../stats.js";
import { remainingShort } from "../stats.js";
@@ -18,13 +18,13 @@ const TYPE_COLORS: Record<string, string> = {
export function Dashboard({
data,
stats,
onAuditProduct,
onSelectProduct,
onAuditItem,
onSelectItem,
}: {
data: Bootstrap;
stats: Stats;
onAuditProduct: (p: Product) => void;
onSelectProduct: (p: Product) => void;
onAuditItem: (i: Item) => void;
onSelectItem: (i: Item) => void;
}) {
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
const last7Series = stats.series7.map((l) => l.grams);
@@ -40,6 +40,14 @@ export function Dashboard({
const lowBulk = stats.lowStockBulk;
const lowDiscrete = stats.lowStockDiscreteGroups;
const todayDate = new Date(data.today || TODAY_STR);
const greetingDate = todayDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
return (
<div
style={{
@@ -49,7 +57,7 @@ export function Dashboard({
}}
>
<div style={{ marginBottom: 24 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Saturday · April 25, 2026</div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{greetingDate}</div>
<h1
className="serif"
style={{
@@ -92,7 +100,7 @@ export function Dashboard({
<Stat
label="Avg cost per gram"
value={fmt.money(stats.avgPerGram)}
sub={`Across ${data.products.length} purchases`}
sub={`Across ${stats.purchaseCount} purchases`}
/>
<Stat
label="30-day spend"
@@ -134,7 +142,7 @@ export function Dashboard({
<Stat
label="Spent all-time"
value={fmt.money(stats.totalSpend)}
sub={`${data.products.length} purchase${data.products.length === 1 ? "" : "s"}${stats.goneSpend > 0 ? ` · ${fmt.money(stats.goneSpend)} lost` : ""}`}
sub={`${stats.purchaseCount} purchase${stats.purchaseCount === 1 ? "" : "s"}${stats.goneSpend > 0 ? ` · ${fmt.money(stats.goneSpend)} lost` : ""}`}
/>
<Stat
label="Purchased all-time"
@@ -163,7 +171,7 @@ export function Dashboard({
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
</div>
</div>
<Btn variant="secondary" icon="check" onClick={() => onAuditProduct(overdue[0]!)}>
<Btn variant="secondary" icon="check" onClick={() => onAuditItem(overdue[0]!)}>
Run audit
</Btn>
</div>
@@ -282,7 +290,7 @@ export function Dashboard({
{stats.favShop[0]}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 10 }}>
{stats.favShop[1]} of {data.products.length} purchases
{stats.favShop[1]} of {stats.purchaseCount} purchases
</div>
</Card>
<Card>
@@ -315,12 +323,12 @@ export function Dashboard({
Nothing running low.
</div>
)}
{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 (
<div
key={p.id}
onClick={() => 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}
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
{helpers.brandName(data, p.brandId)} · {p.type}
{helpers.brandName(data, i.brandId)} · {i.type}
</div>
</div>
<div
@@ -367,7 +375,7 @@ export function Dashboard({
className="mono"
style={{ fontSize: 11, color: "var(--ink-2)", width: 60, textAlign: "right" }}
>
{remainingShort(p)}
{remainingShort(i)}
</div>
</div>
);
@@ -375,7 +383,7 @@ export function Dashboard({
{lowDiscrete.slice(0, 2).map((g) => (
<div
key={g.key}
onClick={() => onSelectProduct(g.items[0]!)}
onClick={() => onSelectItem(g.items[0]!)}
style={{
padding: "12px 24px",
borderTop: "1px solid var(--line)",
+98 -105
View File
@@ -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<FilterKey>("active");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<SortKey>("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<string | null, Product[]>();
for (const p of filteredProducts) {
const arr = byStrain.get(p.strainId) ?? [];
arr.push(p);
byStrain.set(p.strainId, arr);
const byProduct = new Map<string, Item[]>();
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 (
<div
@@ -124,7 +119,7 @@ export function Inventory({
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{sortedProducts.length} item{sortedProducts.length === 1 ? "" : "s"}
{sorted.length} item{sorted.length === 1 ? "" : "s"}
</div>
<h1
className="serif"
@@ -135,7 +130,7 @@ export function Inventory({
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="secondary" icon="check" onClick={onAuditNew}>Audit</Btn>
<Btn variant="primary" icon="plus" onClick={onAddProduct}>New product</Btn>
<Btn variant="primary" icon="plus" onClick={onAddInventory}>Add inventory</Btn>
</div>
</div>
@@ -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({
>
<Icon name="search" size={14} color="var(--ink-3)" />
<input
placeholder="Search by name, brand, shop, SKU…"
placeholder="Search by name, brand, shop, SKU, asset id…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
@@ -219,21 +214,21 @@ export function Inventory({
<Card padded={false}>
<HeaderRow />
{sortedProducts.length === 0 && (
{sorted.length === 0 && (
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
No items match these filters.
</div>
)}
{view === "flat" &&
sortedProducts.map((p) => (
<ProductRow key={p.id} p={p} data={data} onSelect={onSelectProduct} />
sorted.map((i) => (
<ItemRow key={i.id} i={i} data={data} onSelect={onSelectItem} />
))}
{view === "grouped" &&
groups.map((g) => (
<div key={g.strainId ?? "unlinked"}>
<div key={g.productId}>
<GroupHeader group={g} />
{g.products.map((p) => (
<ProductRow key={p.id} p={p} data={data} onSelect={onSelectProduct} indented />
{g.items.map((i) => (
<ItemRow key={i.id} i={i} data={data} onSelect={onSelectItem} indented />
))}
</div>
))}
@@ -301,7 +296,7 @@ function HeaderRow() {
}}
>
<div></div>
<div>Product</div>
<div>Item</div>
<div>Brand</div>
<div>Shop</div>
<div>THC %</div>
@@ -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]}
</div>
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1.1 }}>
{group.strainId === null ? "Unlinked" : group.label}
{group.label}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{group.brand} · {group.type}
<span className="mono">{group.sku}</span> · {group.type}
</div>
</div>
<div style={{ display: "flex", alignItems: "baseline", gap: 18, fontSize: 12, color: "var(--ink-3)" }}>
<div>
<span className="mono" style={{ color: "var(--ink-2)" }}>
{totalCount}
</span>{" "}
{totalCount === 1 ? "active" : "active"}
{group.products.length !== totalCount && (
<span style={{ color: "var(--ink-4)" }}> / {group.products.length} total</span>
<span className="mono" style={{ color: "var(--ink-2)" }}>{active.length}</span> active
{group.items.length !== active.length && (
<span style={{ color: "var(--ink-4)" }}> / {group.items.length} total</span>
)}
</div>
<div>
@@ -380,7 +371,10 @@ function GroupHeader({
</div>
{lastBuy > 0 && (
<div>
last buy <span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.dateShort(new Date(lastBuy).toISOString())}</span>
last buy{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.dateShort(new Date(lastBuy).toISOString())}
</span>
</div>
)}
</div>
@@ -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 (
<div
onClick={() => 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]}
</div>
<div style={{ minWidth: 0 }}>
<div
@@ -442,26 +436,25 @@ function ProductRow({
textOverflow: "ellipsis",
}}
>
{p.name}
{p.status === "consumed" && (
{i.name}
{i.status === "consumed" && (
<Pill tone="terra" style={{ marginLeft: 6, fontSize: 10 }}>Consumed</Pill>
)}
{p.status === "gone" && (
{i.status === "gone" && (
<Pill tone="amber" style={{ marginLeft: 6, fontSize: 10 }}>Gone</Pill>
)}
{p.status === "active" && overdue && (
{i.status === "active" && overdue && (
<Pill tone="amber" style={{ marginLeft: 6, fontSize: 10 }}>Audit due</Pill>
)}
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)", fontFamily: "var(--mono)" }}>
{p.sku}
{p.assetTag ? ` · ${p.assetTag}` : ""}
{i.assetId} · {i.sku}
</div>
</div>
<div style={{ color: "var(--ink-2)" }}>{helpers.brandName(data, p.brandId)}</div>
<div style={{ color: "var(--ink-3)", fontSize: 12 }}>{helpers.shopName(data, p.shopId)}</div>
<div style={{ fontFamily: "var(--mono)", color: "var(--ink-2)" }}>{p.thc.toFixed(1)}</div>
<div style={{ fontFamily: "var(--mono)" }}>{fmt.money(p.price)}</div>
<div style={{ color: "var(--ink-2)" }}>{helpers.brandName(data, i.brandId)}</div>
<div style={{ color: "var(--ink-3)", fontSize: 12 }}>{helpers.shopName(data, i.shopId)}</div>
<div style={{ fontFamily: "var(--mono)", color: "var(--ink-2)" }}>{i.thc.toFixed(1)}</div>
<div style={{ fontFamily: "var(--mono)" }}>{fmt.money(i.price)}</div>
<div
style={{
display: "flex",
@@ -470,8 +463,8 @@ function ProductRow({
gap: 4,
}}
>
<div style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{remainingShort(p)}</div>
{p.status === "active" && p.kind === "bulk" && (
<div style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{remainingShort(i)}</div>
{i.status === "active" && i.kind === "bulk" && (
<div style={{ width: 80, height: 5, background: "var(--bg-3)", borderRadius: 2 }}>
<div
style={{
@@ -490,7 +483,7 @@ function ProductRow({
)}
</div>
<div style={{ fontSize: 11, color: overdue ? "var(--terracotta)" : "var(--ink-3)" }}>
{p.status !== "active" ? (
{i.status !== "active" ? (
<span style={{ fontStyle: "italic" }}>archived</span>
) : last ? (
<span>
+3 -3
View File
@@ -70,9 +70,9 @@ export function SettingsView({
<Card style={{ marginBottom: 14 }}>
<div className="serif" style={{ fontSize: 22, marginBottom: 16 }}>Library</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 16 }}>
<Stat label="Active" value={data.products.filter((p) => p.status === "active").length} />
<Stat label="Consumed" value={data.products.filter((p) => p.status === "consumed").length} />
<Stat label="Gone" value={data.products.filter((p) => p.status === "gone").length} />
<Stat label="Active" value={data.inventoryItems.filter((i) => i.status === "active").length} />
<Stat label="Consumed" value={data.inventoryItems.filter((i) => i.status === "consumed").length} />
<Stat label="Gone" value={data.inventoryItems.filter((i) => i.status === "gone").length} />
<Stat label="Bins" value={data.bins.length} />
</div>
<div style={{ display: "flex", gap: 8 }}>
+1 -1
View File
@@ -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 (
<Card key={s.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>