564cae253a
Build and push image / build (push) Successful in 53s
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>
469 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|