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
+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;