02dc6e523f
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>
237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { api } from "./api.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";
|
|
import { Dashboard } from "./views/Dashboard.js";
|
|
import { Inventory } from "./views/Inventory.js";
|
|
import { BinsView } from "./views/BinsView.js";
|
|
import { BrandsView } from "./views/BrandsView.js";
|
|
import { ShopsView } from "./views/ShopsView.js";
|
|
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 { 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";
|
|
import { AuditFlow } from "./components/modals/AuditFlow.js";
|
|
import {
|
|
AddBinModal,
|
|
AddBrandModal,
|
|
AddShopModal,
|
|
EditBinModal,
|
|
EditBrandModal,
|
|
EditShopModal,
|
|
} from "./components/modals/CatalogModals.js";
|
|
|
|
type ModalKey =
|
|
| "add"
|
|
| "edit"
|
|
| "editProduct"
|
|
| "consume"
|
|
| "gone"
|
|
| "audit"
|
|
| "addBrand"
|
|
| "addShop"
|
|
| "addBin"
|
|
| "editBin"
|
|
| "editBrand"
|
|
| "editShop"
|
|
| null;
|
|
|
|
export function App() {
|
|
const [view, setView] = useState<ViewKey>("dashboard");
|
|
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);
|
|
const [modalShop, setModalShop] = useState<Shop | null>(null);
|
|
|
|
const [theme, setTheme] = useState<ThemeKey>(
|
|
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
|
);
|
|
|
|
useEffect(() => {
|
|
document.documentElement.dataset.theme = theme;
|
|
localStorage.setItem("apothecary.theme", theme);
|
|
}, [theme]);
|
|
|
|
const { data, isLoading, error } = useQuery<Bootstrap>({
|
|
queryKey: ["bootstrap"],
|
|
queryFn: api.bootstrap,
|
|
});
|
|
|
|
const stats = useMemo(() => (data ? computeStats(data) : null), [data]);
|
|
const items = useMemo(() => (data ? enrichItems(data) : []), [data]);
|
|
|
|
// 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 = items.find((i) => i.id === selected.id);
|
|
if (fresh && fresh !== selected) setSelected(fresh);
|
|
}
|
|
}, [data, items]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const openAdd = () => {
|
|
setModalItem(null);
|
|
setModal("add");
|
|
};
|
|
const openConsume = (i?: Item) => {
|
|
setModalItem(i ?? null);
|
|
setSelected(null);
|
|
setModal("consume");
|
|
};
|
|
const openMarkGone = (i?: Item) => {
|
|
setModalItem(i ?? null);
|
|
setSelected(null);
|
|
setModal("gone");
|
|
};
|
|
const openAudit = (i?: Item) => {
|
|
setModalItem(i ?? null);
|
|
setModal("audit");
|
|
};
|
|
const openEdit = (i: Item) => {
|
|
setModalItem(i);
|
|
setSelected(null);
|
|
setModal("edit");
|
|
};
|
|
const openEditProduct = (p: Product) => {
|
|
setModalProduct(p);
|
|
setSelected(null);
|
|
setModal("editProduct");
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="parchment" style={{ padding: 60, color: "var(--ink-3)" }}>
|
|
Loading…
|
|
</div>
|
|
);
|
|
}
|
|
if (error || !data || !stats) {
|
|
return (
|
|
<div className="parchment" style={{ padding: 60, color: "var(--terracotta)" }}>
|
|
Failed to load: {String(error ?? "no data")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="app-shell" data-screen-label="App">
|
|
<Sidebar
|
|
view={view}
|
|
onNav={setView}
|
|
onAddProduct={openAdd}
|
|
onMarkFinished={() => openConsume()}
|
|
onAudit={() => openAudit()}
|
|
/>
|
|
|
|
<main className="main parchment" style={{ minWidth: 0 }}>
|
|
{view === "dashboard" && (
|
|
<Dashboard
|
|
data={data}
|
|
stats={stats}
|
|
onAuditItem={openAudit}
|
|
onSelectItem={setSelected}
|
|
/>
|
|
)}
|
|
{view === "inventory" && (
|
|
<Inventory
|
|
data={data}
|
|
onSelectItem={setSelected}
|
|
onAddInventory={openAdd}
|
|
onAuditNew={() => openAudit()}
|
|
/>
|
|
)}
|
|
{view === "bins" && (
|
|
<BinsView
|
|
data={data}
|
|
onSelectItem={setSelected}
|
|
onAddBin={() => setModal("addBin")}
|
|
onEditBin={(bin) => {
|
|
setModalBin(bin);
|
|
setModal("editBin");
|
|
}}
|
|
/>
|
|
)}
|
|
{view === "shops" && (
|
|
<ShopsView
|
|
data={data}
|
|
onAddShop={() => setModal("addShop")}
|
|
onEditShop={(shop) => {
|
|
setModalShop(shop);
|
|
setModal("editShop");
|
|
}}
|
|
/>
|
|
)}
|
|
{view === "brands" && (
|
|
<BrandsView
|
|
data={data}
|
|
onAddBrand={() => setModal("addBrand")}
|
|
onEditBrand={(brand) => {
|
|
setModalBrand(brand);
|
|
setModal("editBrand");
|
|
}}
|
|
/>
|
|
)}
|
|
{view === "charts" && <ChartsView data={data} stats={stats} />}
|
|
{view === "settings" && (
|
|
<SettingsView data={data} theme={theme} onThemeChange={setTheme} />
|
|
)}
|
|
</main>
|
|
|
|
{selected && (
|
|
<ProductDetail
|
|
item={selected}
|
|
data={data}
|
|
onClose={() => setSelected(null)}
|
|
onConsume={openConsume}
|
|
onMarkGone={openMarkGone}
|
|
onAudit={openAudit}
|
|
onEdit={openEdit}
|
|
onEditProduct={openEditProduct}
|
|
/>
|
|
)}
|
|
|
|
{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)} item={modalItem} />
|
|
)}
|
|
{modal === "gone" && (
|
|
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
|
)}
|
|
{modal === "audit" && (
|
|
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
|
)}
|
|
{modal === "addBrand" && <AddBrandModal onClose={() => setModal(null)} />}
|
|
{modal === "addShop" && <AddShopModal onClose={() => setModal(null)} />}
|
|
{modal === "addBin" && <AddBinModal onClose={() => setModal(null)} />}
|
|
{modal === "editBin" && modalBin && (
|
|
<EditBinModal bin={modalBin} onClose={() => setModal(null)} />
|
|
)}
|
|
{modal === "editBrand" && modalBrand && (
|
|
<EditBrandModal brand={modalBrand} onClose={() => setModal(null)} />
|
|
)}
|
|
{modal === "editShop" && modalShop && (
|
|
<EditShopModal shop={modalShop} onClose={() => setModal(null)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|