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(); 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", {item.assetId}], ["SKU", {item.sku}], ["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 : ], ["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 (
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)", }} >
{onBack && backLabel && ( )}
Inventory · {item.assetId}
{isActive && ( onAudit(item)}> Audit )} {isActive && ( onCheckout(item)}> Check out )} {isCheckedOut && ( onCheckin(item)}> Check in )}
{(isActive || isCheckedOut) && ( onConsume(item)}> Consume )} {(isActive || isCheckedOut) && ( onMarkGone(item)}> Gone )} onEdit(item)}> Edit
{TYPE_GLYPHS[item.type]} {item.type}
{item.status === "consumed" && ( Consumed · {fmt.daysAgo(item.consumedDate, getStoredTimezone())} )} {item.status === "gone" && ( Gone · {fmt.daysAgo(item.goneDate, getStoredTimezone())} )} {isCheckedOut && ( Checked out · {fmt.daysAgo(item.checkoutDate, getStoredTimezone())} )} {isActive && overdue && Audit overdue · {sinceCheck}d}

{item.name}

{helpers.brandName(data, item.brandId)} · from {helpers.shopName(data, item.shopId)}
{siblings.length > 0 && (
{siblings.length} other instance{siblings.length === 1 ? "" : "s"} on file
)} {(() => { 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 (
{statCards.map(([l, v], i) => (
{l}
{v}
))}
); })()} {(isActive || isCheckedOut) && (
{item.kind === "discrete" ? "Units remaining" : "Remaining"}
{item.kind === "discrete" ? `${item.countLastAudit ?? item.countOriginal} of ${item.countOriginal}` : `${rem.toFixed(2)} of ${item.weight} ${cfg?.unit ?? "g"}`} {Math.round(pctRemaining * 100)}%
{item.kind === "bulk" && last && (
Last {last.mode} on {fmt.dateShort(last.date, getStoredTimezone())}
)} {item.containerWeight != null && last && (
Expected container total: {((item.containerWeight - item.weight) + rem).toFixed(2)}g
)}
)}
Audit history
{isActive && ( )}
{item.audits.length === 0 ? (
No audits recorded. Cadence for {item.type}: every {cfg?.cadenceDays ?? "—"} days.
) : (
{[...item.audits].reverse().map((a, idx, arr) => (
{a.mode === "weigh" && "Weighed"} {a.mode === "estimate" && "Estimated"} {a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")}
{fmt.date(a.date, getStoredTimezone())} · {fmt.daysAgo(a.date, getStoredTimezone())}
{item.kind === "discrete" ? a.value : a.value.toFixed(2)} {cfg?.unit}
was {a.prev != null ? (item.kind === "discrete" ? a.prev : a.prev.toFixed(2)) : "—"} {cfg?.unit}
))}
)}
Details
{detailRows.map(([l, v], i) => (
{l} {v}
))}
{(item.status === "consumed" || item.status === "gone") && (
{item.status === "gone" ? "Why it's gone" : "Final notes"}
{item.status === "consumed" && (
{[1, 2, 3, 4, 5].map((n) => ( ))}
)}
"{item.notes ?? "No notes recorded."}"
)}
); }