Files
Apothecary/web/src/types.ts
T
josh 5fa1e34914
Build and push image / build (push) Successful in 57s
Split audits into Weigh Ins (bulk) and Bin Checks (discrete)
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>
2026-06-06 18:28:55 -04:00

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;
},
};