Files
Apothecary/web/src/views/Inventory.tsx
T
josh e7fd9af62c
Build and push image / build (push) Successful in 1m8s
Add checkout/custody feature for tracking items in personal possession
Items can now be checked out of their bin into "my custody" and later
checked back in or marked consumed. Adds checkout/checkin API endpoints,
a My Custody sidebar page, CheckoutFlow and CheckinFlow modals, and
updates ProductDetail, Inventory, ConsumeFlow, and MarkGoneFlow to
handle the new checked-out status. Bulk items prompt for remaining
weight on check-in.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 20:49:58 -04:00

547 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<FilterKey>("active");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<SortKey>("recent");
const [search, setSearch] = useState("");
const [view, setView] = useState<ViewKey>(
() => (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<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 [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 (
<div
style={{
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
maxWidth: 2400,
margin: "0 auto",
}}
>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{sorted.length} item{sorted.length === 1 ? "" : "s"}
</div>
<h1
className="serif"
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
>
Inventory
</h1>
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="secondary" icon="check" onClick={onAuditNew}>Audit</Btn>
<Btn variant="primary" icon="plus" onClick={onAddInventory}>Add inventory</Btn>
</div>
</div>
<Card style={{ marginBottom: 14, padding: 14 }}>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<Segmented<FilterKey>
value={filter}
options={[
["active", "Active"],
["checked-out", "Checked out"],
["consumed", "Consumed"],
["gone", "Gone"],
["all", "All"],
]}
onChange={setFilter}
/>
<Segmented<ViewKey>
value={view}
options={[
["flat", "Flat"],
["grouped", "By product"],
]}
onChange={setView}
/>
<div
style={{
flex: 1,
minWidth: 220,
display: "flex",
alignItems: "center",
gap: 8,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: "0 10px",
}}
>
<Icon name="search" size={14} color="var(--ink-3)" />
<input
placeholder="Search by name, brand, shop, SKU, asset id…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
border: "none",
outline: "none",
background: "transparent",
padding: "8px 0",
fontSize: 13,
flex: 1,
color: "var(--ink)",
}}
/>
{search && (
<button
onClick={() => setSearch("")}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 2,
display: "inline-flex",
color: "var(--ink-3)",
}}
>
<Icon name="close" size={12} />
</button>
)}
</div>
<Select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
>
<option value="all">All types</option>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>{t.id}</option>
))}
</Select>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
>
<option value="recent">Recent first</option>
<option value="name">Name (AZ)</option>
<option value="thc">THC % (high)</option>
<option value="remaining">Remaining (high)</option>
<option value="price">Price (high)</option>
<option value="audit">Audit overdue first</option>
</Select>
</div>
</Card>
<Card padded={false}>
<HeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && (
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
No items match these filters.
</div>
)}
{view === "flat" &&
sorted.map((i) => (
<ItemRow key={i.id} i={i} data={data} onSelect={onSelectItem} />
))}
{view === "grouped" &&
groups.map((g) => (
<div key={g.productId}>
<GroupHeader group={g} />
{g.items.map((i) => (
<ItemRow key={i.id} i={i} data={data} onSelect={onSelectItem} indented />
))}
</div>
))}
</Card>
</div>
);
}
function Segmented<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: [T, string][];
onChange: (v: T) => void;
}) {
return (
<div
style={{
display: "inline-flex",
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: 3,
}}
>
{options.map(([k, l]) => (
<button
key={k}
onClick={() => onChange(k)}
style={{
padding: "6px 14px",
fontSize: 12,
fontWeight: 500,
borderRadius: 6,
border: "none",
background: value === k ? "var(--surface)" : "transparent",
color: value === k ? "var(--ink)" : "var(--ink-3)",
boxShadow: value === k ? "var(--shadow-sm)" : "none",
cursor: "pointer",
}}
>
{l}
</button>
))}
</div>
);
}
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 (
<div
className="inv-header"
style={{
display: "grid",
gridTemplateColumns: GRID_COLS,
columnGap: 16,
padding: "12px 20px",
borderBottom: "1px solid var(--line)",
background: "var(--bg-2)",
fontSize: 11,
color: "var(--ink-3)",
textTransform: "uppercase",
letterSpacing: "0.08em",
}}
>
{COL_LABELS.map((label, i) => {
const sk = COL_SORT[i];
if (!sk) return <div key={i}>{label}</div>;
const active = sortBy === sk;
return (
<button
key={i}
onClick={() => onSort(sk)}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
fontSize: "inherit",
textTransform: "inherit",
letterSpacing: "inherit",
fontWeight: active ? 600 : "inherit",
color: active ? "var(--ink)" : "var(--ink-3)",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
{label}
{active && <span style={{ fontSize: 9 }}></span>}
</button>
);
})}
</div>
);
}
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 (
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 16,
padding: "16px 20px 10px",
borderBottom: "1px solid var(--line)",
background: "var(--bg-2)",
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 12, minWidth: 0 }}>
<div style={{ fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)", width: 18 }}>
{TYPE_GLYPHS[group.type]}
</div>
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1.1 }}>
{group.label}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
<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)" }}>{active.length}</span> active
{group.items.length !== active.length && (
<span style={{ color: "var(--ink-4)" }}> / {group.items.length} total</span>
)}
</div>
<div>
<span className="mono" style={{ color: "var(--ink-2)" }}>
{totalRemaining.toFixed(2).replace(/\.?0+$/, "") || "0"}
</span>{" "}
{cfg?.unit ?? "g"} on hand
</div>
{lastBuy > 0 && (
<div>
last buy{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.dateShort(new Date(lastBuy).toISOString())}
</span>
</div>
)}
</div>
</div>
);
}
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 (
<div
onClick={() => 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",
}}
>
<div
style={{
fontFamily: "var(--serif)",
fontSize: 18,
color: "var(--ink-3)",
opacity: indented ? 0.5 : 1,
}}
>
{TYPE_GLYPHS[i.type]}
</div>
<div style={{ minWidth: 0 }}>
<div
style={{
fontWeight: 500,
color: "var(--ink)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{i.name}
{i.status === "consumed" && (
<Pill tone="terra" style={{ marginLeft: 6, fontSize: 10 }}>Consumed</Pill>
)}
{i.status === "gone" && (
<Pill tone="amber" style={{ marginLeft: 6, fontSize: 10 }}>Gone</Pill>
)}
{i.status === "checked-out" && (
<Pill tone="outline" style={{ marginLeft: 6, fontSize: 10 }}>Checked out</Pill>
)}
{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)" }}>
{i.assetId} · {i.sku}
</div>
</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",
flexDirection: "column",
alignItems: "flex-start",
gap: 4,
}}
>
<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={{
width: `${pctRemaining * 100}%`,
height: "100%",
background:
pctRemaining < 0.25
? "var(--terracotta)"
: pctRemaining < 0.5
? "var(--amber)"
: "var(--sage)",
borderRadius: 2,
}}
/>
</div>
)}
</div>
<div style={{ fontSize: 11, color: overdue ? "var(--terracotta)" : "var(--ink-3)" }}>
{i.status !== "active" ? (
<span style={{ fontStyle: "italic" }}>archived</span>
) : last ? (
<span>
<span className="mono">{sinceCheck}d</span> ago · {last.mode}
</span>
) : (
<span style={{ fontStyle: "italic" }}>never</span>
)}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)", display: "flex", alignItems: "center", gap: 6 }}>
{i.status === "checked-out" ? (
<span style={{ fontStyle: "italic", color: "var(--sage)" }}>Custody</span>
) : bin ? bin.name : <span style={{ fontStyle: "italic" }}></span>}
<span className="inv-row-chevron" style={{ color: "var(--ink-3)", marginLeft: "auto", fontSize: 14 }}></span>
</div>
</div>
);
}