52564d1e2f
Build and push image / build (push) Successful in 17m4s
For discrete items, show "Cost per ct" using price/count instead of dividing by countOriginal*unitWeight (which treated mg as grams for edibles, showing $0.02 instead of $3.50). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
505 lines
19 KiB
TypeScript
505 lines
19 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";
|
|
import { useExitAnimation } from "../hooks/useExitAnimation.js";
|
|
import { useFocusTrap } from "../hooks/useFocusTrap.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,
|
|
backLabel,
|
|
onBack,
|
|
}: {
|
|
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;
|
|
backLabel?: string;
|
|
onBack?: () => 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);
|
|
const rem = helpers.remaining(item);
|
|
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";
|
|
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
|
const trapRef = useFocusTrap<HTMLDivElement>();
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") triggerClose();
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [triggerClose]);
|
|
|
|
// 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 ?? "—"}`],
|
|
[
|
|
item.kind === "discrete" ? `Cost per ${cfg?.unit ?? "ct"}` : "Cost per gram",
|
|
item.kind === "bulk" && item.weight > 0
|
|
? fmt.money(item.price / item.weight)
|
|
: item.kind === "discrete" && item.countOriginal > 0
|
|
? fmt.money(item.price / item.countOriginal)
|
|
: "—",
|
|
],
|
|
];
|
|
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
|
|
ref={trapRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
background: "oklch(20% 0.02 60 / 0.4)",
|
|
zIndex: 50,
|
|
display: "flex",
|
|
justifyContent: "flex-end",
|
|
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
|
|
}}
|
|
onClick={triggerClose}
|
|
>
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
style={{
|
|
width: "min(720px, 100vw)",
|
|
height: "100%",
|
|
animation: closing ? "drawer-out 220ms ease-in forwards" : "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",
|
|
flexDirection: "column",
|
|
gap: onBack ? 8 : 0,
|
|
position: "sticky",
|
|
top: 0,
|
|
background: "var(--bg)",
|
|
zIndex: 1,
|
|
}}
|
|
>
|
|
{onBack && backLabel && (
|
|
<button
|
|
onClick={onBack}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
background: "none",
|
|
border: "none",
|
|
padding: 0,
|
|
fontSize: 12,
|
|
color: "var(--sage)",
|
|
cursor: "pointer",
|
|
alignSelf: "flex-start",
|
|
}}
|
|
>
|
|
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
|
|
</button>
|
|
)}
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
|
Inventory · <span className="mono">{item.assetId}</span>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
|
{isActive && (
|
|
<Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onAudit(item)}>
|
|
Audit
|
|
</Btn>
|
|
)}
|
|
{isActive && (
|
|
<Btn variant={overdue ? "ghost" : "secondary"} icon="pocket" onClick={() => onCheckout(item)}>
|
|
Check out
|
|
</Btn>
|
|
)}
|
|
{isCheckedOut && (
|
|
<Btn variant="sage" icon="pocket" onClick={() => onCheckin(item)}>
|
|
Check in
|
|
</Btn>
|
|
)}
|
|
<div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 2px" }} />
|
|
{(isActive || isCheckedOut) && (
|
|
<Btn variant="ghost" icon="leaf" onClick={() => onConsume(item)}>
|
|
Consume
|
|
</Btn>
|
|
)}
|
|
{(isActive || isCheckedOut) && (
|
|
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)}>
|
|
Gone
|
|
</Btn>
|
|
)}
|
|
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)}>
|
|
Edit
|
|
</Btn>
|
|
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
|
</div>
|
|
</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" : "Remaining"}
|
|
</div>
|
|
<div style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
|
|
{item.kind === "discrete"
|
|
? `${item.countLastAudit ?? item.countOriginal} of ${item.countOriginal}`
|
|
: `${rem.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",
|
|
}}
|
|
>
|
|
Last {last.mode} on {fmt.dateShort(last.date, getStoredTimezone())}
|
|
</div>
|
|
)}
|
|
{item.containerWeight != null && last && (
|
|
<div
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--ink-3)",
|
|
marginTop: 6,
|
|
fontStyle: "italic",
|
|
}}
|
|
>
|
|
Expected container total: {((item.containerWeight - item.weight) + rem).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>
|
|
);
|
|
}
|