5fa1e34914
Build and push image / build (push) Successful in 57s
Replaces the unified audit system with two purpose-built flows: - Weigh Ins: rebranded audit flow for bulk products (Flower, Concentrate, Tincture) with scale weigh, container weigh, and estimate modes - Bin Checks: new bin-level presence check — select a bin, scan every item, resolve discrepancies (wrong bin, unknown, missing), auto-records presence audits on verified items Adds cadence_days and last_checked to bins table, with per-bin overdue tracking on the dashboard and bins view. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
234 lines
7.8 KiB
TypeScript
234 lines
7.8 KiB
TypeScript
export type ProductStatus = "active" | "consumed" | "gone" | "checked-out";
|
|
export type ProductKind = "bulk" | "discrete";
|
|
export type AuditMode = "weigh" | "estimate" | "presence";
|
|
|
|
export interface Audit {
|
|
date: string;
|
|
mode: AuditMode;
|
|
value: number;
|
|
prev: number | null;
|
|
confirmedBy: string | null;
|
|
}
|
|
|
|
// Product = catalog entry. Identified by SKU. One row per "kind of thing
|
|
// you can scan" — strain + brand + form factor (Flower/bulk, Pre-roll/discrete).
|
|
// Display name comes from the linked strain (strainId is required).
|
|
export interface Product {
|
|
id: string;
|
|
sku: string;
|
|
strainId: string;
|
|
brandId: string | null;
|
|
type: string;
|
|
kind: ProductKind;
|
|
createdAt: string;
|
|
}
|
|
|
|
// InventoryItem = a physical jar/pack you bought. Has a 6-digit asset id
|
|
// (printed on a roll of labels the user owns). Carries per-batch values:
|
|
// shop, bin, price, cannabinoids, weight/count, lifecycle, audits. Brand
|
|
// lives on the product, not here.
|
|
export interface InventoryItem {
|
|
id: string;
|
|
assetId: string;
|
|
productId: string;
|
|
shopId: string | null;
|
|
binId: string | null;
|
|
price: number;
|
|
thc: number;
|
|
cbd: number;
|
|
totalCannabinoids: number;
|
|
weight: number;
|
|
containerWeight: number | null;
|
|
lastAuditWeight: number | null;
|
|
countOriginal: number;
|
|
countLastAudit: number | null;
|
|
unitWeight: number;
|
|
purchaseDate: string;
|
|
status: ProductStatus;
|
|
consumedDate: string | null;
|
|
goneDate: string | null;
|
|
checkoutDate: string | null;
|
|
prevBinId: string | null;
|
|
rating: number | null;
|
|
notes: 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`, `brandId` without a per-row lookup. The display
|
|
// `name` is the strain's name. This is the shape the UI and helpers operate on.
|
|
export interface Item extends InventoryItem {
|
|
name: string;
|
|
sku: string;
|
|
type: string;
|
|
kind: ProductKind;
|
|
brandId: string | null;
|
|
strainId: string;
|
|
}
|
|
|
|
export interface Strain {
|
|
id: string;
|
|
name: string;
|
|
defaultThc: number | null;
|
|
defaultCbd: number | null;
|
|
defaultTotalCannabinoids: number | null;
|
|
notes: string | null;
|
|
}
|
|
|
|
export interface Brand {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface Shop {
|
|
id: string;
|
|
name: string;
|
|
location: string | null;
|
|
}
|
|
|
|
export interface Bin {
|
|
id: string;
|
|
name: string;
|
|
capacity: number;
|
|
cadenceDays: number;
|
|
lastChecked: string | null;
|
|
}
|
|
|
|
export interface TypeConfig {
|
|
id: string;
|
|
kind: ProductKind;
|
|
auditMode: AuditMode;
|
|
cadenceDays: number;
|
|
unit: string;
|
|
weighable: boolean;
|
|
weightUnit: string;
|
|
showCannabinoidPct: boolean;
|
|
}
|
|
|
|
export interface Bootstrap {
|
|
products: Product[];
|
|
inventoryItems: InventoryItem[];
|
|
brands: Brand[];
|
|
shops: Shop[];
|
|
bins: Bin[];
|
|
strains: Strain[];
|
|
today: string;
|
|
}
|
|
|
|
// Type config lives client-side — static, not user data.
|
|
export const TYPES: TypeConfig[] = [
|
|
{ id: "Flower", kind: "bulk", auditMode: "weigh", cadenceDays: 14, unit: "g", weighable: true, weightUnit: "g", showCannabinoidPct: true },
|
|
{ id: "Concentrate", kind: "bulk", auditMode: "estimate", cadenceDays: 21, unit: "g", weighable: true, weightUnit: "g", showCannabinoidPct: true },
|
|
{ id: "Tincture", kind: "bulk", auditMode: "estimate", cadenceDays: 30, unit: "ml", weighable: false, weightUnit: "ml", showCannabinoidPct: true },
|
|
{ id: "Pre-roll", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false, weightUnit: "g", showCannabinoidPct: true },
|
|
{ id: "Edible", kind: "discrete", auditMode: "presence", cadenceDays: 60, unit: "ct", weighable: false, weightUnit: "mg", showCannabinoidPct: false },
|
|
{ id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false, weightUnit: "g", showCannabinoidPct: true },
|
|
];
|
|
|
|
// User-supplied 6-digit asset ids are printed on a roll of physical tags.
|
|
export const ASSET_ID_RE = /^\d{6}$/;
|
|
|
|
import { getToday, getBrowserTimezone } from "./tz.js";
|
|
export { getToday } from "./tz.js";
|
|
|
|
export const TODAY_STR = getToday(getBrowserTimezone());
|
|
|
|
// 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 = strainMap.get(product.strainId);
|
|
out.push({
|
|
...inv,
|
|
name: strain?.name ?? "(unknown strain)",
|
|
sku: product.sku,
|
|
type: product.type,
|
|
kind: product.kind,
|
|
brandId: product.brandId,
|
|
strainId: product.strainId,
|
|
});
|
|
}
|
|
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 ?? "—";
|
|
},
|
|
brandName(data: { brands: Brand[] }, id: string | null): string {
|
|
return data.brands.find((b) => b.id === id)?.name ?? "—";
|
|
},
|
|
typeConfig(id: string): TypeConfig {
|
|
return TYPES.find((t) => t.id === id) ?? TYPES[0]!;
|
|
},
|
|
tare(item: { containerWeight: number | null; weight: number }): number | null {
|
|
if (item.containerWeight == null) return null;
|
|
return item.containerWeight - item.weight;
|
|
},
|
|
daysSince(iso: string | null, today = TODAY_STR): number {
|
|
if (!iso) return Infinity;
|
|
return Math.floor((+new Date(today) - +new Date(iso)) / 86_400_000);
|
|
},
|
|
lastAudit(p: Item): Audit | null {
|
|
return p.audits.length > 0 ? p.audits[p.audits.length - 1]! : null;
|
|
},
|
|
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: Item, today = TODAY_STR): boolean {
|
|
if (p.status !== "active" && p.status !== "checked-out") return false;
|
|
const cfg = TYPES.find((t) => t.id === p.type);
|
|
if (!cfg || cfg.kind === "discrete") return false;
|
|
return this.daysSinceCheck(p, today) >= cfg.cadenceDays;
|
|
},
|
|
binCheckOverdue(bin: Bin, today = TODAY_STR): boolean {
|
|
return this.daysSinceBinCheck(bin, today) >= bin.cadenceDays;
|
|
},
|
|
daysSinceBinCheck(bin: Bin, today = TODAY_STR): number {
|
|
return this.daysSince(bin.lastChecked, today);
|
|
},
|
|
remaining(p: Item): number {
|
|
if (p.status !== "active" && p.status !== "checked-out") return 0;
|
|
if (p.kind === "discrete") {
|
|
return p.countLastAudit ?? p.countOriginal;
|
|
}
|
|
const last = this.lastAudit(p);
|
|
return last ? last.value : p.weight;
|
|
},
|
|
pctRemaining(p: Item): number {
|
|
if (p.kind === "discrete") {
|
|
const cur = p.countLastAudit ?? p.countOriginal;
|
|
return p.countOriginal > 0 ? cur / p.countOriginal : 0;
|
|
}
|
|
const rem = this.remaining(p);
|
|
return p.weight > 0 ? rem / p.weight : 0;
|
|
},
|
|
};
|