Track inventory at the instance level, not by product
Build and push image / build (push) Successful in 46s
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:
+81
-17
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user