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:
+29
-25
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Bin, Product } from "../types.js";
|
||||
import { helpers, TODAY_STR } from "../types.js";
|
||||
import type { Bootstrap, Bin, Item } from "../types.js";
|
||||
import { helpers, TODAY_STR, enrichItems } from "../types.js";
|
||||
import { remainingShort } from "../stats.js";
|
||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||
import { api } from "../api.js";
|
||||
@@ -38,16 +39,18 @@ function groupBins(bins: Bin[]): [string, Bin[]][] {
|
||||
|
||||
export function BinsView({
|
||||
data,
|
||||
onSelectProduct,
|
||||
onSelectItem,
|
||||
onAddBin,
|
||||
onEditBin,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onSelectProduct: (p: Product) => void;
|
||||
onSelectItem: (i: Item) => void;
|
||||
onAddBin: () => void;
|
||||
onEditBin: (bin: Bin) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const items = useMemo(() => enrichItems(data), [data]);
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.deleteBin(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }),
|
||||
@@ -56,7 +59,7 @@ export function BinsView({
|
||||
const handleDelete = (binId: string, binName: string, activeCount: number) => {
|
||||
const msg =
|
||||
activeCount > 0
|
||||
? `Delete "${binName}"? ${activeCount} active product${activeCount === 1 ? "" : "s"} will be moved to Unassigned.`
|
||||
? `Delete "${binName}"? ${activeCount} active item${activeCount === 1 ? "" : "s"} will be moved to Unassigned.`
|
||||
: `Delete "${binName}"?`;
|
||||
if (window.confirm(msg)) remove.mutate(binId);
|
||||
};
|
||||
@@ -84,14 +87,14 @@ export function BinsView({
|
||||
<Btn variant="secondary" icon="plus" onClick={onAddBin}>New bin</Btn>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600 }}>
|
||||
Where each active product physically lives. Archived items aren't assigned to a bin.
|
||||
Where each active inventory item physically lives. Archived items aren't assigned to a bin.
|
||||
</div>
|
||||
|
||||
{data.bins.length === 0 && (
|
||||
<Card style={{ padding: 60, textAlign: "center" }}>
|
||||
<div className="serif" style={{ fontSize: 22, marginBottom: 6 }}>No bins yet</div>
|
||||
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
|
||||
Add a bin to start placing products somewhere.
|
||||
Add a bin to start placing items somewhere.
|
||||
</div>
|
||||
<Btn variant="primary" icon="plus" onClick={onAddBin}>Add your first bin</Btn>
|
||||
</Card>
|
||||
@@ -107,19 +110,19 @@ export function BinsView({
|
||||
}}
|
||||
>
|
||||
{bins.map((bin) => {
|
||||
const items = data.products.filter(
|
||||
(p) => p.binId === bin.id && p.status === "active",
|
||||
const binItems = items.filter(
|
||||
(i) => i.binId === bin.id && i.status === "active",
|
||||
);
|
||||
// Discrete products (pre-rolls, edibles, vapes) take a slot per unit;
|
||||
// bulk products take one slot per jar/container.
|
||||
const slotsUsed = items.reduce(
|
||||
(s, p) =>
|
||||
s + (p.kind === "discrete" ? (p.countLastAudit ?? p.countOriginal) : 1),
|
||||
// Discrete items (pre-rolls, edibles, vapes) take a slot per unit;
|
||||
// bulk items take one slot per jar/container.
|
||||
const slotsUsed = binItems.reduce(
|
||||
(s, i) =>
|
||||
s + (i.kind === "discrete" ? (i.countLastAudit ?? i.countOriginal) : 1),
|
||||
0,
|
||||
);
|
||||
const fillPct = slotsUsed / bin.capacity;
|
||||
const totalValue = items.reduce(
|
||||
(s, p) => s + p.price * helpers.pctRemaining(p, TODAY_STR),
|
||||
const totalValue = binItems.reduce(
|
||||
(s, i) => s + i.price * helpers.pctRemaining(i, TODAY_STR),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
@@ -156,7 +159,7 @@ export function BinsView({
|
||||
<Icon name="edit" size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(bin.id, bin.name, items.length)}
|
||||
onClick={() => handleDelete(bin.id, bin.name, binItems.length)}
|
||||
title="Remove bin"
|
||||
aria-label={`Remove bin ${bin.name}`}
|
||||
disabled={remove.isPending}
|
||||
@@ -208,7 +211,7 @@ export function BinsView({
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 8, flex: 1 }}>
|
||||
{items.length === 0 && (
|
||||
{binItems.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: 30,
|
||||
@@ -221,10 +224,10 @@ export function BinsView({
|
||||
Empty
|
||||
</div>
|
||||
)}
|
||||
{items.map((p) => (
|
||||
{binItems.map((i) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => onSelectProduct(p)}
|
||||
key={i.id}
|
||||
onClick={() => onSelectItem(i)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -242,7 +245,7 @@ export function BinsView({
|
||||
width: 18,
|
||||
}}
|
||||
>
|
||||
{TYPE_GLYPHS[p.type]}
|
||||
{TYPE_GLYPHS[i.type]}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
@@ -254,14 +257,15 @@ export function BinsView({
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
{i.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||
{helpers.brandName(data, p.brandId)}
|
||||
<span className="mono">{i.assetId}</span> ·{" "}
|
||||
{helpers.brandName(data, i.brandId)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-2)" }}>
|
||||
{remainingShort(p)}
|
||||
{remainingShort(i)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -18,12 +18,9 @@ export function BrandsView({
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }),
|
||||
});
|
||||
|
||||
const handleDelete = (brandId: string, brandName: string, productCount: number, strainCount: number) => {
|
||||
const parts: string[] = [];
|
||||
if (productCount > 0) parts.push(`${productCount} product${productCount === 1 ? "" : "s"}`);
|
||||
if (strainCount > 0) parts.push(`${strainCount} strain${strainCount === 1 ? "" : "s"}`);
|
||||
const tail = parts.length > 0
|
||||
? ` ${parts.join(" and ")} will be unbranded.`
|
||||
const handleDelete = (brandId: string, brandName: string, itemCount: number) => {
|
||||
const tail = itemCount > 0
|
||||
? ` ${itemCount} inventory item${itemCount === 1 ? "" : "s"} will be unbranded.`
|
||||
: "";
|
||||
if (window.confirm(`Delete "${brandName}"?${tail}`)) remove.mutate(brandId);
|
||||
};
|
||||
@@ -71,8 +68,7 @@ export function BrandsView({
|
||||
}}
|
||||
>
|
||||
{data.brands.map((b) => {
|
||||
const productCount = data.products.filter((p) => p.brandId === b.id).length;
|
||||
const strainCount = data.strains.filter((s) => s.brandId === b.id).length;
|
||||
const itemCount = data.inventoryItems.filter((i) => i.brandId === b.id).length;
|
||||
return (
|
||||
<Card key={b.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -81,7 +77,7 @@ export function BrandsView({
|
||||
</div>
|
||||
</div>
|
||||
<Pill tone="outline">
|
||||
{productCount} purchase{productCount === 1 ? "" : "s"}
|
||||
{itemCount} purchase{itemCount === 1 ? "" : "s"}
|
||||
</Pill>
|
||||
<button
|
||||
onClick={() => onEditBrand(b)}
|
||||
@@ -100,7 +96,7 @@ export function BrandsView({
|
||||
<Icon name="edit" size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(b.id, b.name, productCount, strainCount)}
|
||||
onClick={() => handleDelete(b.id, b.name, itemCount)}
|
||||
title="Remove brand"
|
||||
aria-label={`Remove brand ${b.name}`}
|
||||
disabled={remove.isPending}
|
||||
|
||||
@@ -8,16 +8,16 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
|
||||
const series = stats.series90.map((s) => ({ date: s.date, grams: s.grams }));
|
||||
|
||||
const spendByMonth: Record<string, number> = {};
|
||||
data.products.forEach((p) => {
|
||||
const k = p.purchaseDate.slice(0, 7);
|
||||
spendByMonth[k] = (spendByMonth[k] ?? 0) + p.price;
|
||||
data.inventoryItems.forEach((i) => {
|
||||
const k = i.purchaseDate.slice(0, 7);
|
||||
spendByMonth[k] = (spendByMonth[k] ?? 0) + i.price;
|
||||
});
|
||||
const months = Object.entries(spendByMonth).sort();
|
||||
|
||||
const spendByShop: Record<string, number> = {};
|
||||
data.products.forEach((p) => {
|
||||
const name = helpers.shopName(data, p.shopId);
|
||||
spendByShop[name] = (spendByShop[name] ?? 0) + p.price;
|
||||
data.inventoryItems.forEach((i) => {
|
||||
const name = helpers.shopName(data, i.shopId);
|
||||
spendByShop[name] = (spendByShop[name] ?? 0) + i.price;
|
||||
});
|
||||
const shopRanked = Object.entries(spendByShop).sort((a, b) => b[1] - a[1]);
|
||||
const shopMax = shopRanked[0]?.[1] ?? 1;
|
||||
|
||||
+26
-18
@@ -1,4 +1,4 @@
|
||||
import type { Bootstrap, Product } from "../types.js";
|
||||
import type { Bootstrap, Item } from "../types.js";
|
||||
import { helpers, TODAY_STR } from "../types.js";
|
||||
import type { Stats } from "../stats.js";
|
||||
import { remainingShort } from "../stats.js";
|
||||
@@ -18,13 +18,13 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
export function Dashboard({
|
||||
data,
|
||||
stats,
|
||||
onAuditProduct,
|
||||
onSelectProduct,
|
||||
onAuditItem,
|
||||
onSelectItem,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
stats: Stats;
|
||||
onAuditProduct: (p: Product) => void;
|
||||
onSelectProduct: (p: Product) => void;
|
||||
onAuditItem: (i: Item) => void;
|
||||
onSelectItem: (i: Item) => void;
|
||||
}) {
|
||||
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
|
||||
const last7Series = stats.series7.map((l) => l.grams);
|
||||
@@ -40,6 +40,14 @@ export function Dashboard({
|
||||
const lowBulk = stats.lowStockBulk;
|
||||
const lowDiscrete = stats.lowStockDiscreteGroups;
|
||||
|
||||
const todayDate = new Date(data.today || TODAY_STR);
|
||||
const greetingDate = todayDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -49,7 +57,7 @@ export function Dashboard({
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Saturday · April 25, 2026</div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{greetingDate}</div>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{
|
||||
@@ -92,7 +100,7 @@ export function Dashboard({
|
||||
<Stat
|
||||
label="Avg cost per gram"
|
||||
value={fmt.money(stats.avgPerGram)}
|
||||
sub={`Across ${data.products.length} purchases`}
|
||||
sub={`Across ${stats.purchaseCount} purchases`}
|
||||
/>
|
||||
<Stat
|
||||
label="30-day spend"
|
||||
@@ -134,7 +142,7 @@ export function Dashboard({
|
||||
<Stat
|
||||
label="Spent all-time"
|
||||
value={fmt.money(stats.totalSpend)}
|
||||
sub={`${data.products.length} purchase${data.products.length === 1 ? "" : "s"}${stats.goneSpend > 0 ? ` · ${fmt.money(stats.goneSpend)} lost` : ""}`}
|
||||
sub={`${stats.purchaseCount} purchase${stats.purchaseCount === 1 ? "" : "s"}${stats.goneSpend > 0 ? ` · ${fmt.money(stats.goneSpend)} lost` : ""}`}
|
||||
/>
|
||||
<Stat
|
||||
label="Purchased all-time"
|
||||
@@ -163,7 +171,7 @@ export function Dashboard({
|
||||
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
|
||||
</div>
|
||||
</div>
|
||||
<Btn variant="secondary" icon="check" onClick={() => onAuditProduct(overdue[0]!)}>
|
||||
<Btn variant="secondary" icon="check" onClick={() => onAuditItem(overdue[0]!)}>
|
||||
Run audit
|
||||
</Btn>
|
||||
</div>
|
||||
@@ -282,7 +290,7 @@ export function Dashboard({
|
||||
{stats.favShop[0]}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 10 }}>
|
||||
{stats.favShop[1]} of {data.products.length} purchases
|
||||
{stats.favShop[1]} of {stats.purchaseCount} purchases
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -315,12 +323,12 @@ export function Dashboard({
|
||||
Nothing running low.
|
||||
</div>
|
||||
)}
|
||||
{lowBulk.slice(0, 3).map((p) => {
|
||||
const pct = helpers.pctRemaining(p, TODAY_STR);
|
||||
{lowBulk.slice(0, 3).map((i) => {
|
||||
const pct = helpers.pctRemaining(i, TODAY_STR);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => onSelectProduct(p)}
|
||||
key={i.id}
|
||||
onClick={() => onSelectItem(i)}
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
borderTop: "1px solid var(--line)",
|
||||
@@ -340,10 +348,10 @@ export function Dashboard({
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
{i.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||
{helpers.brandName(data, p.brandId)} · {p.type}
|
||||
{helpers.brandName(data, i.brandId)} · {i.type}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -367,7 +375,7 @@ export function Dashboard({
|
||||
className="mono"
|
||||
style={{ fontSize: 11, color: "var(--ink-2)", width: 60, textAlign: "right" }}
|
||||
>
|
||||
{remainingShort(p)}
|
||||
{remainingShort(i)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -375,7 +383,7 @@ export function Dashboard({
|
||||
{lowDiscrete.slice(0, 2).map((g) => (
|
||||
<div
|
||||
key={g.key}
|
||||
onClick={() => onSelectProduct(g.items[0]!)}
|
||||
onClick={() => onSelectItem(g.items[0]!)}
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
borderTop: "1px solid var(--line)",
|
||||
|
||||
+98
-105
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Bootstrap, Product } from "../types.js";
|
||||
import { TYPES, helpers, TODAY_STR } from "../types.js";
|
||||
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";
|
||||
@@ -13,15 +13,17 @@ const GRID_COLS = "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr";
|
||||
|
||||
export function Inventory({
|
||||
data,
|
||||
onSelectProduct,
|
||||
onAddProduct,
|
||||
onSelectItem,
|
||||
onAddInventory,
|
||||
onAuditNew,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onSelectProduct: (p: Product) => void;
|
||||
onAddProduct: () => void;
|
||||
onSelectItem: (i: Item) => void;
|
||||
onAddInventory: () => void;
|
||||
onAuditNew: () => void;
|
||||
}) {
|
||||
const items = useMemo(() => enrichItems(data), [data]);
|
||||
|
||||
const [filter, setFilter] = useState<FilterKey>("active");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<SortKey>("recent");
|
||||
@@ -33,7 +35,7 @@ export function Inventory({
|
||||
localStorage.setItem("apothecary.inventoryView", view);
|
||||
}, [view]);
|
||||
|
||||
const sortFn = (a: Product, b: Product) => {
|
||||
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;
|
||||
@@ -45,73 +47,66 @@ export function Inventory({
|
||||
return 0;
|
||||
};
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
let products = data.products;
|
||||
if (filter === "active") products = products.filter((p) => p.status === "active");
|
||||
else if (filter === "consumed") products = products.filter((p) => p.status === "consumed");
|
||||
else if (filter === "gone") products = products.filter((p) => p.status === "gone");
|
||||
if (typeFilter !== "all") products = products.filter((p) => p.type === typeFilter);
|
||||
const filtered = useMemo(() => {
|
||||
let out = items;
|
||||
if (filter === "active") out = out.filter((i) => i.status === "active");
|
||||
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();
|
||||
products = products.filter((p) => {
|
||||
const brand = helpers.brandName(data, p.brandId).toLowerCase();
|
||||
const shop = helpers.shopName(data, p.shopId).toLowerCase();
|
||||
out = out.filter((i) => {
|
||||
const brand = helpers.brandName(data, i.brandId).toLowerCase();
|
||||
const shop = helpers.shopName(data, i.shopId).toLowerCase();
|
||||
return (
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
i.name.toLowerCase().includes(q) ||
|
||||
brand.includes(q) ||
|
||||
shop.includes(q) ||
|
||||
p.sku.toLowerCase().includes(q)
|
||||
i.sku.toLowerCase().includes(q) ||
|
||||
i.assetId.toLowerCase().includes(q) ||
|
||||
(i.strainName?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
});
|
||||
}
|
||||
return products;
|
||||
}, [data, filter, typeFilter, search]);
|
||||
return out;
|
||||
}, [items, data, filter, typeFilter, search]);
|
||||
|
||||
const sortedProducts = useMemo(
|
||||
() => [...filteredProducts].sort(sortFn),
|
||||
[filteredProducts, sortBy],
|
||||
);
|
||||
const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy]);
|
||||
|
||||
// For grouped mode: bucket by strainId. Products without a strainId fall
|
||||
// into an "Unlinked" bucket at the end. Within each group, sort by sortFn.
|
||||
// Grouped mode: bucket by productId. Same-product instances collapse under
|
||||
// a header that shows total count + total remaining + last purchase.
|
||||
type Group = {
|
||||
strainId: string | null;
|
||||
productId: string;
|
||||
label: string;
|
||||
brand: string;
|
||||
sku: string;
|
||||
type: string;
|
||||
products: Product[];
|
||||
items: Item[];
|
||||
};
|
||||
const groups: Group[] = useMemo(() => {
|
||||
const byStrain = new Map<string | null, Product[]>();
|
||||
for (const p of filteredProducts) {
|
||||
const arr = byStrain.get(p.strainId) ?? [];
|
||||
arr.push(p);
|
||||
byStrain.set(p.strainId, arr);
|
||||
const byProduct = new Map<string, Item[]>();
|
||||
for (const i of filtered) {
|
||||
const arr = byProduct.get(i.productId) ?? [];
|
||||
arr.push(i);
|
||||
byProduct.set(i.productId, arr);
|
||||
}
|
||||
const out: Group[] = [];
|
||||
for (const [strainId, products] of byStrain.entries()) {
|
||||
const first = products[0]!;
|
||||
// Prefer the strain's canonical name when available so casing is
|
||||
// consistent regardless of which product was added first.
|
||||
const strain = strainId ? data.strains.find((s) => s.id === strainId) : null;
|
||||
for (const [productId, list] of byProduct.entries()) {
|
||||
const first = list[0]!;
|
||||
out.push({
|
||||
strainId,
|
||||
label: strain?.name ?? first.name,
|
||||
brand: helpers.brandName(data, first.brandId),
|
||||
productId,
|
||||
label: first.name,
|
||||
sku: first.sku,
|
||||
type: first.type,
|
||||
products: [...products].sort(sortFn),
|
||||
items: [...list].sort(sortFn),
|
||||
});
|
||||
}
|
||||
// Order groups by their most-recent purchase date desc so newest strains float up.
|
||||
out.sort((a, b) => {
|
||||
if (a.strainId === null) return 1;
|
||||
if (b.strainId === null) return -1;
|
||||
const aMax = Math.max(...a.products.map((p) => +new Date(p.purchaseDate)));
|
||||
const bMax = Math.max(...b.products.map((p) => +new Date(p.purchaseDate)));
|
||||
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;
|
||||
}, [filteredProducts, data, sortBy]);
|
||||
}, [filtered, sortBy]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -124,7 +119,7 @@ export function Inventory({
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||
{sortedProducts.length} item{sortedProducts.length === 1 ? "" : "s"}
|
||||
{sorted.length} item{sorted.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<h1
|
||||
className="serif"
|
||||
@@ -135,7 +130,7 @@ export function Inventory({
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="secondary" icon="check" onClick={onAuditNew}>Audit</Btn>
|
||||
<Btn variant="primary" icon="plus" onClick={onAddProduct}>New product</Btn>
|
||||
<Btn variant="primary" icon="plus" onClick={onAddInventory}>Add inventory</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,7 +151,7 @@ export function Inventory({
|
||||
value={view}
|
||||
options={[
|
||||
["flat", "Flat"],
|
||||
["grouped", "Grouped"],
|
||||
["grouped", "By product"],
|
||||
]}
|
||||
onChange={setView}
|
||||
/>
|
||||
@@ -176,7 +171,7 @@ export function Inventory({
|
||||
>
|
||||
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||
<input
|
||||
placeholder="Search by name, brand, shop, SKU…"
|
||||
placeholder="Search by name, brand, shop, SKU, asset id…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{
|
||||
@@ -219,21 +214,21 @@ export function Inventory({
|
||||
|
||||
<Card padded={false}>
|
||||
<HeaderRow />
|
||||
{sortedProducts.length === 0 && (
|
||||
{sorted.length === 0 && (
|
||||
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
||||
No items match these filters.
|
||||
</div>
|
||||
)}
|
||||
{view === "flat" &&
|
||||
sortedProducts.map((p) => (
|
||||
<ProductRow key={p.id} p={p} data={data} onSelect={onSelectProduct} />
|
||||
sorted.map((i) => (
|
||||
<ItemRow key={i.id} i={i} data={data} onSelect={onSelectItem} />
|
||||
))}
|
||||
{view === "grouped" &&
|
||||
groups.map((g) => (
|
||||
<div key={g.strainId ?? "unlinked"}>
|
||||
<div key={g.productId}>
|
||||
<GroupHeader group={g} />
|
||||
{g.products.map((p) => (
|
||||
<ProductRow key={p.id} p={p} data={data} onSelect={onSelectProduct} indented />
|
||||
{g.items.map((i) => (
|
||||
<ItemRow key={i.id} i={i} data={data} onSelect={onSelectItem} indented />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -301,7 +296,7 @@ function HeaderRow() {
|
||||
}}
|
||||
>
|
||||
<div></div>
|
||||
<div>Product</div>
|
||||
<div>Item</div>
|
||||
<div>Brand</div>
|
||||
<div>Shop</div>
|
||||
<div>THC %</div>
|
||||
@@ -317,24 +312,23 @@ function GroupHeader({
|
||||
group,
|
||||
}: {
|
||||
group: {
|
||||
strainId: string | null;
|
||||
productId: string;
|
||||
label: string;
|
||||
brand: string;
|
||||
sku: string;
|
||||
type: string;
|
||||
products: Product[];
|
||||
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.products.filter((p) => p.status === "active");
|
||||
const totalRemaining = active.reduce((s, p) => {
|
||||
if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, TODAY_STR);
|
||||
const cur = p.countLastAudit ?? p.countOriginal;
|
||||
return s + cur * (p.unitWeight || 0);
|
||||
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 totalCount = active.length;
|
||||
const lastBuy = group.products.reduce((max, p) => {
|
||||
const t = +new Date(p.purchaseDate);
|
||||
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);
|
||||
@@ -356,20 +350,17 @@ function GroupHeader({
|
||||
{TYPE_GLYPHS[group.type]}
|
||||
</div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1.1 }}>
|
||||
{group.strainId === null ? "Unlinked" : group.label}
|
||||
{group.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{group.brand} · {group.type}
|
||||
<span className="mono">{group.sku}</span> · {group.type}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 18, fontSize: 12, color: "var(--ink-3)" }}>
|
||||
<div>
|
||||
<span className="mono" style={{ color: "var(--ink-2)" }}>
|
||||
{totalCount}
|
||||
</span>{" "}
|
||||
{totalCount === 1 ? "active" : "active"}
|
||||
{group.products.length !== totalCount && (
|
||||
<span style={{ color: "var(--ink-4)" }}> / {group.products.length} total</span>
|
||||
<span className="mono" style={{ color: "var(--ink-2)" }}>{active.length}</span> active
|
||||
{group.items.length !== active.length && (
|
||||
<span style={{ color: "var(--ink-4)" }}> / {group.items.length} total</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -380,7 +371,10 @@ function GroupHeader({
|
||||
</div>
|
||||
{lastBuy > 0 && (
|
||||
<div>
|
||||
last buy <span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.dateShort(new Date(lastBuy).toISOString())}</span>
|
||||
last buy{" "}
|
||||
<span className="mono" style={{ color: "var(--ink-2)" }}>
|
||||
{fmt.dateShort(new Date(lastBuy).toISOString())}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -388,26 +382,26 @@ function GroupHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function ProductRow({
|
||||
p,
|
||||
function ItemRow({
|
||||
i,
|
||||
data,
|
||||
onSelect,
|
||||
indented = false,
|
||||
}: {
|
||||
p: Product;
|
||||
i: Item;
|
||||
data: Bootstrap;
|
||||
onSelect: (p: Product) => void;
|
||||
onSelect: (i: Item) => void;
|
||||
indented?: boolean;
|
||||
}) {
|
||||
const bin = data.bins.find((b) => b.id === p.binId);
|
||||
const pctRemaining = helpers.pctRemaining(p, TODAY_STR);
|
||||
const overdue = helpers.auditOverdue(p, TODAY_STR);
|
||||
const sinceCheck = helpers.daysSinceCheck(p, TODAY_STR);
|
||||
const last = helpers.lastAudit(p);
|
||||
const isInactive = p.status !== "active";
|
||||
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";
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(p)}
|
||||
onClick={() => onSelect(i)}
|
||||
className="inv-row"
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -430,7 +424,7 @@ function ProductRow({
|
||||
opacity: indented ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{TYPE_GLYPHS[p.type]}
|
||||
{TYPE_GLYPHS[i.type]}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
@@ -442,26 +436,25 @@ function ProductRow({
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
{p.status === "consumed" && (
|
||||
{i.name}
|
||||
{i.status === "consumed" && (
|
||||
<Pill tone="terra" style={{ marginLeft: 6, fontSize: 10 }}>Consumed</Pill>
|
||||
)}
|
||||
{p.status === "gone" && (
|
||||
{i.status === "gone" && (
|
||||
<Pill tone="amber" style={{ marginLeft: 6, fontSize: 10 }}>Gone</Pill>
|
||||
)}
|
||||
{p.status === "active" && overdue && (
|
||||
{i.status === "active" && overdue && (
|
||||
<Pill tone="amber" style={{ marginLeft: 6, fontSize: 10 }}>Audit due</Pill>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--ink-3)", fontFamily: "var(--mono)" }}>
|
||||
{p.sku}
|
||||
{p.assetTag ? ` · ${p.assetTag}` : ""}
|
||||
{i.assetId} · {i.sku}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "var(--ink-2)" }}>{helpers.brandName(data, p.brandId)}</div>
|
||||
<div style={{ color: "var(--ink-3)", fontSize: 12 }}>{helpers.shopName(data, p.shopId)}</div>
|
||||
<div style={{ fontFamily: "var(--mono)", color: "var(--ink-2)" }}>{p.thc.toFixed(1)}</div>
|
||||
<div style={{ fontFamily: "var(--mono)" }}>{fmt.money(p.price)}</div>
|
||||
<div style={{ color: "var(--ink-2)" }}>{helpers.brandName(data, i.brandId)}</div>
|
||||
<div style={{ color: "var(--ink-3)", fontSize: 12 }}>{helpers.shopName(data, i.shopId)}</div>
|
||||
<div style={{ fontFamily: "var(--mono)", color: "var(--ink-2)" }}>{i.thc.toFixed(1)}</div>
|
||||
<div style={{ fontFamily: "var(--mono)" }}>{fmt.money(i.price)}</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -470,8 +463,8 @@ function ProductRow({
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{remainingShort(p)}</div>
|
||||
{p.status === "active" && p.kind === "bulk" && (
|
||||
<div style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{remainingShort(i)}</div>
|
||||
{i.status === "active" && i.kind === "bulk" && (
|
||||
<div style={{ width: 80, height: 5, background: "var(--bg-3)", borderRadius: 2 }}>
|
||||
<div
|
||||
style={{
|
||||
@@ -490,7 +483,7 @@ function ProductRow({
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: overdue ? "var(--terracotta)" : "var(--ink-3)" }}>
|
||||
{p.status !== "active" ? (
|
||||
{i.status !== "active" ? (
|
||||
<span style={{ fontStyle: "italic" }}>archived</span>
|
||||
) : last ? (
|
||||
<span>
|
||||
|
||||
@@ -70,9 +70,9 @@ export function SettingsView({
|
||||
<Card style={{ marginBottom: 14 }}>
|
||||
<div className="serif" style={{ fontSize: 22, marginBottom: 16 }}>Library</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 16 }}>
|
||||
<Stat label="Active" value={data.products.filter((p) => p.status === "active").length} />
|
||||
<Stat label="Consumed" value={data.products.filter((p) => p.status === "consumed").length} />
|
||||
<Stat label="Gone" value={data.products.filter((p) => p.status === "gone").length} />
|
||||
<Stat label="Active" value={data.inventoryItems.filter((i) => i.status === "active").length} />
|
||||
<Stat label="Consumed" value={data.inventoryItems.filter((i) => i.status === "consumed").length} />
|
||||
<Stat label="Gone" value={data.inventoryItems.filter((i) => i.status === "gone").length} />
|
||||
<Stat label="Bins" value={data.bins.length} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ShopsView({
|
||||
}}
|
||||
>
|
||||
{data.shops.map((s) => {
|
||||
const count = data.products.filter((p) => p.shopId === s.id).length;
|
||||
const count = data.inventoryItems.filter((i) => i.shopId === s.id).length;
|
||||
return (
|
||||
<Card key={s.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
Reference in New Issue
Block a user