Initial commit: Apothecary v0.4.0
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
// Dashboard, Inventory, Product detail screens
|
||||
|
||||
const H = window.DATA_HELPERS;
|
||||
const TODAY_STR = "2026-04-25";
|
||||
|
||||
// ─── Helpers shared across screens ─────────────────────────────────
|
||||
const remainingDisplay = (p) => {
|
||||
const cfg = window.SAMPLE_DATA.types.find(t => t.id === p.type);
|
||||
if (p.kind === "discrete") {
|
||||
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
|
||||
return `${cur} / ${p.countOriginal} ${cfg?.unit || "ct"}`;
|
||||
}
|
||||
const est = H.estimatedRemaining(p, TODAY_STR);
|
||||
return `${est.toFixed(2).replace(/\.?0+$/,"")} / ${p.weight} ${cfg?.unit || "g"}`;
|
||||
};
|
||||
const remainingShort = (p) => {
|
||||
if (p.kind === "discrete") {
|
||||
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
|
||||
return `${cur} ct`;
|
||||
}
|
||||
const cfg = window.SAMPLE_DATA.types.find(t => t.id === p.type);
|
||||
const est = H.estimatedRemaining(p, TODAY_STR);
|
||||
return `${est.toFixed(2).replace(/\.?0+$/,"") || "0"} ${cfg?.unit || "g"}`;
|
||||
};
|
||||
|
||||
const Dashboard = ({data, stats, onNav, onSelectProduct, onAudit, onMarkGone}) => {
|
||||
const series30 = stats.series30.map(d => ({ date: d.date, value: d.grams, label: "" }));
|
||||
|
||||
// Type breakdown
|
||||
const typeColors = {
|
||||
"Flower": "var(--sage)",
|
||||
"Concentrate": "var(--terracotta)",
|
||||
"Edible": "var(--amber)",
|
||||
"Vaporizer": "var(--plum)",
|
||||
"Pre-roll": "oklch(50% 0.06 200)",
|
||||
"Tincture": "oklch(55% 0.06 270)"
|
||||
};
|
||||
const segments = Object.entries(stats.typeBreakdown).map(([k, v]) => ({
|
||||
label: k, value: v, color: typeColors[k] || "var(--ink-3)"
|
||||
}));
|
||||
|
||||
// Sparklines
|
||||
const last7Series = stats.series7.map(l => l.grams);
|
||||
const last30Series = series30.map(d => d.value);
|
||||
|
||||
const overdue = stats.overdueAudits;
|
||||
const lowBulk = stats.lowStockBulk;
|
||||
const lowDiscrete = stats.lowStockDiscreteGroups;
|
||||
|
||||
return (
|
||||
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
|
||||
{/* Header */}
|
||||
<div style={{display: "flex", alignItems: "center", justifyContent: "space-between", gap: 24, marginBottom: 16, flexWrap: "wrap"}}>
|
||||
<div style={{minWidth: 0}}>
|
||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Saturday · April 25, 2026</div>
|
||||
<h1 className="serif" style={{fontSize: 36, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em", lineHeight: 1.1, whiteSpace: "nowrap"}}>Good evening.</h1>
|
||||
</div>
|
||||
<div style={{display: "flex", gap: 8, flexShrink: 0}}>
|
||||
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>New product</Btn>
|
||||
<Btn variant="secondary" icon="check" onClick={() => onNav("audit")}>Audit</Btn>
|
||||
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Mark finished</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{fontSize: 14, color: "var(--ink-2)", marginBottom: 28, maxWidth: 700}}>
|
||||
{stats.activeCount} active items across {data.bins.length} bins · {stats.consumedCount} consumed · {stats.goneCount} gone.
|
||||
{overdue.length > 0 && <span style={{color: "var(--terracotta)"}}> · {overdue.length} audit{overdue.length === 1 ? "" : "s"} overdue.</span>}
|
||||
</div>
|
||||
|
||||
{/* Top stats row */}
|
||||
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14, marginBottom: 14}}>
|
||||
<Stat
|
||||
label="Daily average"
|
||||
value={stats.dailyAvg.toFixed(2)}
|
||||
unit="g / day"
|
||||
sub={`${fmt.g(stats.weeklyAvg)} weekly · ${fmt.g(stats.monthlyAvg)} monthly`}
|
||||
spark={<Sparkline values={last30Series} width={240} height={28} color="var(--sage)" fill />}
|
||||
/>
|
||||
<Stat
|
||||
label="Avg cost per gram"
|
||||
value={fmt.money(stats.avgPerGram)}
|
||||
sub={`Across ${data.products.length} purchases`}
|
||||
/>
|
||||
<Stat
|
||||
label="30-day spend"
|
||||
value={fmt.moneyShort(stats.spend30)}
|
||||
sub={`Inventory value: ${fmt.money(stats.inventoryValue)}${stats.goneSpend > 0 ? ` · ${fmt.money(stats.goneSpend)} lost` : ""}`}
|
||||
/>
|
||||
<Stat
|
||||
label="THC last 7 days"
|
||||
value={stats.thcLast7.toLocaleString()}
|
||||
unit="mg"
|
||||
sub={`Last 30: ${(stats.thcLast30/1000).toFixed(1)} g THC`}
|
||||
spark={<Sparkline values={last7Series} width={240} height={28} color="var(--terracotta)" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Audit alert strip */}
|
||||
{overdue.length > 0 && (
|
||||
<Card style={{marginBottom: 14, borderColor: "var(--amber)", background: "var(--amber-soft)"}}>
|
||||
<div style={{display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap"}}>
|
||||
<div style={{flex: 1, minWidth: 240}}>
|
||||
<div className="smallcaps" style={{color: "oklch(48% 0.10 75)"}}>Audit overdue</div>
|
||||
<div className="serif" style={{fontSize: 20, marginTop: 4, color: "var(--ink)"}}>
|
||||
{overdue.length} item{overdue.length === 1 ? "" : "s"} haven't been checked in a while
|
||||
</div>
|
||||
<div style={{fontSize: 12, color: "var(--ink-2)", marginTop: 4}}>
|
||||
{overdue.slice(0, 3).map(p => p.name).join(" · ")}
|
||||
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
|
||||
</div>
|
||||
</div>
|
||||
<Btn variant="secondary" icon="check" onClick={() => onAudit && onAudit(overdue[0])}>Run audit</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Main grid */}
|
||||
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr", gap: 14, marginBottom: 14}}>
|
||||
{/* Consumption chart */}
|
||||
<Card>
|
||||
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
|
||||
<div>
|
||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Consumption</div>
|
||||
<div className="serif" style={{fontSize: 22, marginTop: 4}}>Last 30 days</div>
|
||||
</div>
|
||||
<div style={{display: "flex", gap: 16, fontSize: 12, color: "var(--ink-3)"}}>
|
||||
<div><span style={{color: "var(--ink)"}} className="serif" >{fmt.g(stats.series30.reduce((s,l)=>s+l.grams,0))}</span> est. total</div>
|
||||
<div><span style={{color: "var(--ink)"}} className="serif">{stats.avgGap.toFixed(0)}</span> day avg between buys</div>
|
||||
</div>
|
||||
</div>
|
||||
<BarChart data={series30.map(d => ({...d, label: ""}))} height={140} color="var(--sage)" />
|
||||
<div style={{display: "flex", justifyContent: "space-between", marginTop: 8, fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>
|
||||
<span>30 days ago</span>
|
||||
<span>15 days ago</span>
|
||||
<span>today</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Type breakdown */}
|
||||
<Card>
|
||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>By type · grams on hand</div>
|
||||
<div className="serif" style={{fontSize: 22, marginTop: 4, marginBottom: 16}}>Inventory</div>
|
||||
<div style={{display: "flex", alignItems: "center", gap: 20}}>
|
||||
<Donut segments={segments} size={140} thickness={20} />
|
||||
<div style={{flex: 1, display: "flex", flexDirection: "column", gap: 8}}>
|
||||
{segments.map(s => (
|
||||
<div key={s.label} style={{display: "flex", alignItems: "center", gap: 8, fontSize: 12}}>
|
||||
<div style={{width: 8, height: 8, borderRadius: 2, background: s.color}} />
|
||||
<div style={{flex: 1, color: "var(--ink-2)"}}>{s.label}</div>
|
||||
<div className="mono" style={{color: "var(--ink)"}}>{s.value.toFixed(1)}g</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14, marginBottom: 14}}>
|
||||
<Stat
|
||||
label="Days of supply"
|
||||
value={Math.round(stats.daysOfSupply)}
|
||||
unit="days"
|
||||
sub="Flower & pre-rolls at current pace"
|
||||
/>
|
||||
<Stat
|
||||
label="Avg lifespan"
|
||||
value={Math.round(stats.avgLifespan)}
|
||||
unit="days"
|
||||
sub="From purchase to finished"
|
||||
/>
|
||||
<Stat
|
||||
label="Days between buys"
|
||||
value={stats.avgGap.toFixed(1)}
|
||||
unit="days"
|
||||
sub="Average across all purchases"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: shop + brand + low stock */}
|
||||
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr 1.4fr", gap: 14}}>
|
||||
<Card>
|
||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Favorite shop</div>
|
||||
<div className="serif" style={{fontSize: 28, marginTop: 6, fontWeight: 500}}>{stats.favShop[0]}</div>
|
||||
<div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 4}}>{stats.favShop[1]} of {data.products.length} purchases</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Favorite brand</div>
|
||||
<div className="serif" style={{fontSize: 28, marginTop: 6, fontWeight: 500}}>{stats.favBrand[0]}</div>
|
||||
<div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 4}}>{stats.favBrand[1]} purchases</div>
|
||||
</Card>
|
||||
<Card padded={false}>
|
||||
<div style={{padding: "20px 20px 12px", display: "flex", justifyContent: "space-between", alignItems: "baseline"}}>
|
||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Low stock · running out</div>
|
||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{lowBulk.length + lowDiscrete.length} item{(lowBulk.length + lowDiscrete.length) === 1 ? "" : "s"}</div>
|
||||
</div>
|
||||
<div>
|
||||
{(lowBulk.length + lowDiscrete.length) === 0 && <div style={{padding: "0 20px 20px", fontSize: 13, color: "var(--ink-3)"}}>Nothing running low.</div>}
|
||||
{lowBulk.slice(0, 3).map(p => {
|
||||
const pct = H.pctRemaining(p, TODAY_STR);
|
||||
return (
|
||||
<div key={p.id} onClick={() => onSelectProduct(p)} style={{padding: "10px 20px", borderTop: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, cursor: "pointer"}}>
|
||||
<div style={{flex: 1, minWidth: 0}}>
|
||||
<div style={{fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>{p.name}</div>
|
||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{H.brandName(data, p.brandId)} · {p.type}</div>
|
||||
</div>
|
||||
<div style={{width: 60, height: 4, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden"}}>
|
||||
<div style={{width: `${pct * 100}%`, height: "100%", background: pct < 0.15 ? "var(--terracotta)" : "var(--amber)"}} />
|
||||
</div>
|
||||
<div className="mono" style={{fontSize: 11, color: "var(--ink-2)", width: 60, textAlign: "right"}}>{remainingShort(p)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{lowDiscrete.slice(0, 2).map(g => (
|
||||
<div key={g.key} onClick={() => onSelectProduct(g.items[0])} style={{padding: "10px 20px", borderTop: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, cursor: "pointer"}}>
|
||||
<div style={{flex: 1, minWidth: 0}}>
|
||||
<div style={{fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>{g.name}</div>
|
||||
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{H.brandName(data, g.brandId)} · {g.type}</div>
|
||||
</div>
|
||||
<Pill tone="amber" style={{fontSize: 10}}>{g.totalCount} left</Pill>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── INVENTORY ─────────────────────────────────────────────────────
|
||||
const Inventory = ({data, onSelectProduct, onNav}) => {
|
||||
const [filter, setFilter] = React.useState("active"); // active | consumed | gone | all
|
||||
const [typeFilter, setTypeFilter] = React.useState("all");
|
||||
const [sortBy, setSortBy] = React.useState("recent");
|
||||
const [search, setSearch] = React.useState("");
|
||||
|
||||
let products = data.products;
|
||||
if (filter === "active") products = products.filter(p => p.status === "active");
|
||||
else if (filter === "consumed") products = products.filter(p => p.status === "consumed");
|
||||
else if (filter === "gone") products = products.filter(p => p.status === "gone");
|
||||
if (typeFilter !== "all") products = products.filter(p => p.type === typeFilter);
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
products = products.filter(p => {
|
||||
const brand = H.brandName(data, p.brandId).toLowerCase();
|
||||
const shop = H.shopName(data, p.shopId).toLowerCase();
|
||||
return p.name.toLowerCase().includes(q) ||
|
||||
brand.includes(q) ||
|
||||
shop.includes(q) ||
|
||||
p.sku.toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
products = [...products].sort((a, b) => {
|
||||
if (sortBy === "recent") return new Date(b.purchaseDate) - new Date(a.purchaseDate);
|
||||
if (sortBy === "name") return a.name.localeCompare(b.name);
|
||||
if (sortBy === "thc") return b.thc - a.thc;
|
||||
if (sortBy === "remaining") return H.estimatedRemaining(b, TODAY_STR) - H.estimatedRemaining(a, TODAY_STR);
|
||||
if (sortBy === "price") return b.price - a.price;
|
||||
if (sortBy === "audit") return H.daysSinceCheck(b, TODAY_STR) - H.daysSinceCheck(a, TODAY_STR);
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
|
||||
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24}}>
|
||||
<div>
|
||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{products.length} items</div>
|
||||
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Inventory</h1>
|
||||
</div>
|
||||
<div style={{display: "flex", gap: 8}}>
|
||||
<Btn variant="secondary" icon="check" onClick={() => onNav("audit")}>Audit</Btn>
|
||||
<Btn variant="primary" icon="plus" onClick={() => onNav("add")}>New product</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<Card style={{marginBottom: 14, padding: 14}}>
|
||||
<div style={{display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap"}}>
|
||||
{/* Tabs */}
|
||||
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
|
||||
{[["active", "Active"], ["consumed", "Consumed"], ["gone", "Gone"], ["all", "All"]].map(([k, l]) => (
|
||||
<button key={k} onClick={() => setFilter(k)} style={{
|
||||
padding: "6px 14px",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: filter === k ? "var(--surface)" : "transparent",
|
||||
color: filter === k ? "var(--ink)" : "var(--ink-3)",
|
||||
boxShadow: filter === k ? "var(--shadow-sm)" : "none",
|
||||
cursor: "pointer"
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{flex: 1, minWidth: 220, display: "flex", alignItems: "center", gap: 8, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: "0 10px"}}>
|
||||
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||
<input
|
||||
placeholder="Search by name, brand, shop, SKU…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{border: "none", outline: "none", background: "transparent", padding: "8px 0", fontSize: 13, flex: 1, color: "var(--ink)"}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} style={{...inputStyle, width: "auto", padding: "8px 10px"}}>
|
||||
<option value="all">All types</option>
|
||||
{data.types.map(t => <option key={t.id} value={t.id}>{t.id}</option>)}
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onChange={e => setSortBy(e.target.value)} style={{...inputStyle, width: "auto", padding: "8px 10px"}}>
|
||||
<option value="recent">Recent first</option>
|
||||
<option value="name">Name (A–Z)</option>
|
||||
<option value="thc">THC % (high)</option>
|
||||
<option value="remaining">Remaining (high)</option>
|
||||
<option value="price">Price (high)</option>
|
||||
<option value="audit">Audit overdue first</option>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card padded={false}>
|
||||
<div style={{display: "grid", gridTemplateColumns: "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr", padding: "12px 20px", borderBottom: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>
|
||||
<div></div>
|
||||
<div>Product</div>
|
||||
<div>Brand</div>
|
||||
<div>Shop</div>
|
||||
<div style={{textAlign: "right"}}>THC %</div>
|
||||
<div style={{textAlign: "right"}}>Price</div>
|
||||
<div style={{textAlign: "right"}}>Remaining</div>
|
||||
<div>Last checked</div>
|
||||
<div>Bin</div>
|
||||
</div>
|
||||
{products.length === 0 && (
|
||||
<div style={{padding: 60, textAlign: "center", color: "var(--ink-3)"}}>No items match these filters.</div>
|
||||
)}
|
||||
{products.map(p => {
|
||||
const bin = data.bins.find(b => b.id === p.binId);
|
||||
const pctRemaining = H.pctRemaining(p, TODAY_STR);
|
||||
const overdue = H.auditOverdue(data, p, TODAY_STR);
|
||||
const sinceCheck = H.daysSinceCheck(p, TODAY_STR);
|
||||
const last = H.lastAudit(p);
|
||||
const isInactive = p.status !== "active";
|
||||
return (
|
||||
<div key={p.id} onClick={() => onSelectProduct(p)} className="inv-row" style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr",
|
||||
padding: "14px 20px",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
opacity: isInactive ? 0.55 : 1,
|
||||
fontSize: 13
|
||||
}}>
|
||||
<div style={{fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)"}}>{TYPE_GLYPHS[p.type]}</div>
|
||||
<div style={{minWidth: 0}}>
|
||||
<div style={{fontWeight: 500, color: "var(--ink)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>
|
||||
{p.name}
|
||||
{p.status === "consumed" && <Pill tone="terra" style={{marginLeft: 6, fontSize: 10}}>Consumed</Pill>}
|
||||
{p.status === "gone" && <Pill tone="amber" style={{marginLeft: 6, fontSize: 10}}>Gone</Pill>}
|
||||
{p.status === "active" && overdue && <Pill tone="amber" style={{marginLeft: 6, fontSize: 10}}>Audit due</Pill>}
|
||||
</div>
|
||||
<div style={{fontSize: 11, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{p.sku}{p.assetTag ? ` · ${p.assetTag}` : ""}</div>
|
||||
</div>
|
||||
<div style={{color: "var(--ink-2)"}}>{H.brandName(data, p.brandId)}</div>
|
||||
<div style={{color: "var(--ink-3)", fontSize: 12}}>{H.shopName(data, p.shopId)}</div>
|
||||
<div style={{textAlign: "right", fontFamily: "var(--mono)", color: "var(--ink-2)"}}>{p.thc.toFixed(1)}</div>
|
||||
<div style={{textAlign: "right", fontFamily: "var(--mono)"}}>{fmt.money(p.price)}</div>
|
||||
<div style={{textAlign: "right", display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4}}>
|
||||
<div style={{fontFamily: "var(--mono)", fontSize: 12}}>{remainingShort(p)}</div>
|
||||
{p.status === "active" && p.kind === "bulk" && (
|
||||
<div style={{width: 50, height: 3, background: "var(--bg-3)", borderRadius: 2}}>
|
||||
<div style={{width: `${pctRemaining*100}%`, height: "100%", background: pctRemaining < 0.25 ? "var(--terracotta)" : pctRemaining < 0.5 ? "var(--amber)" : "var(--sage)", borderRadius: 2}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{fontSize: 11, color: overdue ? "var(--terracotta)" : "var(--ink-3)"}}>
|
||||
{p.status !== "active"
|
||||
? <span style={{fontStyle: "italic"}}>archived</span>
|
||||
: last
|
||||
? <span><span className="mono">{sinceCheck}d</span> ago · {last.mode}</span>
|
||||
: <span style={{fontStyle: "italic"}}>never</span>}
|
||||
</div>
|
||||
<div style={{fontSize: 12, color: "var(--ink-3)"}}>{bin ? bin.name : <span style={{fontStyle: "italic"}}>—</span>}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── PRODUCT DETAIL ────────────────────────────────────────────────
|
||||
const ProductDetail = ({product, data, onClose, onConsume, onMarkGone, onAudit, onEdit}) => {
|
||||
const bin = data.bins.find(b => b.id === product.binId);
|
||||
const cfg = data.types.find(t => t.id === product.type);
|
||||
const pctRemaining = H.pctRemaining(product, TODAY_STR);
|
||||
const est = H.estimatedRemaining(product, TODAY_STR);
|
||||
const last = H.lastAudit(product);
|
||||
const overdue = H.auditOverdue(data, product, TODAY_STR);
|
||||
const sinceCheck = H.daysSinceCheck(product, TODAY_STR);
|
||||
|
||||
const isActive = product.status === "active";
|
||||
|
||||
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)"
|
||||
}}>
|
||||
{/* Header */}
|
||||
<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 finished</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"}}>
|
||||
{/* Identity */}
|
||||
<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)"}}>{H.brandName(data, product.brandId)} · from {H.shopName(data, product.shopId)}</div>
|
||||
|
||||
{/* Hero stats */}
|
||||
<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)}%`]
|
||||
].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>
|
||||
|
||||
{/* Remaining + audit */}
|
||||
{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 != null ? 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>
|
||||
)}
|
||||
|
||||
{/* Audit history */}
|
||||
<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 || 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) => (
|
||||
<div key={i} style={{padding: "12px 16px", borderBottom: i < product.audits.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>
|
||||
|
||||
{/* Details list */}
|
||||
<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"}}>
|
||||
{[
|
||||
["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", H.brandName(data, product.brandId)],
|
||||
["Shop", H.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)`
|
||||
: "—"
|
||||
],
|
||||
...(product.status === "consumed" ? [
|
||||
["Date finished", fmt.date(product.consumedDate)],
|
||||
["Lasted", `${Math.round((new Date(product.consumedDate) - new Date(product.purchaseDate))/86400000)} days`]
|
||||
] : []),
|
||||
...(product.status === "gone" ? [
|
||||
["Date gone", fmt.date(product.goneDate)],
|
||||
["After", `${Math.round((new Date(product.goneDate) - new Date(product.purchaseDate))/86400000)} days`]
|
||||
] : [])
|
||||
].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>
|
||||
|
||||
{/* Final notes */}
|
||||
{(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>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { Dashboard, Inventory, ProductDetail, remainingDisplay, remainingShort });
|
||||
Reference in New Issue
Block a user