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
+106 -77
View File
@@ -1,76 +1,82 @@
import type { Bootstrap, Product } from "../types.js";
import type { Bootstrap, Item, Product } from "../types.js";
import { TYPES, helpers, TODAY_STR } from "../types.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js";
// Right-side drawer for an inventory instance. Shows the asset id and
// product context up top, then per-batch fields (price, THC, weight),
// audit history, and full detail rows.
export function ProductDetail({
product,
item,
data,
onClose,
onConsume,
onMarkGone,
onAudit,
onEdit,
onEditProduct,
}: {
product: Product;
item: Item;
data: Bootstrap;
onClose: () => void;
onConsume: (p: Product) => void;
onMarkGone: (p: Product) => void;
onAudit: (p: Product) => void;
onEdit: (p: Product) => void;
onConsume: (i: Item) => void;
onMarkGone: (i: Item) => void;
onAudit: (i: Item) => void;
onEdit: (i: Item) => void;
onEditProduct: (p: Product) => void;
}) {
const bin = data.bins.find((b) => b.id === product.binId);
const cfg = TYPES.find((t) => t.id === product.type);
const pctRemaining = helpers.pctRemaining(product, TODAY_STR);
const est = helpers.estimatedRemaining(product, TODAY_STR);
const last = helpers.lastAudit(product);
const overdue = helpers.auditOverdue(product, TODAY_STR);
const sinceCheck = helpers.daysSinceCheck(product, TODAY_STR);
const bin = data.bins.find((b) => b.id === item.binId);
const cfg = TYPES.find((t) => t.id === item.type);
const product = data.products.find((p) => p.id === item.productId);
const pctRemaining = helpers.pctRemaining(item, TODAY_STR);
const est = helpers.estimatedRemaining(item, TODAY_STR);
const last = helpers.lastAudit(item);
const overdue = helpers.auditOverdue(item, TODAY_STR);
const sinceCheck = helpers.daysSinceCheck(item, TODAY_STR);
const isActive = product.status === "active";
const isActive = item.status === "active";
// Sibling instances of the same product (excluding this one) — useful for
// seeing previous purchases of the same SKU.
const siblings = data.inventoryItems.filter(
(i) => i.productId === item.productId && i.id !== item.id,
);
const detailRows: [string, React.ReactNode][] = [
["SKU", <span className="mono">{product.sku}</span>],
[
"Asset tag",
product.assetTag ? (
<span className="mono">{product.assetTag}</span>
) : (
<span style={{ color: "var(--ink-3)" }}>None</span>
),
],
["Type", `${product.type} · ${product.kind}`],
["Brand", helpers.brandName(data, product.brandId)],
["Shop", helpers.shopName(data, product.shopId)],
["Total cannabinoids", `${product.totalCannabinoids.toFixed(1)}%`],
["Purchase date", fmt.date(product.purchaseDate)],
["Asset id", <span className="mono">{item.assetId}</span>],
["SKU", <span className="mono">{item.sku}</span>],
["Type", `${item.type} · ${item.kind}`],
["Strain", item.strainName ?? <span style={{ color: "var(--ink-3)" }}>Unlinked</span>],
["Brand", helpers.brandName(data, item.brandId)],
["Shop", helpers.shopName(data, item.shopId)],
["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`],
["Purchase date", fmt.date(item.purchaseDate)],
["Bin", bin ? bin.name : <span style={{ color: "var(--ink-3)" }}></span>],
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
[
"Cost per gram",
product.kind === "bulk" && product.weight > 0
? fmt.money(product.price / product.weight)
: product.kind === "discrete" && product.unitWeight > 0
? `${fmt.money(product.price / (product.countOriginal * product.unitWeight))} (effective)`
item.kind === "bulk" && item.weight > 0
? fmt.money(item.price / item.weight)
: item.kind === "discrete" && item.unitWeight > 0
? `${fmt.money(item.price / (item.countOriginal * item.unitWeight))} (effective)`
: "—",
],
];
if (product.status === "consumed") {
if (item.status === "consumed") {
detailRows.push(
["Date finished", fmt.date(product.consumedDate)],
["Date finished", fmt.date(item.consumedDate)],
[
"Lasted",
`${Math.round((+new Date(product.consumedDate!) - +new Date(product.purchaseDate)) / 86_400_000)} days`,
`${Math.round((+new Date(item.consumedDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
],
);
}
if (product.status === "gone") {
if (item.status === "gone") {
detailRows.push(
["Date gone", fmt.date(product.goneDate)],
["Date gone", fmt.date(item.goneDate)],
[
"After",
`${Math.round((+new Date(product.goneDate!) - +new Date(product.purchaseDate)) / 86_400_000)} days`,
`${Math.round((+new Date(item.goneDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
],
);
}
@@ -112,25 +118,25 @@ export function ProductDetail({
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Product · {product.sku}
Inventory · <span className="mono">{item.assetId}</span>
</div>
<div style={{ display: "flex", gap: 6 }}>
{isActive && (
<Btn variant="ghost" icon="check" onClick={() => onAudit(product)}>
<Btn variant="ghost" icon="check" onClick={() => onAudit(item)}>
Audit
</Btn>
)}
{isActive && (
<Btn variant="secondary" icon="check" onClick={() => onConsume(product)}>
<Btn variant="secondary" icon="check" onClick={() => onConsume(item)}>
Mark consumed
</Btn>
)}
{isActive && (
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(product)}>
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)}>
Mark gone
</Btn>
)}
<Btn variant="ghost" icon="edit" onClick={() => onEdit(product)}>
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)}>
Edit
</Btn>
<Btn variant="ghost" icon="close" onClick={onClose} />
@@ -140,13 +146,13 @@ export function ProductDetail({
<div style={{ padding: "32px 32px 60px" }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8 }}>
<div className="serif" style={{ fontSize: 18, color: "var(--ink-3)" }}>
{TYPE_GLYPHS[product.type]} {product.type}
{TYPE_GLYPHS[item.type]} {item.type}
</div>
{product.status === "consumed" && (
<Pill tone="terra">Consumed · {fmt.daysAgo(product.consumedDate)}</Pill>
{item.status === "consumed" && (
<Pill tone="terra">Consumed · {fmt.daysAgo(item.consumedDate)}</Pill>
)}
{product.status === "gone" && (
<Pill tone="amber">Gone · {fmt.daysAgo(product.goneDate)}</Pill>
{item.status === "gone" && (
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate)}</Pill>
)}
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
</div>
@@ -160,11 +166,34 @@ export function ProductDetail({
lineHeight: 1.1,
}}
>
{product.name}
{item.name}
</h1>
<div style={{ fontSize: 16, color: "var(--ink-2)" }}>
{helpers.brandName(data, product.brandId)} · from {helpers.shopName(data, product.shopId)}
{helpers.brandName(data, item.brandId)} · from {helpers.shopName(data, item.shopId)}
</div>
{product && (
<div style={{ marginTop: 8 }}>
<button
onClick={() => onEditProduct(product)}
style={{
background: "none",
border: "none",
fontSize: 12,
color: "var(--ink-3)",
cursor: "pointer",
textDecoration: "underline",
padding: 0,
}}
>
Edit product (catalog)
</button>
{siblings.length > 0 && (
<span style={{ fontSize: 12, color: "var(--ink-3)", marginLeft: 12 }}>
· {siblings.length} other instance{siblings.length === 1 ? "" : "s"} on file
</span>
)}
</div>
)}
<div
style={{
@@ -182,9 +211,9 @@ export function ProductDetail({
[
[
"Price",
product.kind === "discrete" && product.countOriginal > 0 ? (
item.kind === "discrete" && item.countOriginal > 0 ? (
<>
{fmt.money(product.price / product.countOriginal)}
{fmt.money(item.price / item.countOriginal)}
<span style={{ fontSize: 14, color: "var(--ink-3)", marginLeft: 4 }}>
/unit
</span>
@@ -198,21 +227,21 @@ export function ProductDetail({
letterSpacing: 0,
}}
>
{fmt.money(product.price)} total
{fmt.money(item.price)} total
</div>
</>
) : (
fmt.money(product.price)
fmt.money(item.price)
),
],
[
product.kind === "discrete" ? "Quantity" : "Size",
product.kind === "discrete"
? `${product.countOriginal} ${cfg?.unit ?? "ct"}`
: `${product.weight} ${cfg?.unit ?? "g"}`,
item.kind === "discrete" ? "Quantity" : "Size",
item.kind === "discrete"
? `${item.countOriginal} ${cfg?.unit ?? "ct"}`
: `${item.weight} ${cfg?.unit ?? "g"}`,
],
["THC", `${product.thc.toFixed(1)}%`],
["CBD", `${product.cbd.toFixed(1)}%`],
["THC", `${item.thc.toFixed(1)}%`],
["CBD", `${item.cbd.toFixed(1)}%`],
] as [string, React.ReactNode][]
).map(([l, v], i) => (
<div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
@@ -233,12 +262,12 @@ export function ProductDetail({
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{product.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
{item.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
</div>
<div style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
{product.kind === "discrete"
? `${product.countLastAudit ?? product.countOriginal} of ${product.countOriginal}`
: `${est.toFixed(2)} of ${product.weight} ${cfg?.unit ?? "g"}`}
{item.kind === "discrete"
? `${item.countLastAudit ?? item.countOriginal} of ${item.countOriginal}`
: `${est.toFixed(2)} of ${item.weight} ${cfg?.unit ?? "g"}`}
<span style={{ color: "var(--ink-3)", marginLeft: 8 }}>
{Math.round(pctRemaining * 100)}%
</span>
@@ -258,7 +287,7 @@ export function ProductDetail({
}}
/>
</div>
{product.kind === "bulk" && last && (
{item.kind === "bulk" && last && (
<div
style={{
fontSize: 11,
@@ -286,7 +315,7 @@ export function ProductDetail({
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Audit history</div>
{isActive && (
<button
onClick={() => onAudit(product)}
onClick={() => onAudit(item)}
style={{
background: "none",
border: "none",
@@ -300,9 +329,9 @@ export function ProductDetail({
</button>
)}
</div>
{product.audits.length === 0 ? (
{item.audits.length === 0 ? (
<div style={{ fontSize: 13, color: "var(--ink-3)", fontStyle: "italic", padding: "12px 0" }}>
No audits recorded. Cadence for {product.type}: every {cfg?.cadenceDays ?? "—"} days.
No audits recorded. Cadence for {item.type}: every {cfg?.cadenceDays ?? "—"} days.
</div>
) : (
<div
@@ -315,12 +344,12 @@ export function ProductDetail({
overflow: "hidden",
}}
>
{[...product.audits].reverse().map((a, i, arr) => (
{[...item.audits].reverse().map((a, idx, arr) => (
<div
key={i}
key={idx}
style={{
padding: "12px 16px",
borderBottom: i < arr.length - 1 ? "1px solid var(--line)" : "none",
borderBottom: idx < arr.length - 1 ? "1px solid var(--line)" : "none",
display: "flex",
alignItems: "center",
gap: 12,
@@ -384,7 +413,7 @@ export function ProductDetail({
</div>
</div>
{(product.status === "consumed" || product.status === "gone") && (
{(item.status === "consumed" || item.status === "gone") && (
<div
style={{
marginTop: 36,
@@ -403,16 +432,16 @@ export function ProductDetail({
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{product.status === "gone" ? "Why it's gone" : "Final notes"}
{item.status === "gone" ? "Why it's gone" : "Final notes"}
</div>
{product.status === "consumed" && (
{item.status === "consumed" && (
<div style={{ display: "flex", gap: 2 }}>
{[1, 2, 3, 4, 5].map((n) => (
<Icon
key={n}
name="star"
size={14}
color={n <= (product.rating ?? 0) ? "var(--amber)" : "var(--ink-4)"}
color={n <= (item.rating ?? 0) ? "var(--amber)" : "var(--ink-4)"}
/>
))}
</div>
@@ -422,7 +451,7 @@ export function ProductDetail({
className="serif"
style={{ fontSize: 18, lineHeight: 1.5, color: "var(--ink-2)", fontStyle: "italic" }}
>
"{product.notes ?? "No notes recorded."}"
"{item.notes ?? "No notes recorded."}"
</div>
</div>
)}