Initial commit: Apothecary v0.4.0
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Bin, Product } from "../types.js";
|
||||
import { helpers, TODAY_STR } from "../types.js";
|
||||
import { remainingShort } from "../stats.js";
|
||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||
import { api } from "../api.js";
|
||||
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
|
||||
|
||||
export function BinsView({
|
||||
data,
|
||||
onSelectProduct,
|
||||
onAddBin,
|
||||
onEditBin,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onSelectProduct: (p: Product) => void;
|
||||
onAddBin: () => void;
|
||||
onEditBin: (bin: Bin) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.deleteBin(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }),
|
||||
});
|
||||
|
||||
const handleDelete = (binId: string, binName: string, activeCount: number) => {
|
||||
const msg =
|
||||
activeCount > 0
|
||||
? `Delete "${binName}"? ${activeCount} active product${activeCount === 1 ? "" : "s"} will be moved to Unassigned.`
|
||||
: `Delete "${binName}"?`;
|
||||
if (window.confirm(msg)) remove.mutate(binId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||
maxWidth: 2400,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{data.bins.length} bins</div>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
|
||||
>
|
||||
Bins & storage
|
||||
</h1>
|
||||
</div>
|
||||
<Btn variant="secondary" icon="plus" onClick={onAddBin}>New bin</Btn>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600 }}>
|
||||
Where each active product physically lives. Archived items aren't assigned to a bin.
|
||||
</div>
|
||||
|
||||
{data.bins.length === 0 && (
|
||||
<Card style={{ padding: 60, textAlign: "center" }}>
|
||||
<div className="serif" style={{ fontSize: 22, marginBottom: 6 }}>No bins yet</div>
|
||||
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
|
||||
Add a bin to start placing products somewhere.
|
||||
</div>
|
||||
<Btn variant="primary" icon="plus" onClick={onAddBin}>Add your first bin</Btn>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(380px, 1fr))",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{data.bins.map((bin) => {
|
||||
const items = data.products.filter((p) => p.binId === bin.id && p.status === "active");
|
||||
const fillPct = items.length / bin.capacity;
|
||||
const totalValue = items.reduce(
|
||||
(s, p) => s + p.price * helpers.pctRemaining(p, TODAY_STR),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<Card key={bin.id} padded={false} style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "20px 22px 16px", borderBottom: "1px solid var(--line)" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 4,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<h3 className="serif" style={{ fontSize: 24, margin: 0, fontWeight: 500 }}>
|
||||
{bin.name}
|
||||
</h3>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<Pill tone="outline">{items.length} / {bin.capacity}</Pill>
|
||||
<button
|
||||
onClick={() => onEditBin(bin)}
|
||||
title="Edit bin"
|
||||
aria-label={`Edit bin ${bin.name}`}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 4,
|
||||
borderRadius: "var(--r-sm)",
|
||||
cursor: "pointer",
|
||||
color: "var(--ink-3)",
|
||||
display: "inline-flex",
|
||||
}}
|
||||
>
|
||||
<Icon name="edit" size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(bin.id, bin.name, items.length)}
|
||||
title="Remove bin"
|
||||
aria-label={`Remove bin ${bin.name}`}
|
||||
disabled={remove.isPending}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 4,
|
||||
borderRadius: "var(--r-sm)",
|
||||
cursor: remove.isPending ? "wait" : "pointer",
|
||||
color: "var(--ink-3)",
|
||||
display: "inline-flex",
|
||||
}}
|
||||
>
|
||||
<Icon name="bin" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--ink-3)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>{bin.location}</span>
|
||||
<span className="mono">{fmt.money(totalValue)}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
height: 4,
|
||||
background: "var(--bg-3)",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.min(fillPct, 1) * 100}%`,
|
||||
height: "100%",
|
||||
background:
|
||||
fillPct > 0.9
|
||||
? "var(--terracotta)"
|
||||
: fillPct > 0.7
|
||||
? "var(--amber)"
|
||||
: "var(--sage)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 8, flex: 1 }}>
|
||||
{items.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: 30,
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "var(--ink-3)",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
Empty
|
||||
</div>
|
||||
)}
|
||||
{items.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => onSelectProduct(p)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 14px",
|
||||
borderRadius: "var(--r-sm)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--serif)",
|
||||
fontSize: 18,
|
||||
color: "var(--ink-3)",
|
||||
width: 18,
|
||||
}}
|
||||
>
|
||||
{TYPE_GLYPHS[p.type]}
|
||||
</div>
|
||||
<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)" }}>
|
||||
{helpers.brandName(data, p.brandId)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-2)" }}>
|
||||
{remainingShort(p)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Bootstrap } from "../types.js";
|
||||
import { Btn, Card, Pill } from "../components/primitives/index.js";
|
||||
|
||||
export function BrandsView({
|
||||
data,
|
||||
onAddBrand,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onAddBrand: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||
maxWidth: 2400,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||
{data.brands.length} brand{data.brands.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
|
||||
>
|
||||
Brands
|
||||
</h1>
|
||||
</div>
|
||||
<Btn variant="primary" icon="plus" onClick={onAddBrand}>New brand</Btn>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600 }}>
|
||||
Brands you've purchased from. Used in the brand dropdown when adding a new product.
|
||||
</div>
|
||||
|
||||
{data.brands.length === 0 ? (
|
||||
<Card style={{ padding: 60, textAlign: "center" }}>
|
||||
<div className="serif" style={{ fontSize: 22, marginBottom: 6 }}>No brands yet</div>
|
||||
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
|
||||
Add a brand to start tagging your purchases.
|
||||
</div>
|
||||
<Btn variant="primary" icon="plus" onClick={onAddBrand}>Add your first brand</Btn>
|
||||
</Card>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{data.brands.map((b) => {
|
||||
const count = data.products.filter((p) => p.brandId === b.id).length;
|
||||
return (
|
||||
<Card key={b.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1.1 }}>
|
||||
{b.name}
|
||||
</div>
|
||||
</div>
|
||||
<Pill tone="outline">
|
||||
{count} purchase{count === 1 ? "" : "s"}
|
||||
</Pill>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { Bootstrap } from "../types.js";
|
||||
import { helpers } from "../types.js";
|
||||
import type { Stats } from "../stats.js";
|
||||
import { fmt } from "../format.js";
|
||||
import { BarChart, Card } from "../components/primitives/index.js";
|
||||
|
||||
export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
|
||||
const series = stats.series90.map((s) => ({ date: s.date, grams: s.grams }));
|
||||
|
||||
const spendByMonth: Record<string, number> = {};
|
||||
data.products.forEach((p) => {
|
||||
const k = p.purchaseDate.slice(0, 7);
|
||||
spendByMonth[k] = (spendByMonth[k] ?? 0) + p.price;
|
||||
});
|
||||
const months = Object.entries(spendByMonth).sort();
|
||||
|
||||
const spendByShop: Record<string, number> = {};
|
||||
data.products.forEach((p) => {
|
||||
const name = helpers.shopName(data, p.shopId);
|
||||
spendByShop[name] = (spendByShop[name] ?? 0) + p.price;
|
||||
});
|
||||
const shopRanked = Object.entries(spendByShop).sort((a, b) => b[1] - a[1]);
|
||||
const shopMax = shopRanked[0]?.[1] ?? 1;
|
||||
const monthMax = Math.max(...months.map((x) => x[1]), 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||
maxWidth: 2400,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Last 90 days</div>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
|
||||
>
|
||||
Patterns & spend
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card style={{ marginBottom: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<div className="serif" style={{ fontSize: 22 }}>Daily grams · 90 days</div>
|
||||
<div style={{ display: "flex", gap: 24, fontSize: 12, color: "var(--ink-3)" }}>
|
||||
<div>
|
||||
Total{" "}
|
||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
||||
{series.reduce((s, e) => s + e.grams, 0).toFixed(1)} g
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Avg{" "}
|
||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
||||
{(series.reduce((s, e) => s + e.grams, 0) / 90).toFixed(2)} g/day
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Items finished{" "}
|
||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>{stats.consumedCount}</span>
|
||||
</div>
|
||||
{stats.goneCount > 0 && (
|
||||
<div>
|
||||
Items gone{" "}
|
||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>{stats.goneCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<BarChart data={series.map((s) => ({ value: s.grams }))} height={180} color="var(--sage)" />
|
||||
</Card>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
|
||||
<Card>
|
||||
<div className="serif" style={{ fontSize: 22, marginBottom: 18 }}>Spend by month</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
{months.map(([m, v]) => {
|
||||
const d = new Date(m + "-01");
|
||||
return (
|
||||
<div key={m} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)", width: 60 }}>
|
||||
{d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 24,
|
||||
background: "var(--bg-2)",
|
||||
borderRadius: 4,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${(v / monthMax) * 100}%`,
|
||||
height: "100%",
|
||||
background: "var(--terracotta)",
|
||||
borderRadius: 4,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mono" style={{ width: 70, textAlign: "right", fontSize: 13 }}>
|
||||
{fmt.moneyShort(v)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="serif" style={{ fontSize: 22, marginBottom: 18 }}>Spend by shop</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
{shopRanked.map(([s, v]) => (
|
||||
<div key={s} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ flex: 1.5, fontSize: 13, color: "var(--ink-2)" }}>{s}</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 2,
|
||||
height: 8,
|
||||
background: "var(--bg-2)",
|
||||
borderRadius: 4,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${(v / shopMax) * 100}%`,
|
||||
height: "100%",
|
||||
background: "var(--sage)",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mono" style={{ width: 70, textAlign: "right", fontSize: 13 }}>
|
||||
{fmt.moneyShort(v)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="serif" style={{ fontSize: 22, marginBottom: 6 }}>Inferred consumption heatmap</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)", marginBottom: 18 }}>
|
||||
13 weeks · darker = higher inferred daily use, prorated across each item's lifespan
|
||||
</div>
|
||||
<Heatmap series={series} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Heatmap({ series }: { series: { date: string; grams: number }[] }) {
|
||||
const first = new Date(series[0]!.date);
|
||||
const offset = first.getDay();
|
||||
const cells: ({ date: string; grams: number } | null)[] = [];
|
||||
for (let i = 0; i < offset; i++) cells.push(null);
|
||||
series.forEach((s) => cells.push(s));
|
||||
while (cells.length < 13 * 7) cells.push(null);
|
||||
|
||||
const max = Math.max(...series.map((s) => s.grams), 0.001);
|
||||
const colorFor = (g: number) => {
|
||||
if (g === 0) return "var(--bg-3)";
|
||||
const t = g / max;
|
||||
return `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`;
|
||||
};
|
||||
|
||||
const days = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3, paddingTop: 18 }}>
|
||||
{days.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ height: 14, fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)" }}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(13, 1fr)",
|
||||
gap: 3,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 13 }).map((_, w) => {
|
||||
const firstDay = cells[w * 7];
|
||||
return (
|
||||
<div
|
||||
key={w}
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: "var(--ink-3)",
|
||||
fontFamily: "var(--mono)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{firstDay && new Date(firstDay.date).getDate() <= 7
|
||||
? new Date(firstDay.date).toLocaleDateString("en-US", { month: "short" })
|
||||
: ""}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateRows: "repeat(7, 1fr)",
|
||||
gridAutoFlow: "column",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{cells.map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={c ? `${c.date}: ${c.grams.toFixed(2)}g` : ""}
|
||||
style={{
|
||||
aspectRatio: "1",
|
||||
minHeight: 14,
|
||||
background: c ? colorFor(c.grams) : "transparent",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginTop: 14,
|
||||
fontSize: 10,
|
||||
color: "var(--ink-3)",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
>
|
||||
<span>Less</span>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
background: t === 0 ? "var(--bg-3)" : `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import type { Bootstrap, Product } from "../types.js";
|
||||
import { helpers, TODAY_STR } from "../types.js";
|
||||
import type { Stats } from "../stats.js";
|
||||
import { remainingShort } from "../stats.js";
|
||||
import { fmt } from "../format.js";
|
||||
import { Btn, Card, Stat, Pill, Sparkline, BarChart, Donut } from "../components/primitives/index.js";
|
||||
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
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)",
|
||||
};
|
||||
|
||||
export function Dashboard({
|
||||
data,
|
||||
stats,
|
||||
onAuditProduct,
|
||||
onSelectProduct,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
stats: Stats;
|
||||
onAuditProduct: (p: Product) => void;
|
||||
onSelectProduct: (p: Product) => void;
|
||||
}) {
|
||||
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
|
||||
const last7Series = stats.series7.map((l) => l.grams);
|
||||
const last30Series = stats.series30.map((d) => d.grams);
|
||||
|
||||
const segments = Object.entries(stats.typeBreakdown).map(([k, v]) => ({
|
||||
label: k,
|
||||
value: v,
|
||||
color: TYPE_COLORS[k] ?? "var(--ink-3)",
|
||||
}));
|
||||
|
||||
const overdue = stats.overdueAudits;
|
||||
const lowBulk = stats.lowStockBulk;
|
||||
const lowDiscrete = stats.lowStockDiscreteGroups;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||
maxWidth: 2400,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Saturday · April 25, 2026</div>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: 48,
|
||||
margin: "8px 0 0",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
Good evening.
|
||||
</h1>
|
||||
</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>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 18,
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<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={
|
||||
stats.goneSpend > 0
|
||||
? `${fmt.money(stats.goneSpend)} lost to gone items`
|
||||
: `7-day: ${fmt.money(stats.spend7)} · 90-day: ${fmt.money(stats.spend90)}`
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 18,
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<Stat
|
||||
label="Inventory value"
|
||||
value={fmt.money(stats.inventoryValue)}
|
||||
sub={`${stats.activeCount} active item${stats.activeCount === 1 ? "" : "s"} on hand`}
|
||||
/>
|
||||
<Stat
|
||||
label="Inventory on hand"
|
||||
value={stats.inventoryGrams.toFixed(stats.inventoryGrams >= 10 ? 1 : 2)}
|
||||
unit="g"
|
||||
sub="Estimated remaining across active jars"
|
||||
/>
|
||||
<Stat
|
||||
label="Spent all-time"
|
||||
value={fmt.money(stats.totalSpend)}
|
||||
sub={`${data.products.length} purchase${data.products.length === 1 ? "" : "s"}${stats.goneSpend > 0 ? ` · ${fmt.money(stats.goneSpend)} lost` : ""}`}
|
||||
/>
|
||||
<Stat
|
||||
label="Purchased all-time"
|
||||
value={stats.totalGrams.toFixed(stats.totalGrams >= 10 ? 1 : 2)}
|
||||
unit="g"
|
||||
sub="Lifetime weight purchased (excl. tinctures, edibles)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{overdue.length > 0 && (
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: 18,
|
||||
borderColor: "var(--amber)",
|
||||
background: "var(--amber-soft)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 18, 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={() => onAuditProduct(overdue[0]!)}>
|
||||
Run audit
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1fr",
|
||||
gap: 18,
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<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} 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>
|
||||
|
||||
<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: 18,
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1.4fr",
|
||||
gap: 18,
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Favorite shop</div>
|
||||
<div className="serif" style={{ fontSize: 40, marginTop: 12, fontWeight: 500, lineHeight: 1.05 }}>
|
||||
{stats.favShop[0]}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 10 }}>
|
||||
{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: 40, marginTop: 12, fontWeight: 500, lineHeight: 1.05 }}>
|
||||
{stats.favBrand[0]}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 10 }}>
|
||||
{stats.favBrand[1]} purchases
|
||||
</div>
|
||||
</Card>
|
||||
<Card padded={false}>
|
||||
<div
|
||||
style={{
|
||||
padding: "24px 24px 14px",
|
||||
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 24px 24px", fontSize: 13, color: "var(--ink-3)" }}>
|
||||
Nothing running low.
|
||||
</div>
|
||||
)}
|
||||
{lowBulk.slice(0, 3).map((p) => {
|
||||
const pct = helpers.pctRemaining(p, TODAY_STR);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => onSelectProduct(p)}
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
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)" }}>
|
||||
{helpers.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: "12px 24px",
|
||||
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)" }}>
|
||||
{helpers.brandName(data, g.brandId)} · {g.type}
|
||||
</div>
|
||||
</div>
|
||||
<Pill tone="amber" style={{ fontSize: 10 }}>
|
||||
{g.totalCount} left
|
||||
</Pill>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Bootstrap, Product } from "../types.js";
|
||||
import { TYPES, helpers, TODAY_STR } from "../types.js";
|
||||
import { remainingShort } from "../stats.js";
|
||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||
import { Btn, Card, Pill, Icon, Select, inputStyle } from "../components/primitives/index.js";
|
||||
|
||||
type FilterKey = "active" | "consumed" | "gone" | "all";
|
||||
type SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit";
|
||||
type ViewKey = "flat" | "grouped";
|
||||
|
||||
const GRID_COLS = "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr";
|
||||
|
||||
export function Inventory({
|
||||
data,
|
||||
onSelectProduct,
|
||||
onAddProduct,
|
||||
onAuditNew,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onSelectProduct: (p: Product) => void;
|
||||
onAddProduct: () => void;
|
||||
onAuditNew: () => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<FilterKey>("active");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<SortKey>("recent");
|
||||
const [search, setSearch] = useState("");
|
||||
const [view, setView] = useState<ViewKey>(
|
||||
() => (localStorage.getItem("apothecary.inventoryView") as ViewKey | null) ?? "flat",
|
||||
);
|
||||
useEffect(() => {
|
||||
localStorage.setItem("apothecary.inventoryView", view);
|
||||
}, [view]);
|
||||
|
||||
const sortFn = (a: Product, b: Product) => {
|
||||
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 helpers.estimatedRemaining(b, TODAY_STR) - helpers.estimatedRemaining(a, TODAY_STR);
|
||||
if (sortBy === "price") return b.price - a.price;
|
||||
if (sortBy === "audit")
|
||||
return helpers.daysSinceCheck(b, TODAY_STR) - helpers.daysSinceCheck(a, TODAY_STR);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
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 = helpers.brandName(data, p.brandId).toLowerCase();
|
||||
const shop = helpers.shopName(data, p.shopId).toLowerCase();
|
||||
return (
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
brand.includes(q) ||
|
||||
shop.includes(q) ||
|
||||
p.sku.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
return products;
|
||||
}, [data, filter, typeFilter, search]);
|
||||
|
||||
const sortedProducts = useMemo(
|
||||
() => [...filteredProducts].sort(sortFn),
|
||||
[filteredProducts, sortBy],
|
||||
);
|
||||
|
||||
// For grouped mode: bucket by strainId. Products without a strainId fall
|
||||
// into an "Unlinked" bucket at the end. Within each group, sort by sortFn.
|
||||
type Group = {
|
||||
strainId: string | null;
|
||||
label: string;
|
||||
brand: string;
|
||||
type: string;
|
||||
products: Product[];
|
||||
};
|
||||
const groups: Group[] = useMemo(() => {
|
||||
const byStrain = new Map<string | null, Product[]>();
|
||||
for (const p of filteredProducts) {
|
||||
const arr = byStrain.get(p.strainId) ?? [];
|
||||
arr.push(p);
|
||||
byStrain.set(p.strainId, arr);
|
||||
}
|
||||
const out: Group[] = [];
|
||||
for (const [strainId, products] of byStrain.entries()) {
|
||||
const first = products[0]!;
|
||||
// Prefer the strain's canonical name when available so casing is
|
||||
// consistent regardless of which product was added first.
|
||||
const strain = strainId ? data.strains.find((s) => s.id === strainId) : null;
|
||||
out.push({
|
||||
strainId,
|
||||
label: strain?.name ?? first.name,
|
||||
brand: helpers.brandName(data, first.brandId),
|
||||
type: first.type,
|
||||
products: [...products].sort(sortFn),
|
||||
});
|
||||
}
|
||||
// Order groups by their most-recent purchase date desc so newest strains float up.
|
||||
out.sort((a, b) => {
|
||||
if (a.strainId === null) return 1;
|
||||
if (b.strainId === null) return -1;
|
||||
const aMax = Math.max(...a.products.map((p) => +new Date(p.purchaseDate)));
|
||||
const bMax = Math.max(...b.products.map((p) => +new Date(p.purchaseDate)));
|
||||
return bMax - aMax;
|
||||
});
|
||||
return out;
|
||||
}, [filteredProducts, data, sortBy]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||
maxWidth: 2400,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||
{sortedProducts.length} item{sortedProducts.length === 1 ? "" : "s"}
|
||||
</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={onAuditNew}>Audit</Btn>
|
||||
<Btn variant="primary" icon="plus" onClick={onAddProduct}>New product</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card style={{ marginBottom: 14, padding: 14 }}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Segmented<FilterKey>
|
||||
value={filter}
|
||||
options={[
|
||||
["active", "Active"],
|
||||
["consumed", "Consumed"],
|
||||
["gone", "Gone"],
|
||||
["all", "All"],
|
||||
]}
|
||||
onChange={setFilter}
|
||||
/>
|
||||
|
||||
<Segmented<ViewKey>
|
||||
value={view}
|
||||
options={[
|
||||
["flat", "Flat"],
|
||||
["grouped", "Grouped"],
|
||||
]}
|
||||
onChange={setView}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.id}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortKey)}
|
||||
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>
|
||||
|
||||
<Card padded={false}>
|
||||
<HeaderRow />
|
||||
{sortedProducts.length === 0 && (
|
||||
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
||||
No items match these filters.
|
||||
</div>
|
||||
)}
|
||||
{view === "flat" &&
|
||||
sortedProducts.map((p) => (
|
||||
<ProductRow key={p.id} p={p} data={data} onSelect={onSelectProduct} />
|
||||
))}
|
||||
{view === "grouped" &&
|
||||
groups.map((g) => (
|
||||
<div key={g.strainId ?? "unlinked"}>
|
||||
<GroupHeader group={g} />
|
||||
{g.products.map((p) => (
|
||||
<ProductRow key={p.id} p={p} data={data} onSelect={onSelectProduct} indented />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Segmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: T;
|
||||
options: [T, string][];
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
padding: 3,
|
||||
}}
|
||||
>
|
||||
{options.map(([k, l]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => onChange(k)}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: value === k ? "var(--surface)" : "transparent",
|
||||
color: value === k ? "var(--ink)" : "var(--ink-3)",
|
||||
boxShadow: value === k ? "var(--shadow-sm)" : "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderRow() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
columnGap: 16,
|
||||
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>THC %</div>
|
||||
<div>Price</div>
|
||||
<div>Remaining</div>
|
||||
<div>Last checked</div>
|
||||
<div>Bin</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupHeader({
|
||||
group,
|
||||
}: {
|
||||
group: {
|
||||
strainId: string | null;
|
||||
label: string;
|
||||
brand: string;
|
||||
type: string;
|
||||
products: Product[];
|
||||
};
|
||||
}) {
|
||||
// Aggregate remaining: bulk uses estimatedRemaining; discrete uses unitWeight × count.
|
||||
// Counts use status === "active" only — archived rows shouldn't inflate "on hand."
|
||||
const active = group.products.filter((p) => p.status === "active");
|
||||
const totalRemaining = active.reduce((s, p) => {
|
||||
if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, TODAY_STR);
|
||||
const cur = p.countLastAudit ?? p.countOriginal;
|
||||
return s + cur * (p.unitWeight || 0);
|
||||
}, 0);
|
||||
const totalCount = active.length;
|
||||
const lastBuy = group.products.reduce((max, p) => {
|
||||
const t = +new Date(p.purchaseDate);
|
||||
return t > max ? t : max;
|
||||
}, 0);
|
||||
const cfg = TYPES.find((t) => t.id === group.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
padding: "16px 20px 10px",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
background: "var(--bg-2)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 12, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)", width: 18 }}>
|
||||
{TYPE_GLYPHS[group.type]}
|
||||
</div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1.1 }}>
|
||||
{group.strainId === null ? "Unlinked" : group.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{group.brand} · {group.type}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 18, fontSize: 12, color: "var(--ink-3)" }}>
|
||||
<div>
|
||||
<span className="mono" style={{ color: "var(--ink-2)" }}>
|
||||
{totalCount}
|
||||
</span>{" "}
|
||||
{totalCount === 1 ? "active" : "active"}
|
||||
{group.products.length !== totalCount && (
|
||||
<span style={{ color: "var(--ink-4)" }}> / {group.products.length} total</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="mono" style={{ color: "var(--ink-2)" }}>
|
||||
{totalRemaining.toFixed(2).replace(/\.?0+$/, "") || "0"}
|
||||
</span>{" "}
|
||||
{cfg?.unit ?? "g"} on hand
|
||||
</div>
|
||||
{lastBuy > 0 && (
|
||||
<div>
|
||||
last buy <span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.dateShort(new Date(lastBuy).toISOString())}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductRow({
|
||||
p,
|
||||
data,
|
||||
onSelect,
|
||||
indented = false,
|
||||
}: {
|
||||
p: Product;
|
||||
data: Bootstrap;
|
||||
onSelect: (p: Product) => void;
|
||||
indented?: boolean;
|
||||
}) {
|
||||
const bin = data.bins.find((b) => b.id === p.binId);
|
||||
const pctRemaining = helpers.pctRemaining(p, TODAY_STR);
|
||||
const overdue = helpers.auditOverdue(p, TODAY_STR);
|
||||
const sinceCheck = helpers.daysSinceCheck(p, TODAY_STR);
|
||||
const last = helpers.lastAudit(p);
|
||||
const isInactive = p.status !== "active";
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(p)}
|
||||
className="inv-row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
columnGap: 16,
|
||||
padding: indented ? "14px 20px 14px 36px" : "14px 20px",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
opacity: isInactive ? 0.55 : 1,
|
||||
fontSize: 13,
|
||||
borderLeft: indented ? "2px solid var(--bg-3)" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--serif)",
|
||||
fontSize: 18,
|
||||
color: "var(--ink-3)",
|
||||
opacity: indented ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{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)" }}>{helpers.brandName(data, p.brandId)}</div>
|
||||
<div style={{ color: "var(--ink-3)", fontSize: 12 }}>{helpers.shopName(data, p.shopId)}</div>
|
||||
<div style={{ fontFamily: "var(--mono)", color: "var(--ink-2)" }}>{p.thc.toFixed(1)}</div>
|
||||
<div style={{ fontFamily: "var(--mono)" }}>{fmt.money(p.price)}</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{remainingShort(p)}</div>
|
||||
{p.status === "active" && p.kind === "bulk" && (
|
||||
<div style={{ width: 80, height: 5, 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Bootstrap } from "../types.js";
|
||||
import { Btn, Card, Stat } from "../components/primitives/index.js";
|
||||
|
||||
export type ThemeKey = "light" | "dark";
|
||||
|
||||
export function SettingsView({
|
||||
data,
|
||||
theme,
|
||||
onThemeChange,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
theme: ThemeKey;
|
||||
onThemeChange: (t: ThemeKey) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||
maxWidth: 1400,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Settings</div>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
|
||||
>
|
||||
Preferences
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card style={{ marginBottom: 14 }}>
|
||||
<div className="serif" style={{ fontSize: 22, marginBottom: 16 }}>Appearance</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<SettingRow label="Theme" hint="Light parchment or dim ink">
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
padding: 3,
|
||||
}}
|
||||
>
|
||||
{(["light", "dark"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => onThemeChange(t)}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: theme === t ? "var(--surface)" : "transparent",
|
||||
color: theme === t ? "var(--ink)" : "var(--ink-3)",
|
||||
cursor: "pointer",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginBottom: 14 }}>
|
||||
<div className="serif" style={{ fontSize: 22, marginBottom: 16 }}>Library</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 16 }}>
|
||||
<Stat label="Active" value={data.products.filter((p) => p.status === "active").length} />
|
||||
<Stat label="Consumed" value={data.products.filter((p) => p.status === "consumed").length} />
|
||||
<Stat label="Gone" value={data.products.filter((p) => p.status === "gone").length} />
|
||||
<Stat label="Bins" value={data.bins.length} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="secondary">Export CSV</Btn>
|
||||
<Btn variant="secondary">Export JSON</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingBottom: 14,
|
||||
borderBottom: "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{label}</div>
|
||||
{hint && <div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 2 }}>{hint}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Bootstrap } from "../types.js";
|
||||
import { Btn, Card, Pill } from "../components/primitives/index.js";
|
||||
|
||||
export function ShopsView({
|
||||
data,
|
||||
onAddShop,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onAddShop: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
|
||||
maxWidth: 2400,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||
{data.shops.length} shop{data.shops.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
|
||||
>
|
||||
Shops
|
||||
</h1>
|
||||
</div>
|
||||
<Btn variant="primary" icon="plus" onClick={onAddShop}>New shop</Btn>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600 }}>
|
||||
Where you've purchased from. Used in the shop dropdown when adding a new product.
|
||||
</div>
|
||||
|
||||
{data.shops.length === 0 ? (
|
||||
<Card style={{ padding: 60, textAlign: "center" }}>
|
||||
<div className="serif" style={{ fontSize: 22, marginBottom: 6 }}>No shops yet</div>
|
||||
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
|
||||
Add a shop to start logging where each purchase came from.
|
||||
</div>
|
||||
<Btn variant="primary" icon="plus" onClick={onAddShop}>Add your first shop</Btn>
|
||||
</Card>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{data.shops.map((s) => {
|
||||
const count = data.products.filter((p) => p.shopId === s.id).length;
|
||||
return (
|
||||
<Card key={s.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1.1 }}>
|
||||
{s.name}
|
||||
</div>
|
||||
{s.location && (
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 4 }}>
|
||||
{s.location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Pill tone="outline">
|
||||
{count} purchase{count === 1 ? "" : "s"}
|
||||
</Pill>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user