import { useEffect, useMemo, useState } from "react"; import type { Bootstrap, Item } from "../types.js"; import { TYPES, helpers, TODAY_STR, enrichItems } from "../types.js"; import { remainingShort } from "../stats.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; import { Btn, Card, Pill, Icon, Select, inputStyle } from "../components/primitives/index.js"; type FilterKey = "active" | "checked-out" | "consumed" | "gone" | "all"; type SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit"; type ViewKey = "flat" | "grouped"; const GRID_COLS = "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr"; export function Inventory({ data, onSelectItem, onAddInventory, onAuditNew, }: { data: Bootstrap; onSelectItem: (i: Item) => void; onAddInventory: () => void; onAuditNew: () => void; }) { const items = useMemo(() => enrichItems(data), [data]); const [filter, setFilter] = useState("active"); const [typeFilter, setTypeFilter] = useState("all"); const [sortBy, setSortBy] = useState("recent"); const [search, setSearch] = useState(""); const [view, setView] = useState( () => (localStorage.getItem("apothecary.inventoryView") as ViewKey | null) ?? "flat", ); useEffect(() => { localStorage.setItem("apothecary.inventoryView", view); }, [view]); const sortFn = (a: Item, b: Item) => { if (sortBy === "recent") return +new Date(b.purchaseDate) - +new Date(a.purchaseDate); if (sortBy === "name") return a.name.localeCompare(b.name); if (sortBy === "thc") return b.thc - a.thc; if (sortBy === "remaining") return helpers.estimatedRemaining(b, TODAY_STR) - helpers.estimatedRemaining(a, TODAY_STR); if (sortBy === "price") return b.price - a.price; if (sortBy === "audit") return helpers.daysSinceCheck(b, TODAY_STR) - helpers.daysSinceCheck(a, TODAY_STR); return 0; }; const filtered = useMemo(() => { let out = items; if (filter === "active") out = out.filter((i) => i.status === "active"); else if (filter === "checked-out") out = out.filter((i) => i.status === "checked-out"); else if (filter === "consumed") out = out.filter((i) => i.status === "consumed"); else if (filter === "gone") out = out.filter((i) => i.status === "gone"); if (typeFilter !== "all") out = out.filter((i) => i.type === typeFilter); if (search) { const q = search.toLowerCase(); out = out.filter((i) => { const brand = helpers.brandName(data, i.brandId).toLowerCase(); const shop = helpers.shopName(data, i.shopId).toLowerCase(); return ( i.name.toLowerCase().includes(q) || brand.includes(q) || shop.includes(q) || i.sku.toLowerCase().includes(q) || i.assetId.toLowerCase().includes(q) ); }); } return out; }, [items, data, filter, typeFilter, search]); const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy]); // Grouped mode: bucket by productId. Same-product instances collapse under // a header that shows total count + total remaining + last purchase. type Group = { productId: string; label: string; sku: string; type: string; items: Item[]; }; const groups: Group[] = useMemo(() => { const byProduct = new Map(); for (const i of filtered) { const arr = byProduct.get(i.productId) ?? []; arr.push(i); byProduct.set(i.productId, arr); } const out: Group[] = []; for (const [productId, list] of byProduct.entries()) { const first = list[0]!; out.push({ productId, label: first.name, sku: first.sku, type: first.type, items: [...list].sort(sortFn), }); } out.sort((a, b) => { const aMax = Math.max(...a.items.map((p) => +new Date(p.purchaseDate))); const bMax = Math.max(...b.items.map((p) => +new Date(p.purchaseDate))); return bMax - aMax; }); return out; }, [filtered, sortBy]); return (
{sorted.length} item{sorted.length === 1 ? "" : "s"}

Inventory

Audit Add inventory
value={filter} options={[ ["active", "Active"], ["checked-out", "Checked out"], ["consumed", "Consumed"], ["gone", "Gone"], ["all", "All"], ]} onChange={setFilter} /> value={view} options={[ ["flat", "Flat"], ["grouped", "By product"], ]} onChange={setView} />
setSearch(e.target.value)} style={{ border: "none", outline: "none", background: "transparent", padding: "8px 0", fontSize: 13, flex: 1, color: "var(--ink)", }} /> {search && ( )}
{sorted.length === 0 && (
No items match these filters.
)} {view === "flat" && sorted.map((i) => ( ))} {view === "grouped" && groups.map((g) => (
{g.items.map((i) => ( ))}
))}
); } function Segmented({ value, options, onChange, }: { value: T; options: [T, string][]; onChange: (v: T) => void; }) { return (
{options.map(([k, l]) => ( ))}
); } const COL_SORT: (SortKey | null)[] = [null, "name", null, null, "thc", "price", "remaining", "audit", null]; const COL_LABELS = ["", "Item", "Brand", "Shop", "THC %", "Price", "Remaining", "Last checked", "Bin"]; function HeaderRow({ sortBy, onSort }: { sortBy: SortKey; onSort: (k: SortKey) => void }) { return (
{COL_LABELS.map((label, i) => { const sk = COL_SORT[i]; if (!sk) return
{label}
; const active = sortBy === sk; return ( ); })}
); } function GroupHeader({ group, }: { group: { productId: string; label: string; sku: string; type: string; items: Item[]; }; }) { // Aggregate remaining: bulk uses estimatedRemaining; discrete uses unitWeight × count. // Counts use status === "active" only — archived rows shouldn't inflate "on hand." const active = group.items.filter((i) => i.status === "active"); const totalRemaining = active.reduce((s, i) => { if (i.kind === "bulk") return s + helpers.estimatedRemaining(i, TODAY_STR); const cur = i.countLastAudit ?? i.countOriginal; return s + cur * (i.unitWeight || 0); }, 0); const lastBuy = group.items.reduce((max, i) => { const t = +new Date(i.purchaseDate); return t > max ? t : max; }, 0); const cfg = TYPES.find((t) => t.id === group.type); return (
{TYPE_GLYPHS[group.type]}
{group.label}
{group.sku} · {group.type}
{active.length} active {group.items.length !== active.length && ( / {group.items.length} total )}
{totalRemaining.toFixed(2).replace(/\.?0+$/, "") || "0"} {" "} {cfg?.unit ?? "g"} on hand
{lastBuy > 0 && (
last buy{" "} {fmt.dateShort(new Date(lastBuy).toISOString())}
)}
); } function ItemRow({ i, data, onSelect, indented = false, }: { i: Item; data: Bootstrap; onSelect: (i: Item) => void; indented?: boolean; }) { const bin = data.bins.find((b) => b.id === i.binId); const pctRemaining = helpers.pctRemaining(i, TODAY_STR); const overdue = helpers.auditOverdue(i, TODAY_STR); const sinceCheck = helpers.daysSinceCheck(i, TODAY_STR); const last = helpers.lastAudit(i); const isInactive = i.status !== "active" && i.status !== "checked-out"; return (
onSelect(i)} className="inv-row" style={{ display: "grid", gridTemplateColumns: GRID_COLS, columnGap: 16, padding: indented ? "14px 20px 14px 36px" : "14px 20px", borderBottom: "1px solid var(--line)", alignItems: "center", cursor: "pointer", opacity: isInactive ? 0.55 : 1, fontSize: 13, borderLeft: indented ? "2px solid var(--bg-3)" : "none", }} >
{TYPE_GLYPHS[i.type]}
{i.name} {i.status === "consumed" && ( Consumed )} {i.status === "gone" && ( Gone )} {i.status === "checked-out" && ( Checked out )} {i.status === "active" && overdue && ( Audit due )}
{i.assetId} · {i.sku}
{helpers.brandName(data, i.brandId)}
{helpers.shopName(data, i.shopId)}
{i.thc.toFixed(1)}
{fmt.money(i.price)}
{remainingShort(i)}
{i.status === "active" && i.kind === "bulk" && (
)}
{i.status !== "active" ? ( archived ) : last ? ( {sinceCheck}d ago · {last.mode} ) : ( never )}
{i.status === "checked-out" ? ( Custody ) : bin ? bin.name : }
); }