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,43 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Product } from "../../types.js";
|
||||
import { helpers, TODAY_STR } from "../../types.js";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { helpers, TODAY_STR, enrichItems } from "../../types.js";
|
||||
import { remainingShort } from "../../stats.js";
|
||||
import { fmt } from "../../format.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js";
|
||||
import { ScanField } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
|
||||
export function ConsumeFlow({
|
||||
data,
|
||||
onClose,
|
||||
product: initialProduct,
|
||||
item: initialItem,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
item: Item | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const active = data.products.filter((p) => p.status === "active");
|
||||
const [productId, setProductId] = useState(initialProduct?.id ?? active[0]?.id ?? "");
|
||||
const allItems = enrichItems(data);
|
||||
const active = allItems.filter((i) => i.status === "active");
|
||||
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
||||
const [rating, setRating] = useState(4);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
|
||||
const product = data.products.find((p) => p.id === productId);
|
||||
const item = allItems.find((i) => i.id === itemId);
|
||||
|
||||
const finish = useMutation({
|
||||
mutationFn: () => api.finishProduct(productId, { date, rating, notes }),
|
||||
mutationFn: () => api.finishInventoryItem(itemId, { date, rating, notes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) return null;
|
||||
const bin = data.bins.find((b) => b.id === product.binId);
|
||||
const lifespan = Math.round((+new Date(date) - +new Date(product.purchaseDate)) / 86_400_000);
|
||||
const handleScan = (result: ScanResult) => {
|
||||
if (result.kind === "item") {
|
||||
setItemId(result.item.id);
|
||||
} else {
|
||||
const candidate = active
|
||||
.filter((i) => i.productId === result.product.id)
|
||||
.sort((a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate))[0];
|
||||
if (candidate) setItemId(candidate.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (!item) return null;
|
||||
const bin = data.bins.find((b) => b.id === item.binId);
|
||||
const lifespan = Math.round((+new Date(date) - +new Date(item.purchaseDate)) / 86_400_000);
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
@@ -55,17 +67,18 @@ export function ConsumeFlow({
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<ScanField
|
||||
products={active}
|
||||
matchedProduct={product ?? null}
|
||||
onMatch={setProductId}
|
||||
items={active}
|
||||
products={data.products}
|
||||
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||
onMatch={handleScan}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Or pick from list">
|
||||
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
{active.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} — {helpers.brandName(data, p.brandId)} ({remainingShort(p)} left)
|
||||
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
|
||||
{active.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.assetId} · {i.name} — {helpers.brandName(data, i.brandId)} ({remainingShort(i)} left)
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -86,11 +99,11 @@ export function ConsumeFlow({
|
||||
>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||
{product.name}
|
||||
{item.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{helpers.brandName(data, product.brandId)} · {bin?.name} · purchased{" "}
|
||||
{fmt.dateShort(product.purchaseDate)}
|
||||
<span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "}
|
||||
{fmt.dateShort(item.purchaseDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
|
||||
Reference in New Issue
Block a user