e7fd9af62c
Build and push image / build (push) Successful in 1m8s
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>
547 lines
18 KiB
TypeScript
547 lines
18 KiB
TypeScript
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 (A–Z)</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>
|
||
);
|
||
}
|