Files
Apothecary/web/src/components/ProductDetail.tsx
T
josh 564cae253a
Build and push image / build (push) Successful in 53s
Only estimate remaining after first audit, not from purchase date
Before any audit, bulk items show their full original weight with no
decay applied. The linear decay model only kicks in after the first
audit provides an actual data point. This prevents freshly added,
unopened items from showing misleading consumption estimates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 09:36:29 -04:00

469 lines
18 KiB
TypeScript

import { useEffect } from "react";
import type { Bootstrap, Item, Product } from "../types.js";
import { TYPES, helpers } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js";
// Right-side drawer for an inventory instance. Shows the asset id and
// product context up top, then per-batch fields (price, THC, weight),
// audit history, and full detail rows.
export function ProductDetail({
item,
data,
onClose,
onConsume,
onMarkGone,
onAudit,
onEdit,
onCheckout,
onCheckin,
}: {
item: Item;
data: Bootstrap;
onClose: () => void;
onConsume: (i: Item) => void;
onMarkGone: (i: Item) => void;
onAudit: (i: Item) => void;
onEdit: (i: Item) => void;
onCheckout: (i: Item) => void;
onCheckin: (i: Item) => void;
}) {
const bin = data.bins.find((b) => b.id === item.binId);
const cfg = TYPES.find((t) => t.id === item.type);
const product = data.products.find((p) => p.id === item.productId);
const pctRemaining = helpers.pctRemaining(item, getToday(getStoredTimezone()));
const est = helpers.estimatedRemaining(item, getToday(getStoredTimezone()));
const last = helpers.lastAudit(item);
const overdue = helpers.auditOverdue(item, getToday(getStoredTimezone()));
const sinceCheck = helpers.daysSinceCheck(item, getToday(getStoredTimezone()));
const isActive = item.status === "active";
const isCheckedOut = item.status === "checked-out";
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
// Sibling instances of the same product (excluding this one) — useful for
// seeing previous purchases of the same SKU.
const siblings = data.inventoryItems.filter(
(i) => i.productId === item.productId && i.id !== item.id,
);
const detailRows: [string, React.ReactNode][] = [
["Asset id", <span className="mono">{item.assetId}</span>],
["SKU", <span className="mono">{item.sku}</span>],
["Type", `${item.type} · ${item.kind}`],
["Strain", item.name],
["Brand", helpers.brandName(data, item.brandId)],
["Shop", helpers.shopName(data, item.shopId)],
...(cfg?.showCannabinoidPct !== false
? [["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`] as [string, React.ReactNode]]
: []),
["Purchase date", fmt.date(item.purchaseDate, getStoredTimezone())],
["Bin", isCheckedOut ? "In your custody" : bin ? bin.name : <span style={{ color: "var(--ink-3)" }}></span>],
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
[
"Cost per gram",
item.kind === "bulk" && item.weight > 0
? fmt.money(item.price / item.weight)
: item.kind === "discrete" && item.unitWeight > 0
? `${fmt.money(item.price / (item.countOriginal * item.unitWeight))} (effective)`
: "—",
],
];
if (item.status === "checked-out") {
detailRows.push(["Checked out", fmt.date(item.checkoutDate, getStoredTimezone())]);
}
if (item.status === "consumed") {
detailRows.push(
["Date finished", fmt.date(item.consumedDate, getStoredTimezone())],
[
"Lasted",
`${Math.round((+new Date(item.consumedDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
],
);
}
if (item.status === "gone") {
detailRows.push(
["Date gone", fmt.date(item.goneDate, getStoredTimezone())],
[
"After",
`${Math.round((+new Date(item.goneDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
],
);
}
return (
<div
style={{
position: "fixed",
inset: 0,
background: "oklch(20% 0.02 60 / 0.4)",
zIndex: 50,
display: "flex",
justifyContent: "flex-end",
animation: "backdrop-in 200ms ease-out",
}}
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(720px, 100vw)",
height: "100%",
animation: "drawer-in 250ms ease-out",
background: "var(--bg)",
borderLeft: "1px solid var(--line)",
overflow: "auto",
boxShadow: "var(--shadow-lg)",
}}
>
<div
style={{
padding: "20px 32px",
borderBottom: "1px solid var(--line)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
position: "sticky",
top: 0,
background: "var(--bg)",
zIndex: 1,
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Inventory · <span className="mono">{item.assetId}</span>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}>
{isActive && (
<Btn variant="ghost" icon="pocket" onClick={() => onCheckout(item)}>
Check out
</Btn>
)}
{isActive && (
<Btn variant="ghost" icon="check" onClick={() => onAudit(item)}>
Audit
</Btn>
)}
{isCheckedOut && (
<Btn variant="sage" icon="check" onClick={() => onCheckin(item)}>
Check in
</Btn>
)}
{(isActive || isCheckedOut) && (
<Btn variant="secondary" icon="check" onClick={() => onConsume(item)}>
Mark consumed
</Btn>
)}
{(isActive || isCheckedOut) && (
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)} />
)}
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)} />
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
</div>
<div style={{ padding: "32px 32px 60px" }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8 }}>
<div className="serif" style={{ fontSize: 18, color: "var(--ink-3)" }}>
{TYPE_GLYPHS[item.type]} {item.type}
</div>
{item.status === "consumed" && (
<Pill tone="terra">Consumed · {fmt.daysAgo(item.consumedDate, getStoredTimezone())}</Pill>
)}
{item.status === "gone" && (
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate, getStoredTimezone())}</Pill>
)}
{isCheckedOut && (
<Pill tone="outline">Checked out · {fmt.daysAgo(item.checkoutDate, getStoredTimezone())}</Pill>
)}
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
</div>
<h1
className="serif"
style={{
fontSize: 48,
margin: "0 0 4px",
fontWeight: 500,
letterSpacing: "-0.02em",
lineHeight: 1.1,
}}
>
{item.name}
</h1>
<div style={{ fontSize: 16, color: "var(--ink-2)" }}>
{helpers.brandName(data, item.brandId)} · from {helpers.shopName(data, item.shopId)}
</div>
{siblings.length > 0 && (
<div style={{ marginTop: 8, fontSize: 12, color: "var(--ink-3)" }}>
{siblings.length} other instance{siblings.length === 1 ? "" : "s"} on file
</div>
)}
{(() => {
const statCards: [string, React.ReactNode][] = [
["Price", fmt.money(item.price)],
[
item.kind === "discrete" ? "Unit weight" : "Size",
item.kind === "discrete"
? `${item.unitWeight} ${cfg?.weightUnit ?? "g"}`
: `${item.weight} ${cfg?.unit ?? "g"}`,
],
...(cfg?.showCannabinoidPct !== false
? [
["THC", `${item.thc.toFixed(1)}%`] as [string, React.ReactNode],
["CBD", `${item.cbd.toFixed(1)}%`] as [string, React.ReactNode],
]
: []),
];
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${statCards.length}, 1fr)`,
gap: 1,
marginTop: 32,
background: "var(--line)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
overflow: "hidden",
}}
>
{statCards.map(([l, v], i) => (
<div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{l}</div>
<div className="serif" style={{ fontSize: 26, marginTop: 4, fontWeight: 500 }}>{v}</div>
</div>
))}
</div>
);
})()}
{(isActive || isCheckedOut) && (
<div style={{ marginTop: 20 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: 8,
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{item.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
</div>
<div style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
{item.kind === "discrete"
? `${item.countLastAudit ?? item.countOriginal} of ${item.countOriginal}`
: `${est.toFixed(2)} of ${item.weight} ${cfg?.unit ?? "g"}`}
<span style={{ color: "var(--ink-3)", marginLeft: 8 }}>
{Math.round(pctRemaining * 100)}%
</span>
</div>
</div>
<div style={{ height: 8, background: "var(--bg-3)", borderRadius: 4, overflow: "hidden" }}>
<div
style={{
width: `${pctRemaining * 100}%`,
height: "100%",
background:
pctRemaining < 0.25
? "var(--terracotta)"
: pctRemaining < 0.5
? "var(--amber)"
: "var(--sage)",
}}
/>
</div>
{item.kind === "bulk" && last && (
<div
style={{
fontSize: 11,
color: "var(--ink-3)",
marginTop: 6,
fontStyle: "italic",
}}
>
Estimated by linear decay since last {last.mode} on {fmt.dateShort(last.date, getStoredTimezone())} ({last.value.toFixed(2)}
{cfg?.unit}). Re-audit to update.
</div>
)}
{item.containerWeight != null && last && (
<div
style={{
fontSize: 11,
color: "var(--ink-3)",
marginTop: 6,
fontStyle: "italic",
}}
>
Expected container total: {((item.containerWeight - item.weight) + est).toFixed(2)}g
</div>
)}
</div>
)}
<div style={{ marginTop: 36 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: 12,
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Audit history</div>
{isActive && (
<button
onClick={() => onAudit(item)}
style={{
background: "none",
border: "none",
fontSize: 12,
color: "var(--ink-2)",
cursor: "pointer",
textDecoration: "underline",
}}
>
+ New audit
</button>
)}
</div>
{item.audits.length === 0 ? (
<div style={{ fontSize: 13, color: "var(--ink-3)", fontStyle: "italic", padding: "12px 0" }}>
No audits recorded. Cadence for {item.type}: every {cfg?.cadenceDays ?? "—"} days.
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 0,
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
overflow: "hidden",
}}
>
{[...item.audits].reverse().map((a, idx, arr) => (
<div
key={idx}
style={{
padding: "12px 16px",
borderBottom: idx < arr.length - 1 ? "1px solid var(--line)" : "none",
display: "flex",
alignItems: "center",
gap: 12,
background: "var(--surface)",
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
background:
a.mode === "weigh"
? "var(--sage)"
: a.mode === "estimate"
? "var(--amber)"
: "var(--plum)",
}}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
{a.mode === "weigh" && "Weighed"}
{a.mode === "estimate" && "Estimated"}
{a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")}
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
{fmt.date(a.date, getStoredTimezone())} · {fmt.daysAgo(a.date, getStoredTimezone())}
</div>
</div>
<div className="mono" style={{ fontSize: 13, textAlign: "right" }}>
<div>
{item.kind === "discrete" ? a.value : a.value.toFixed(2)} {cfg?.unit}
</div>
<div style={{ fontSize: 10, color: "var(--ink-3)" }}>
was {a.prev != null ? (item.kind === "discrete" ? a.prev : a.prev.toFixed(2)) : "—"} {cfg?.unit}
</div>
</div>
</div>
))}
</div>
)}
</div>
<div style={{ marginTop: 36 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>Details</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "14px 32px" }}>
{detailRows.map(([l, v], i) => (
<div
key={i}
style={{
display: "flex",
justifyContent: "space-between",
paddingBottom: 12,
borderBottom: "1px solid var(--line)",
}}
>
<span style={{ color: "var(--ink-3)", fontSize: 12 }}>{l}</span>
<span style={{ fontSize: 13, fontWeight: 500, textAlign: "right" }}>{v}</span>
</div>
))}
</div>
</div>
{(item.status === "consumed" || item.status === "gone") && (
<div
style={{
marginTop: 36,
padding: 24,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 12,
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{item.status === "gone" ? "Why it's gone" : "Final notes"}
</div>
{item.status === "consumed" && (
<div style={{ display: "flex", gap: 2 }}>
{[1, 2, 3, 4, 5].map((n) => (
<Icon
key={n}
name="star"
size={14}
color={n <= (item.rating ?? 0) ? "var(--amber)" : "var(--ink-4)"}
/>
))}
</div>
)}
</div>
<div
className="serif"
style={{ fontSize: 18, lineHeight: 1.5, color: "var(--ink-2)", fontStyle: "italic" }}
>
"{item.notes ?? "No notes recorded."}"
</div>
</div>
)}
</div>
</div>
</div>
);
}