Track inventory at the instance level, not by product
Build and push image / build (push) Successful in 46s
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user