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