Initial commit: Apothecary v0.4.0
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
import type { Bootstrap, Product } from "../types.js";
|
||||
import { TYPES, helpers, TODAY_STR } from "../types.js";
|
||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||
import { Btn, Pill, Icon } from "./primitives/index.js";
|
||||
|
||||
export function ProductDetail({
|
||||
product,
|
||||
data,
|
||||
onClose,
|
||||
onConsume,
|
||||
onMarkGone,
|
||||
onAudit,
|
||||
}: {
|
||||
product: Product;
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
onConsume: (p: Product) => void;
|
||||
onMarkGone: (p: Product) => void;
|
||||
onAudit: (p: Product) => void;
|
||||
}) {
|
||||
const bin = data.bins.find((b) => b.id === product.binId);
|
||||
const cfg = TYPES.find((t) => t.id === product.type);
|
||||
const pctRemaining = helpers.pctRemaining(product, TODAY_STR);
|
||||
const est = helpers.estimatedRemaining(product, TODAY_STR);
|
||||
const last = helpers.lastAudit(product);
|
||||
const overdue = helpers.auditOverdue(product, TODAY_STR);
|
||||
const sinceCheck = helpers.daysSinceCheck(product, TODAY_STR);
|
||||
|
||||
const isActive = product.status === "active";
|
||||
|
||||
const detailRows: [string, React.ReactNode][] = [
|
||||
["SKU", <span className="mono">{product.sku}</span>],
|
||||
[
|
||||
"Asset tag",
|
||||
product.assetTag ? (
|
||||
<span className="mono">{product.assetTag}</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--ink-3)" }}>None</span>
|
||||
),
|
||||
],
|
||||
["Type", `${product.type} · ${product.kind}`],
|
||||
["Brand", helpers.brandName(data, product.brandId)],
|
||||
["Shop", helpers.shopName(data, product.shopId)],
|
||||
["Total cannabinoids", `${product.totalCannabinoids.toFixed(1)}%`],
|
||||
["Purchase date", fmt.date(product.purchaseDate)],
|
||||
["Bin", bin ? `${bin.name} — ${bin.location}` : <span style={{ color: "var(--ink-3)" }}>—</span>],
|
||||
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
|
||||
[
|
||||
"Cost per gram",
|
||||
product.kind === "bulk" && product.weight > 0
|
||||
? fmt.money(product.price / product.weight)
|
||||
: product.kind === "discrete" && product.unitWeight > 0
|
||||
? `${fmt.money(product.price / (product.countOriginal * product.unitWeight))} (effective)`
|
||||
: "—",
|
||||
],
|
||||
];
|
||||
if (product.status === "consumed") {
|
||||
detailRows.push(
|
||||
["Date finished", fmt.date(product.consumedDate)],
|
||||
[
|
||||
"Lasted",
|
||||
`${Math.round((+new Date(product.consumedDate!) - +new Date(product.purchaseDate)) / 86_400_000)} days`,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (product.status === "gone") {
|
||||
detailRows.push(
|
||||
["Date gone", fmt.date(product.goneDate)],
|
||||
[
|
||||
"After",
|
||||
`${Math.round((+new Date(product.goneDate!) - +new Date(product.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",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "min(720px, 100vw)",
|
||||
height: "100%",
|
||||
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)" }}>
|
||||
Product · {product.sku}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{isActive && (
|
||||
<Btn variant="ghost" icon="check" onClick={() => onAudit(product)}>
|
||||
Audit
|
||||
</Btn>
|
||||
)}
|
||||
{isActive && (
|
||||
<Btn variant="secondary" icon="check" onClick={() => onConsume(product)}>
|
||||
Mark consumed
|
||||
</Btn>
|
||||
)}
|
||||
{isActive && (
|
||||
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(product)}>
|
||||
Mark gone
|
||||
</Btn>
|
||||
)}
|
||||
<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[product.type]} {product.type}
|
||||
</div>
|
||||
{product.status === "consumed" && (
|
||||
<Pill tone="terra">Consumed · {fmt.daysAgo(product.consumedDate)}</Pill>
|
||||
)}
|
||||
{product.status === "gone" && (
|
||||
<Pill tone="amber">Gone · {fmt.daysAgo(product.goneDate)}</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,
|
||||
}}
|
||||
>
|
||||
{product.name}
|
||||
</h1>
|
||||
<div style={{ fontSize: 16, color: "var(--ink-2)" }}>
|
||||
{helpers.brandName(data, product.brandId)} · from {helpers.shopName(data, product.shopId)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 1,
|
||||
marginTop: 32,
|
||||
background: "var(--line)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{(
|
||||
[
|
||||
["Price", fmt.money(product.price)],
|
||||
[
|
||||
product.kind === "discrete" ? "Quantity" : "Size",
|
||||
product.kind === "discrete"
|
||||
? `${product.countOriginal} ${cfg?.unit ?? "ct"}`
|
||||
: `${product.weight} ${cfg?.unit ?? "g"}`,
|
||||
],
|
||||
["THC", `${product.thc.toFixed(1)}%`],
|
||||
["CBD", `${product.cbd.toFixed(1)}%`],
|
||||
] as [string, string][]
|
||||
).map(([l, v]) => (
|
||||
<div key={l} 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 && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||
{product.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
|
||||
</div>
|
||||
<div style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
|
||||
{product.kind === "discrete"
|
||||
? `${product.countLastAudit ?? product.countOriginal} of ${product.countOriginal}`
|
||||
: `${est.toFixed(2)} of ${product.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>
|
||||
{product.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)} ({last.value}
|
||||
{cfg?.unit}). Re-audit to update.
|
||||
</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(product)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
fontSize: 12,
|
||||
color: "var(--ink-2)",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
+ New audit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{product.audits.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: "var(--ink-3)", fontStyle: "italic", padding: "12px 0" }}>
|
||||
No audits recorded. Cadence for {product.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",
|
||||
}}
|
||||
>
|
||||
{[...product.audits].reverse().map((a, i, arr) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: i < 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)} · {fmt.daysAgo(a.date)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 13, textAlign: "right" }}>
|
||||
<div>
|
||||
{a.value} {cfg?.unit}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "var(--ink-3)" }}>
|
||||
was {a.prev} {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>
|
||||
|
||||
{(product.status === "consumed" || product.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)" }}>
|
||||
{product.status === "gone" ? "Why it's gone" : "Final notes"}
|
||||
</div>
|
||||
{product.status === "consumed" && (
|
||||
<div style={{ display: "flex", gap: 2 }}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<Icon
|
||||
key={n}
|
||||
name="star"
|
||||
size={14}
|
||||
color={n <= (product.rating ?? 0) ? "var(--amber)" : "var(--ink-4)"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="serif"
|
||||
style={{ fontSize: 18, lineHeight: 1.5, color: "var(--ink-2)", fontStyle: "italic" }}
|
||||
>
|
||||
"{product.notes ?? "No notes recorded."}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user