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
+45 -28
View File
@@ -1,19 +1,25 @@
import { useEffect, useState } from "react";
import type { Product } from "../types.js";
import type { Item, Product } from "../types.js";
import { Icon, Field, inputStyle } from "./primitives/index.js";
// Scan-friendly product picker. Auto-focuses on mount so a barcode scanner
// can fire immediately. Exact (case-insensitive) match against sku or
// assetTag → calls onMatch and clears itself for the next scan. Falls back
// to a "no match" hint when the value doesn't resolve.
export type ScanResult =
| { kind: "item"; item: Item }
| { kind: "product"; product: Product };
// Scan-friendly picker. Auto-focuses on mount so a barcode scanner
// can fire immediately. Exact (case-insensitive) match against assetId
// (per-instance) or sku (per-product) → calls onMatch and clears itself.
// Asset id wins ties since it's the more specific identifier.
export function ScanField({
items,
products,
onMatch,
matchedProduct,
matchedLabel,
}: {
products: Product[];
onMatch: (productId: string) => void;
matchedProduct: Product | null;
items: Item[];
products?: Product[];
onMatch: (result: ScanResult) => void;
matchedLabel: string | null;
}) {
const [scan, setScan] = useState("");
const [feedback, setFeedback] = useState<{ type: "matched" | "miss"; text: string } | null>(null);
@@ -24,15 +30,13 @@ export function ScanField({
setFeedback(null);
return;
}
const match = products.find(
(p) =>
p.sku.toLowerCase() === trimmed ||
(p.assetTag != null && p.assetTag.toLowerCase() === trimmed),
);
if (match) {
onMatch(match.id);
const hit = lookup(trimmed, items, products);
if (hit) {
onMatch(hit);
setScan("");
setFeedback({ type: "matched", text: `Matched ${match.name}` });
const name =
hit.kind === "item" ? hit.item.name : hit.product.name;
setFeedback({ type: "matched", text: `Matched ${name}` });
}
}, [scan]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -42,18 +46,15 @@ export function ScanField({
if (!scan.trim() || feedback?.type === "matched") return;
const timer = setTimeout(() => {
const trimmed = scan.trim().toLowerCase();
const match = products.find(
(p) =>
p.sku.toLowerCase() === trimmed ||
(p.assetTag != null && p.assetTag.toLowerCase() === trimmed),
);
if (!match) setFeedback({ type: "miss", text: "No SKU or asset tag matches that." });
if (!lookup(trimmed, items, products)) {
setFeedback({ type: "miss", text: "No asset id or SKU matches that." });
}
}, 400);
return () => clearTimeout(timer);
}, [scan, products]); // eslint-disable-line react-hooks/exhaustive-deps
}, [scan, items, products]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Field label="Scan SKU or asset tag" hint="Or pick from the list below.">
<Field label="Scan asset id or SKU" hint="Or pick from the list below.">
<div
style={{
display: "flex",
@@ -69,7 +70,7 @@ export function ScanField({
value={scan}
onChange={(e) => setScan(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
placeholder="SKU-XXXXXX or AT-0000"
placeholder="K3F9X2 or SKU-XXXXXX"
style={{
border: "none",
outline: "none",
@@ -81,7 +82,7 @@ export function ScanField({
fontFamily: "var(--mono)",
}}
/>
{matchedProduct && !scan && (
{matchedLabel && !scan && (
<span
className="mono"
style={{
@@ -90,7 +91,7 @@ export function ScanField({
whiteSpace: "nowrap",
}}
>
{matchedProduct.name}
{matchedLabel}
</span>
)}
</div>
@@ -108,3 +109,19 @@ export function ScanField({
</Field>
);
}
function lookup(
trimmed: string,
items: Item[],
products?: Product[],
): ScanResult | null {
const itemHit = items.find((i) => i.assetId.toLowerCase() === trimmed);
if (itemHit) return { kind: "item", item: itemHit };
const skuHitItem = items.find((i) => i.sku.toLowerCase() === trimmed);
if (skuHitItem) return { kind: "item", item: skuHitItem };
if (products) {
const productHit = products.find((p) => p.sku.toLowerCase() === trimmed);
if (productHit) return { kind: "product", product: productHit };
}
return null;
}