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
+40 -26
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "./api.js";
import type { Bin, Bootstrap, Brand, Product, Shop } from "./types.js";
import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js";
import { enrichItems } from "./types.js";
import { computeStats } from "./stats.js";
import { Sidebar } from "./components/Sidebar.js";
import type { ViewKey } from "./components/Sidebar.js";
@@ -14,7 +15,8 @@ import { ChartsView } from "./views/ChartsView.js";
import { SettingsView } from "./views/SettingsView.js";
import type { ThemeKey } from "./views/SettingsView.js";
import { ProductDetail } from "./components/ProductDetail.js";
import { AddProductFlow } from "./components/modals/AddProductFlow.js";
import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js";
import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js";
import { EditProductFlow } from "./components/modals/EditProductFlow.js";
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js";
@@ -31,6 +33,7 @@ import {
type ModalKey =
| "add"
| "edit"
| "editProduct"
| "consume"
| "gone"
| "audit"
@@ -44,8 +47,9 @@ type ModalKey =
export function App() {
const [view, setView] = useState<ViewKey>("dashboard");
const [selected, setSelected] = useState<Product | null>(null);
const [selected, setSelected] = useState<Item | null>(null);
const [modal, setModal] = useState<ModalKey>(null);
const [modalItem, setModalItem] = useState<Item | null>(null);
const [modalProduct, setModalProduct] = useState<Product | null>(null);
const [modalBin, setModalBin] = useState<Bin | null>(null);
const [modalBrand, setModalBrand] = useState<Brand | null>(null);
@@ -66,40 +70,46 @@ export function App() {
});
const stats = useMemo(() => (data ? computeStats(data) : null), [data]);
const items = useMemo(() => (data ? enrichItems(data) : []), [data]);
// Re-sync the selected product reference whenever bootstrap refetches
// Re-sync the selected item reference whenever bootstrap refetches
// — otherwise the drawer keeps showing stale audit history after a
// mutation invalidates the cache.
useEffect(() => {
if (selected && data) {
const fresh = data.products.find((p) => p.id === selected.id);
const fresh = items.find((i) => i.id === selected.id);
if (fresh && fresh !== selected) setSelected(fresh);
}
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
}, [data, items]); // eslint-disable-line react-hooks/exhaustive-deps
const openAdd = () => {
setModalProduct(null);
setModalItem(null);
setModal("add");
};
const openConsume = (p?: Product) => {
setModalProduct(p ?? null);
const openConsume = (i?: Item) => {
setModalItem(i ?? null);
setSelected(null);
setModal("consume");
};
const openMarkGone = (p?: Product) => {
setModalProduct(p ?? null);
const openMarkGone = (i?: Item) => {
setModalItem(i ?? null);
setSelected(null);
setModal("gone");
};
const openAudit = (p?: Product) => {
setModalProduct(p ?? null);
const openAudit = (i?: Item) => {
setModalItem(i ?? null);
setModal("audit");
};
const openEdit = (p: Product) => {
setModalProduct(p);
const openEdit = (i: Item) => {
setModalItem(i);
setSelected(null);
setModal("edit");
};
const openEditProduct = (p: Product) => {
setModalProduct(p);
setSelected(null);
setModal("editProduct");
};
if (isLoading) {
return (
@@ -131,22 +141,22 @@ export function App() {
<Dashboard
data={data}
stats={stats}
onAuditProduct={openAudit}
onSelectProduct={setSelected}
onAuditItem={openAudit}
onSelectItem={setSelected}
/>
)}
{view === "inventory" && (
<Inventory
data={data}
onSelectProduct={setSelected}
onAddProduct={openAdd}
onSelectItem={setSelected}
onAddInventory={openAdd}
onAuditNew={() => openAudit()}
/>
)}
{view === "bins" && (
<BinsView
data={data}
onSelectProduct={setSelected}
onSelectItem={setSelected}
onAddBin={() => setModal("addBin")}
onEditBin={(bin) => {
setModalBin(bin);
@@ -182,28 +192,32 @@ export function App() {
{selected && (
<ProductDetail
product={selected}
item={selected}
data={data}
onClose={() => setSelected(null)}
onConsume={openConsume}
onMarkGone={openMarkGone}
onAudit={openAudit}
onEdit={openEdit}
onEditProduct={openEditProduct}
/>
)}
{modal === "add" && <AddProductFlow data={data} onClose={() => setModal(null)} />}
{modal === "edit" && modalProduct && (
{modal === "add" && <AddInventoryFlow data={data} onClose={() => setModal(null)} />}
{modal === "edit" && modalItem && (
<EditInventoryFlow data={data} item={modalItem} onClose={() => setModal(null)} />
)}
{modal === "editProduct" && modalProduct && (
<EditProductFlow data={data} product={modalProduct} onClose={() => setModal(null)} />
)}
{modal === "consume" && (
<ConsumeFlow data={data} onClose={() => setModal(null)} product={modalProduct} />
<ConsumeFlow data={data} onClose={() => setModal(null)} item={modalItem} />
)}
{modal === "gone" && (
<MarkGoneFlow data={data} onClose={() => setModal(null)} product={modalProduct} />
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
)}
{modal === "audit" && (
<AuditFlow data={data} onClose={() => setModal(null)} product={modalProduct} />
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} />
)}
{modal === "addBrand" && <AddBrandModal onClose={() => setModal(null)} />}
{modal === "addShop" && <AddShopModal onClose={() => setModal(null)} />}