Initial commit: Apothecary v0.4.0
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Apothecary — Personal Inventory</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1795
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "apothecary-web",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "./api.js";
|
||||
import type { Bin, Bootstrap, Product } from "./types.js";
|
||||
import { computeStats } from "./stats.js";
|
||||
import { Sidebar } from "./components/Sidebar.js";
|
||||
import type { ViewKey } from "./components/Sidebar.js";
|
||||
import { Dashboard } from "./views/Dashboard.js";
|
||||
import { Inventory } from "./views/Inventory.js";
|
||||
import { BinsView } from "./views/BinsView.js";
|
||||
import { BrandsView } from "./views/BrandsView.js";
|
||||
import { ShopsView } from "./views/ShopsView.js";
|
||||
import { ChartsView } from "./views/ChartsView.js";
|
||||
import { SettingsView } from "./views/SettingsView.js";
|
||||
import type { ThemeKey } from "./views/SettingsView.js";
|
||||
import { ProductDetail } from "./components/ProductDetail.js";
|
||||
import { AddProductFlow } from "./components/modals/AddProductFlow.js";
|
||||
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
|
||||
import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js";
|
||||
import { AuditFlow } from "./components/modals/AuditFlow.js";
|
||||
import {
|
||||
AddBinModal,
|
||||
AddBrandModal,
|
||||
AddShopModal,
|
||||
EditBinModal,
|
||||
} from "./components/modals/CatalogModals.js";
|
||||
|
||||
type ModalKey =
|
||||
| "add"
|
||||
| "consume"
|
||||
| "gone"
|
||||
| "audit"
|
||||
| "addBrand"
|
||||
| "addShop"
|
||||
| "addBin"
|
||||
| "editBin"
|
||||
| null;
|
||||
|
||||
export function App() {
|
||||
const [view, setView] = useState<ViewKey>("dashboard");
|
||||
const [selected, setSelected] = useState<Product | null>(null);
|
||||
const [modal, setModal] = useState<ModalKey>(null);
|
||||
const [modalProduct, setModalProduct] = useState<Product | null>(null);
|
||||
const [modalBin, setModalBin] = useState<Bin | null>(null);
|
||||
|
||||
const [theme, setTheme] = useState<ThemeKey>(
|
||||
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem("apothecary.theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const { data, isLoading, error } = useQuery<Bootstrap>({
|
||||
queryKey: ["bootstrap"],
|
||||
queryFn: api.bootstrap,
|
||||
});
|
||||
|
||||
const stats = useMemo(() => (data ? computeStats(data) : null), [data]);
|
||||
|
||||
// Re-sync the selected product reference whenever bootstrap refetches
|
||||
// — otherwise the drawer keeps showing stale audit history after a
|
||||
// mutation invalidates the cache.
|
||||
useEffect(() => {
|
||||
if (selected && data) {
|
||||
const fresh = data.products.find((p) => p.id === selected.id);
|
||||
if (fresh && fresh !== selected) setSelected(fresh);
|
||||
}
|
||||
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const openAdd = () => {
|
||||
setModalProduct(null);
|
||||
setModal("add");
|
||||
};
|
||||
const openConsume = (p?: Product) => {
|
||||
setModalProduct(p ?? null);
|
||||
setSelected(null);
|
||||
setModal("consume");
|
||||
};
|
||||
const openMarkGone = (p?: Product) => {
|
||||
setModalProduct(p ?? null);
|
||||
setSelected(null);
|
||||
setModal("gone");
|
||||
};
|
||||
const openAudit = (p?: Product) => {
|
||||
setModalProduct(p ?? null);
|
||||
setModal("audit");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="parchment" style={{ padding: 60, color: "var(--ink-3)" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error || !data || !stats) {
|
||||
return (
|
||||
<div className="parchment" style={{ padding: 60, color: "var(--terracotta)" }}>
|
||||
Failed to load: {String(error ?? "no data")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell" data-screen-label="App">
|
||||
<Sidebar
|
||||
view={view}
|
||||
onNav={setView}
|
||||
onAddProduct={openAdd}
|
||||
onMarkFinished={() => openConsume()}
|
||||
onAudit={() => openAudit()}
|
||||
/>
|
||||
|
||||
<main className="main parchment" style={{ minWidth: 0 }}>
|
||||
{view === "dashboard" && (
|
||||
<Dashboard
|
||||
data={data}
|
||||
stats={stats}
|
||||
onAuditProduct={openAudit}
|
||||
onSelectProduct={setSelected}
|
||||
/>
|
||||
)}
|
||||
{view === "inventory" && (
|
||||
<Inventory
|
||||
data={data}
|
||||
onSelectProduct={setSelected}
|
||||
onAddProduct={openAdd}
|
||||
onAuditNew={() => openAudit()}
|
||||
/>
|
||||
)}
|
||||
{view === "bins" && (
|
||||
<BinsView
|
||||
data={data}
|
||||
onSelectProduct={setSelected}
|
||||
onAddBin={() => setModal("addBin")}
|
||||
onEditBin={(bin) => {
|
||||
setModalBin(bin);
|
||||
setModal("editBin");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{view === "shops" && (
|
||||
<ShopsView data={data} onAddShop={() => setModal("addShop")} />
|
||||
)}
|
||||
{view === "brands" && (
|
||||
<BrandsView data={data} onAddBrand={() => setModal("addBrand")} />
|
||||
)}
|
||||
{view === "charts" && <ChartsView data={data} stats={stats} />}
|
||||
{view === "settings" && (
|
||||
<SettingsView data={data} theme={theme} onThemeChange={setTheme} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{selected && (
|
||||
<ProductDetail
|
||||
product={selected}
|
||||
data={data}
|
||||
onClose={() => setSelected(null)}
|
||||
onConsume={openConsume}
|
||||
onMarkGone={openMarkGone}
|
||||
onAudit={openAudit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modal === "add" && <AddProductFlow data={data} onClose={() => setModal(null)} />}
|
||||
{modal === "consume" && (
|
||||
<ConsumeFlow data={data} onClose={() => setModal(null)} product={modalProduct} />
|
||||
)}
|
||||
{modal === "gone" && (
|
||||
<MarkGoneFlow data={data} onClose={() => setModal(null)} product={modalProduct} />
|
||||
)}
|
||||
{modal === "audit" && (
|
||||
<AuditFlow data={data} onClose={() => setModal(null)} product={modalProduct} />
|
||||
)}
|
||||
{modal === "addBrand" && <AddBrandModal onClose={() => setModal(null)} />}
|
||||
{modal === "addShop" && <AddShopModal onClose={() => setModal(null)} />}
|
||||
{modal === "addBin" && <AddBinModal onClose={() => setModal(null)} />}
|
||||
{modal === "editBin" && modalBin && (
|
||||
<EditBinModal bin={modalBin} onClose={() => setModal(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Bootstrap, AuditMode } from "./types.js";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status} ${res.statusText}: ${text}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
bootstrap: () => request<Bootstrap>("/bootstrap"),
|
||||
|
||||
createProduct: (body: {
|
||||
name: string;
|
||||
brandId: string;
|
||||
shopId: string;
|
||||
binId: string;
|
||||
type: string;
|
||||
kind: "bulk" | "discrete";
|
||||
weight?: number;
|
||||
countOriginal?: number;
|
||||
unitWeight?: number;
|
||||
price: number;
|
||||
thc: number;
|
||||
cbd: number;
|
||||
totalCannabinoids: number;
|
||||
purchaseDate: string;
|
||||
sku?: string;
|
||||
assetTag?: string;
|
||||
}) => request<{ id: string }>("/products", { method: "POST", body: JSON.stringify(body) }),
|
||||
|
||||
finishProduct: (id: string, body: { date: string; rating?: number; notes?: string }) =>
|
||||
request<{ ok: true }>(`/products/${id}/finish`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
markGone: (id: string, body: { date: string; reason: string; notes?: string }) =>
|
||||
request<{ ok: true }>(`/products/${id}/gone`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
auditProduct: (
|
||||
id: string,
|
||||
body: { date: string; mode: AuditMode; value: number; confirmedBy?: string },
|
||||
) =>
|
||||
request<{ ok: true }>(`/products/${id}/audit`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
createBrand: (name: string) =>
|
||||
request<{ id: string; name: string }>("/brands", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
createShop: (body: { name: string; location?: string }) =>
|
||||
request<{ id: string; name: string; location: string | null }>("/shops", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
createBin: (body: { name: string; location?: string; capacity?: number }) =>
|
||||
request<{ id: string; name: string; location: string | null; capacity: number }>("/bins", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
updateBin: (
|
||||
id: string,
|
||||
body: { name?: string; location?: string | null; capacity?: number },
|
||||
) =>
|
||||
request<{ id: string; name: string; location: string | null; capacity: number }>(
|
||||
`/bins/${id}`,
|
||||
{ method: "PATCH", body: JSON.stringify(body) },
|
||||
),
|
||||
|
||||
deleteBin: (id: string) =>
|
||||
request<{ ok: true }>(`/bins/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Product } from "../types.js";
|
||||
import { Icon, Field, inputStyle } from "./primitives/index.js";
|
||||
|
||||
// Scan-friendly product picker. Auto-focuses on mount so a barcode scanner
|
||||
// can fire immediately. Exact (case-insensitive) match against sku or
|
||||
// assetTag → calls onMatch and clears itself for the next scan. Falls back
|
||||
// to a "no match" hint when the value doesn't resolve.
|
||||
export function ScanField({
|
||||
products,
|
||||
onMatch,
|
||||
matchedProduct,
|
||||
}: {
|
||||
products: Product[];
|
||||
onMatch: (productId: string) => void;
|
||||
matchedProduct: Product | null;
|
||||
}) {
|
||||
const [scan, setScan] = useState("");
|
||||
const [feedback, setFeedback] = useState<{ type: "matched" | "miss"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = scan.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
setFeedback(null);
|
||||
return;
|
||||
}
|
||||
const match = products.find(
|
||||
(p) =>
|
||||
p.sku.toLowerCase() === trimmed ||
|
||||
(p.assetTag != null && p.assetTag.toLowerCase() === trimmed),
|
||||
);
|
||||
if (match) {
|
||||
onMatch(match.id);
|
||||
setScan("");
|
||||
setFeedback({ type: "matched", text: `Matched ${match.name}` });
|
||||
}
|
||||
}, [scan]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Show "no match" only after the user has stopped typing for a beat —
|
||||
// avoids flashing "no match" mid-type while a multi-char SKU is entered.
|
||||
useEffect(() => {
|
||||
if (!scan.trim() || feedback?.type === "matched") return;
|
||||
const timer = setTimeout(() => {
|
||||
const trimmed = scan.trim().toLowerCase();
|
||||
const match = products.find(
|
||||
(p) =>
|
||||
p.sku.toLowerCase() === trimmed ||
|
||||
(p.assetTag != null && p.assetTag.toLowerCase() === trimmed),
|
||||
);
|
||||
if (!match) setFeedback({ type: "miss", text: "No SKU or asset tag matches that." });
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [scan, products]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Field label="Scan SKU or asset tag" hint="Or pick from the list below.">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
...inputStyle,
|
||||
padding: "0 12px",
|
||||
}}
|
||||
>
|
||||
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||
<input
|
||||
autoFocus
|
||||
value={scan}
|
||||
onChange={(e) => setScan(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
placeholder="SKU-XXXXXX or AT-0000"
|
||||
style={{
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
padding: "10px 0",
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
color: "var(--ink)",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
/>
|
||||
{matchedProduct && !scan && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--sage)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
✓ {matchedProduct.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{feedback && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: feedback.type === "matched" ? "var(--sage)" : "var(--terracotta)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{feedback.text}
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Icon } from "./primitives/index.js";
|
||||
|
||||
export type ViewKey =
|
||||
| "dashboard"
|
||||
| "inventory"
|
||||
| "bins"
|
||||
| "shops"
|
||||
| "brands"
|
||||
| "charts"
|
||||
| "settings";
|
||||
|
||||
const NAV: { key: ViewKey; label: string; icon: string }[] = [
|
||||
{ key: "dashboard", label: "Dashboard", icon: "home" },
|
||||
{ key: "inventory", label: "Inventory", icon: "box" },
|
||||
{ key: "bins", label: "Bins", icon: "bin" },
|
||||
{ key: "shops", label: "Shops", icon: "shop" },
|
||||
{ key: "brands", label: "Brands", icon: "tag" },
|
||||
{ key: "charts", label: "Patterns", icon: "chart" },
|
||||
{ key: "settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
const BRAND = "Apothecary";
|
||||
|
||||
const TAGLINES = [
|
||||
"Strictly medicinal",
|
||||
"A discreet inventory",
|
||||
"A herbalist's ledger",
|
||||
"A modest dispensary",
|
||||
"Materia medica",
|
||||
"A green pharmacopeia",
|
||||
"Tinctures & ephemera",
|
||||
"Tended with care",
|
||||
"By the gram",
|
||||
];
|
||||
|
||||
// Picked once at module load — rerolls on a full page refresh, but stays
|
||||
// stable across React re-renders within the same session.
|
||||
const TAGLINE = TAGLINES[Math.floor(Math.random() * TAGLINES.length)]!;
|
||||
|
||||
export function Sidebar({
|
||||
view,
|
||||
onNav,
|
||||
onAddProduct,
|
||||
onMarkFinished,
|
||||
onAudit,
|
||||
}: {
|
||||
view: ViewKey;
|
||||
onNav: (k: ViewKey) => void;
|
||||
onAddProduct: () => void;
|
||||
onMarkFinished: () => void;
|
||||
onAudit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">A</div>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1 }}>
|
||||
{BRAND}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--ink-3)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.1em",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{TAGLINE}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-section">Workspace</div>
|
||||
{NAV.map((n) => (
|
||||
<button
|
||||
key={n.key}
|
||||
className={"nav-link " + (view === n.key ? "active" : "")}
|
||||
onClick={() => onNav(n.key)}
|
||||
>
|
||||
<Icon name={n.icon} size={16} />
|
||||
{n.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="nav-section">Quick</div>
|
||||
<button className="nav-link" onClick={onAddProduct}>
|
||||
<Icon name="plus" size={16} /> Add product
|
||||
</button>
|
||||
<button className="nav-link" onClick={onAudit}>
|
||||
<Icon name="search" size={16} /> Audit
|
||||
</button>
|
||||
<button className="nav-link" onClick={onMarkFinished}>
|
||||
<Icon name="check" size={16} /> Mark consumed
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Strain } from "../../types.js";
|
||||
import { TYPES, TODAY_STR } from "../../types.js";
|
||||
import { fmt } from "../../format.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||
|
||||
const NEW_BRAND = "__new_brand__";
|
||||
const NEW_SHOP = "__new_shop__";
|
||||
const NEW_BIN = "__new_bin__";
|
||||
|
||||
export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
brandId: data.brands[0]?.id ?? NEW_BRAND,
|
||||
shopId: data.shops[0]?.id ?? NEW_SHOP,
|
||||
type: "Flower",
|
||||
weight: 3.5,
|
||||
countOriginal: 1,
|
||||
unitWeight: 0.7,
|
||||
price: 45,
|
||||
thc: 22,
|
||||
cbd: 0.4,
|
||||
totalCannabinoids: 26,
|
||||
purchaseDate: TODAY_STR,
|
||||
binId: data.bins[0]?.id ?? NEW_BIN,
|
||||
sku: "",
|
||||
assetTag: "",
|
||||
});
|
||||
const [newBrand, setNewBrand] = useState("");
|
||||
const [newShopName, setNewShopName] = useState("");
|
||||
const [newShopLocation, setNewShopLocation] = useState("");
|
||||
const [newBinName, setNewBinName] = useState("");
|
||||
const [newBinLocation, setNewBinLocation] = useState("");
|
||||
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Track which cannabinoid fields the user has touched. Pre-fill from a
|
||||
// matched strain only writes into untouched fields, so we never overwrite
|
||||
// numbers the user just typed.
|
||||
const [edited, setEdited] = useState({ thc: false, cbd: false, total: false });
|
||||
|
||||
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const cfg = TYPES.find((t) => t.id === form.type);
|
||||
const isDiscrete = cfg?.kind === "discrete";
|
||||
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
|
||||
|
||||
// Find an existing strain matching the current name + brand + type.
|
||||
// Case-insensitive + trimmed, brand can be NEW_BRAND (no match) or null.
|
||||
const matchedStrain: Strain | null = useMemo(() => {
|
||||
const name = form.name.trim().toLowerCase();
|
||||
if (!name) return null;
|
||||
if (form.brandId === NEW_BRAND) return null;
|
||||
return (
|
||||
data.strains.find(
|
||||
(s) =>
|
||||
s.name.trim().toLowerCase() === name &&
|
||||
(s.brandId ?? null) === (form.brandId ?? null) &&
|
||||
s.type === form.type,
|
||||
) ?? null
|
||||
);
|
||||
}, [data.strains, form.name, form.brandId, form.type]);
|
||||
|
||||
// Pre-fill cannabinoids from the matched strain into untouched fields.
|
||||
useEffect(() => {
|
||||
if (!matchedStrain) return;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
thc: edited.thc ? f.thc : matchedStrain.defaultThc ?? f.thc,
|
||||
cbd: edited.cbd ? f.cbd : matchedStrain.defaultCbd ?? f.cbd,
|
||||
totalCannabinoids: edited.total
|
||||
? f.totalCannabinoids
|
||||
: matchedStrain.defaultTotalCannabinoids ?? f.totalCannabinoids,
|
||||
}));
|
||||
}, [matchedStrain]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
let { brandId, shopId, binId } = form;
|
||||
if (brandId === NEW_BRAND) {
|
||||
if (!newBrand.trim()) throw new Error("New brand name required");
|
||||
const b = await api.createBrand(newBrand.trim());
|
||||
brandId = b.id;
|
||||
}
|
||||
if (shopId === NEW_SHOP) {
|
||||
if (!newShopName.trim()) throw new Error("New shop name required");
|
||||
const s = await api.createShop({ name: newShopName.trim(), location: newShopLocation.trim() });
|
||||
shopId = s.id;
|
||||
}
|
||||
if (binId === NEW_BIN) {
|
||||
if (!newBinName.trim()) throw new Error("New bin name required");
|
||||
const b = await api.createBin({
|
||||
name: newBinName.trim(),
|
||||
location: newBinLocation.trim(),
|
||||
capacity: newBinCapacity,
|
||||
});
|
||||
binId = b.id;
|
||||
}
|
||||
return api.createProduct({
|
||||
...form,
|
||||
brandId,
|
||||
shopId,
|
||||
binId,
|
||||
kind: isDiscrete ? "discrete" : "bulk",
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const isNewBrand = form.brandId === NEW_BRAND;
|
||||
const isNewShop = form.shopId === NEW_SHOP;
|
||||
const isNewBin = form.binId === NEW_BIN;
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(840px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title="Add a product" eyebrow="New entry" onClose={onClose} />
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>Identity</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28 }}>
|
||||
<Field
|
||||
label="Strain"
|
||||
span={2}
|
||||
hint={
|
||||
matchedStrain
|
||||
? "Matched existing strain — cannabinoid defaults pre-filled."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={form.name}
|
||||
placeholder="e.g. Garden Ghost"
|
||||
onChange={(e) => update("name", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Brand">
|
||||
<Select value={form.brandId} onChange={(e) => update("brandId", e.target.value)}>
|
||||
{data.brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
<option value={NEW_BRAND}>+ Add new brand…</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Shop">
|
||||
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
|
||||
{data.shops.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
<option value={NEW_SHOP}>+ Add new shop…</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{isNewBrand && (
|
||||
<Field label="New brand name" span={2}>
|
||||
<Input value={newBrand} onChange={(e) => setNewBrand(e.target.value)} placeholder="e.g. Foxglove Farms" />
|
||||
</Field>
|
||||
)}
|
||||
{isNewShop && (
|
||||
<>
|
||||
<Field label="New shop name">
|
||||
<Input value={newShopName} onChange={(e) => setNewShopName(e.target.value)} placeholder="e.g. Greenleaf Co-op" />
|
||||
</Field>
|
||||
<Field label="Location (optional)">
|
||||
<Input
|
||||
value={newShopLocation}
|
||||
onChange={(e) => setNewShopLocation(e.target.value)}
|
||||
placeholder="e.g. Capitol Hill"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="Type">
|
||||
<Select value={form.type} onChange={(e) => update("type", e.target.value)}>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.id} ({t.kind})</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Bin">
|
||||
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
|
||||
{data.bins.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name} — {b.location}</option>
|
||||
))}
|
||||
<option value={NEW_BIN}>+ Add new bin…</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{isNewBin && (
|
||||
<>
|
||||
<Field label="New bin name">
|
||||
<Input
|
||||
value={newBinName}
|
||||
onChange={(e) => setNewBinName(e.target.value)}
|
||||
placeholder="e.g. Top Drawer"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Location (optional)">
|
||||
<Input
|
||||
value={newBinLocation}
|
||||
onChange={(e) => setNewBinLocation(e.target.value)}
|
||||
placeholder="e.g. Bedroom"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Capacity">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={newBinCapacity}
|
||||
onChange={(e) =>
|
||||
setNewBinCapacity(Math.max(1, Math.floor(+e.target.value || 1)))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="SKU" hint="Leave blank — we'll generate one">
|
||||
<Input value={form.sku} placeholder="SKU-…" onChange={(e) => update("sku", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Asset tag (optional)" hint="If you've physically tagged the item">
|
||||
<Input
|
||||
value={form.assetTag}
|
||||
placeholder="AT-0000"
|
||||
onChange={(e) => update("assetTag", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>Acquisition</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 8 }}>
|
||||
{isDiscrete ? (
|
||||
<>
|
||||
<Field label={`Quantity (${cfg!.unit})`}>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={form.countOriginal}
|
||||
onChange={(e) => update("countOriginal", +e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Per-unit weight (g)" hint="For grams stats">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={form.unitWeight}
|
||||
onChange={(e) => update("unitWeight", +e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={form.weight}
|
||||
onChange={(e) => update("weight", +e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Price ($)">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.price}
|
||||
onChange={(e) => update("price", +e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Purchase date">
|
||||
<Input
|
||||
type="date"
|
||||
value={form.purchaseDate}
|
||||
onChange={(e) => update("purchaseDate", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!isDiscrete && cpg > 0 && (
|
||||
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
|
||||
Cost per {cfg?.unit ?? "g"}:{" "}
|
||||
<span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.money(cpg)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}>Cannabinoid profile</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
|
||||
<Field label="THC %">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={form.thc}
|
||||
onChange={(e) => {
|
||||
setEdited((p) => ({ ...p, thc: true }));
|
||||
update("thc", +e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="CBD %">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={form.cbd}
|
||||
onChange={(e) => {
|
||||
setEdited((p) => ({ ...p, cbd: true }));
|
||||
update("cbd", +e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Total cannabinoids %">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={form.totalCannabinoids}
|
||||
onChange={(e) => {
|
||||
setEdited((p) => ({ ...p, total: true }));
|
||||
update("totalCannabinoids", +e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{form.name
|
||||
? `"${form.name}" → ${
|
||||
isNewBin
|
||||
? newBinName.trim() || "new bin"
|
||||
: data.bins.find((b) => b.id === form.binId)?.name ?? "—"
|
||||
}.`
|
||||
: "Fill in the name to continue."}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={!form.name || save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? "Saving…" : "Save product"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared modal chrome ──────────────────────────────────────────
|
||||
export function ModalBackdrop({
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "oklch(20% 0.02 60 / 0.4)",
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-start",
|
||||
overflow: "auto",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: "100%", display: "flex", justifyContent: "center" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalHeader({
|
||||
title,
|
||||
eyebrow,
|
||||
eyebrowColor,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
eyebrowColor?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 32px",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: eyebrowColor ?? "var(--ink-3)" }}>
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="serif" style={{ fontSize: 28, margin: "4px 0 0", fontWeight: 500 }}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalFooter({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 32px",
|
||||
borderTop: "1px solid var(--line)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
background: "var(--bg-2)",
|
||||
borderRadius: "0 0 var(--r-lg) var(--r-lg)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Product } from "../../types.js";
|
||||
import { TYPES, helpers, TODAY_STR } from "../../types.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||
import { ScanField } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
|
||||
const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
|
||||
weigh: {
|
||||
title: "Reweigh on a scale",
|
||||
desc: "Place the jar (minus tare) and record the new weight.",
|
||||
},
|
||||
estimate: {
|
||||
title: "Visual estimate",
|
||||
desc: "Eyeball the remaining amount — quick and approximate.",
|
||||
},
|
||||
presence: {
|
||||
title: "Confirm presence",
|
||||
desc: "Verify the item is still where you left it. Count units if applicable.",
|
||||
},
|
||||
};
|
||||
|
||||
export function AuditFlow({
|
||||
data,
|
||||
onClose,
|
||||
product: initialProduct,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const overdueFirst = [...data.products]
|
||||
.filter((p) => p.status === "active")
|
||||
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
|
||||
|
||||
const [productId, setProductId] = useState(initialProduct?.id ?? overdueFirst[0]?.id ?? "");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const [confirmedBy, setConfirmedBy] = useState<"SKU" | "asset" | "visual">("SKU");
|
||||
|
||||
const product = data.products.find((p) => p.id === productId);
|
||||
const cfg = product ? TYPES.find((t) => t.id === product.type) : undefined;
|
||||
|
||||
const initialValueFor = (p: Product | undefined): string => {
|
||||
if (!p) return "0";
|
||||
if (p.kind === "discrete") {
|
||||
return String(p.countLastAudit ?? p.countOriginal);
|
||||
}
|
||||
return helpers.estimatedRemaining(p, TODAY_STR).toFixed(2);
|
||||
};
|
||||
const [value, setValue] = useState<string>(initialValueFor(product));
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValueFor(product));
|
||||
}, [productId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const audit = useMutation({
|
||||
mutationFn: () =>
|
||||
api.auditProduct(productId, {
|
||||
date,
|
||||
mode: cfg?.auditMode ?? "weigh",
|
||||
value: Number(value),
|
||||
confirmedBy: cfg?.auditMode === "presence" ? confirmedBy : undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) return null;
|
||||
const auditMode = cfg?.auditMode ?? "weigh";
|
||||
const ml = AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.weigh!;
|
||||
|
||||
const last = helpers.lastAudit(product);
|
||||
const prevValue =
|
||||
product.kind === "discrete"
|
||||
? product.countLastAudit ?? product.countOriginal
|
||||
: last
|
||||
? last.value
|
||||
: product.weight;
|
||||
|
||||
const delta = Number(value) - prevValue;
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(720px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title={ml.title} eyebrow="Audit" onClose={onClose} />
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<ScanField
|
||||
products={overdueFirst}
|
||||
matchedProduct={product ?? null}
|
||||
onMatch={setProductId}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Or pick from list">
|
||||
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
{overdueFirst.map((p) => {
|
||||
const od = helpers.auditOverdue(p);
|
||||
const sc = helpers.daysSinceCheck(p);
|
||||
return (
|
||||
<option key={p.id} value={p.id}>
|
||||
{od ? "⚠ " : ""}
|
||||
{p.name} — {helpers.brandName(data, p.brandId)} · {sc}d since check
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 20, fontWeight: 500 }}>
|
||||
{product.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LAST CHECKED</div>
|
||||
<div className="serif" style={{ fontSize: 18 }}>
|
||||
{last ? `${helpers.daysSinceCheck(product)}d ago` : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 10, fontStyle: "italic" }}>
|
||||
{ml.desc}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr",
|
||||
gap: 16,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label={
|
||||
product.kind === "discrete"
|
||||
? `Count now (${cfg?.unit})`
|
||||
: auditMode === "weigh"
|
||||
? `Weight now (${cfg?.unit})`
|
||||
: `Estimate now (${cfg?.unit})`
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
step={product.kind === "discrete" ? "1" : "0.1"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</Field>
|
||||
{auditMode === "presence" && (
|
||||
<Field label="Confirmed by">
|
||||
<Select
|
||||
value={confirmedBy}
|
||||
onChange={(e) => setConfirmedBy(e.target.value as typeof confirmedBy)}
|
||||
>
|
||||
<option value="SKU">SKU label</option>
|
||||
<option value="asset">Asset tag</option>
|
||||
<option value="visual">Visual ID</option>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 20,
|
||||
padding: 14,
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Was</div>
|
||||
<div className="serif" style={{ fontSize: 22 }}>
|
||||
{prevValue} {cfg?.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
|
||||
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}>
|
||||
{value} {cfg?.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Δ since last</div>
|
||||
<div
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: 22,
|
||||
color: delta < 0 ? "var(--terracotta)" : "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{delta.toFixed(product.kind === "discrete" ? 0 : 2)} {cfg?.unit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
Next audit due in {cfg?.cadenceDays}d
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={audit.isPending}
|
||||
onClick={() => audit.mutate()}
|
||||
>
|
||||
{audit.isPending ? "Saving…" : "Save audit"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input } from "../primitives/index.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
|
||||
export function AddBrandModal({ onClose }: { onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
const create = useMutation({
|
||||
mutationFn: () => api.createBrand(name.trim()),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(480px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title="Add a brand" eyebrow="Catalog" onClose={onClose} />
|
||||
<div style={{ padding: 32 }}>
|
||||
<Field label="Brand name">
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Foxglove Farms"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={!name.trim() || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
{create.isPending ? "Saving…" : "Add brand"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditBinModal({
|
||||
bin,
|
||||
onClose,
|
||||
}: {
|
||||
bin: { id: string; name: string; location: string | null; capacity: number };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(bin.name);
|
||||
const [location, setLocation] = useState(bin.location ?? "");
|
||||
const [capacity, setCapacity] = useState(bin.capacity);
|
||||
const update = useMutation({
|
||||
mutationFn: () =>
|
||||
api.updateBin(bin.id, { name: name.trim(), location: location.trim(), capacity }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(560px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title="Edit bin" eyebrow="Storage" onClose={onClose} />
|
||||
<div style={{ padding: 32, display: "grid", gap: 16 }}>
|
||||
<Field label="Bin name">
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Top Drawer"
|
||||
/>
|
||||
</Field>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
|
||||
<Field label="Location (optional)">
|
||||
<Input
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="e.g. Bedroom"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Capacity">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={!name.trim() || update.isPending}
|
||||
onClick={() => update.mutate()}
|
||||
>
|
||||
{update.isPending ? "Saving…" : "Save changes"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddBinModal({ onClose }: { onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [capacity, setCapacity] = useState(10);
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
api.createBin({ name: name.trim(), location: location.trim(), capacity }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(560px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title="Add a bin" eyebrow="Storage" onClose={onClose} />
|
||||
<div style={{ padding: 32, display: "grid", gap: 16 }}>
|
||||
<Field label="Bin name">
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Top Drawer"
|
||||
/>
|
||||
</Field>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
|
||||
<Field label="Location (optional)">
|
||||
<Input
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="e.g. Bedroom"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Capacity">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={!name.trim() || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
{create.isPending ? "Saving…" : "Add bin"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddShopModal({ onClose }: { onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const create = useMutation({
|
||||
mutationFn: () => api.createShop({ name: name.trim(), location: location.trim() }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(560px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title="Add a shop" eyebrow="Catalog" onClose={onClose} />
|
||||
<div style={{ padding: 32, display: "grid", gap: 16 }}>
|
||||
<Field label="Shop name">
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Greenleaf Co-op"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Location (optional)">
|
||||
<Input
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="e.g. Capitol Hill"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={!name.trim() || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
{create.isPending ? "Saving…" : "Add shop"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Product } from "../../types.js";
|
||||
import { helpers, TODAY_STR } from "../../types.js";
|
||||
import { remainingShort } from "../../stats.js";
|
||||
import { fmt } from "../../format.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js";
|
||||
import { ScanField } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
|
||||
export function ConsumeFlow({
|
||||
data,
|
||||
onClose,
|
||||
product: initialProduct,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const active = data.products.filter((p) => p.status === "active");
|
||||
const [productId, setProductId] = useState(initialProduct?.id ?? active[0]?.id ?? "");
|
||||
const [rating, setRating] = useState(4);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
|
||||
const product = data.products.find((p) => p.id === productId);
|
||||
|
||||
const finish = useMutation({
|
||||
mutationFn: () => api.finishProduct(productId, { date, rating, notes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) return null;
|
||||
const bin = data.bins.find((b) => b.id === product.binId);
|
||||
const lifespan = Math.round((+new Date(date) - +new Date(product.purchaseDate)) / 86_400_000);
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(720px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title="Mark as consumed" eyebrow="Archive · used up" onClose={onClose} />
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<ScanField
|
||||
products={active}
|
||||
matchedProduct={product ?? null}
|
||||
onMatch={setProductId}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Or pick from list">
|
||||
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
{active.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} — {helpers.brandName(data, p.brandId)} ({remainingShort(p)} left)
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||
{product.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{helpers.brandName(data, product.brandId)} · {bin?.name} · purchased{" "}
|
||||
{fmt.dateShort(product.purchaseDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LASTED</div>
|
||||
<div className="serif" style={{ fontSize: 24 }}>{lifespan} days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24 }}>
|
||||
<Field label="Date finished">
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Rating">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
alignItems: "center",
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setRating(n)}
|
||||
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
|
||||
>
|
||||
<Icon
|
||||
name="star"
|
||||
size={20}
|
||||
color={n <= rating ? "var(--amber)" : "var(--ink-4)"}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
fontSize: 12,
|
||||
color: "var(--ink-3)",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
>
|
||||
{rating}/5
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Final notes" hint="Flavor, effects, would you rebuy">
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="What stood out?"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={finish.isPending}
|
||||
onClick={() => finish.mutate()}
|
||||
>
|
||||
{finish.isPending ? "Saving…" : "Mark consumed"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Product } from "../../types.js";
|
||||
import { helpers, TODAY_STR } from "../../types.js";
|
||||
import { remainingShort } from "../../stats.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
|
||||
const REASONS: [string, string][] = [
|
||||
["lost", "Lost / misplaced"],
|
||||
["damaged", "Damaged"],
|
||||
["expired", "Expired"],
|
||||
["gifted", "Gifted away"],
|
||||
["other", "Other"],
|
||||
];
|
||||
|
||||
export function MarkGoneFlow({
|
||||
data,
|
||||
onClose,
|
||||
product: initialProduct,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const active = data.products.filter((p) => p.status === "active");
|
||||
const [productId, setProductId] = useState(initialProduct?.id ?? active[0]?.id ?? "");
|
||||
const [reason, setReason] = useState("lost");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const product = data.products.find((p) => p.id === productId);
|
||||
|
||||
const mark = useMutation({
|
||||
mutationFn: () => api.markGone(productId, { date, reason, notes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(640px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader
|
||||
title="Mark as gone"
|
||||
eyebrow="Archive · not consumed"
|
||||
eyebrowColor="var(--terracotta)"
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--ink-2)",
|
||||
marginBottom: 20,
|
||||
padding: 14,
|
||||
background: "var(--amber-soft)",
|
||||
borderRadius: "var(--r-md)",
|
||||
}}
|
||||
>
|
||||
Use this when an item is lost, damaged, expired, or gifted away. Counts as{" "}
|
||||
<strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
|
||||
</div>
|
||||
|
||||
<Field label="Product">
|
||||
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
{active.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} — {helpers.brandName(data, p.brandId)} ({remainingShort(p)} left)
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 16 }}>
|
||||
<Field label="Reason">
|
||||
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
||||
{REASONS.map(([k, l]) => (
|
||||
<option key={k} value={k}>{l}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Notes (optional)" hint="What happened">
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="e.g. Pack went through the wash"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn variant="danger" icon="bin" disabled={mark.isPending} onClick={() => mark.mutate()}>
|
||||
{mark.isPending ? "Saving…" : "Mark gone"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import type { CSSProperties, ReactNode, ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||
|
||||
// ─── Icons ─────────────────────────────────────────────────────────
|
||||
const ICON_PATHS: Record<string, string> = {
|
||||
home: "M3 11l9-8 9 8M5 9v12h5v-7h4v7h5V9",
|
||||
box: "M3 7l9-4 9 4v10l-9 4-9-4V7zM3 7l9 4 9-4M12 11v10",
|
||||
chart: "M3 21V3M3 21h18M7 17v-7M11 17v-4M15 17v-9M19 17v-2",
|
||||
plus: "M12 5v14M5 12h14",
|
||||
check: "M5 13l4 4L19 7",
|
||||
settings:
|
||||
"M12 3v3M12 18v3M5 5l2 2M17 17l2 2M3 12h3M18 12h3M5 19l2-2M17 7l2-2M12 8a4 4 0 100 8 4 4 0 000-8z",
|
||||
search: "M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4-4",
|
||||
filter: "M3 5h18M6 12h12M10 19h4",
|
||||
bin: "M4 7h16M9 7V4h6v3M6 7v13h12V7",
|
||||
leaf: "M5 19c0-7 5-14 14-14 0 9-5 14-14 14zM5 19l7-7",
|
||||
flame: "M12 3c1 4 4 5 4 9a4 4 0 11-8 0c0-2 2-3 2-6 0-1 1-2 2-3z",
|
||||
droplet: "M12 3l5 7a5 5 0 11-10 0l5-7z",
|
||||
arrow: "M5 12h14M13 5l7 7-7 7",
|
||||
arrowDown: "M12 5v14M5 13l7 7 7-7",
|
||||
close: "M6 6l12 12M18 6L6 18",
|
||||
edit: "M4 20h4l10-10-4-4L4 16v4zM14 6l4 4",
|
||||
star: "M12 3l3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z",
|
||||
calendar: "M5 5h14v15H5zM3 10h18M9 3v4M15 3v4",
|
||||
tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01",
|
||||
shop: "M4 9V4h16v5M4 9v11h16V9M4 9h16M10 20v-6h4v6",
|
||||
};
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 18,
|
||||
color = "currentColor",
|
||||
}: {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<path d={ICON_PATHS[name] ?? ""} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sparkline / BarChart / Donut ──────────────────────────────────
|
||||
export function Sparkline({
|
||||
values,
|
||||
width = 240,
|
||||
height = 32,
|
||||
color = "var(--ink)",
|
||||
fill = false,
|
||||
}: {
|
||||
values: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
fill?: boolean;
|
||||
}) {
|
||||
if (!values.length) return null;
|
||||
const max = Math.max(...values, 0.001);
|
||||
const min = Math.min(...values, 0);
|
||||
const span = max - min || 1;
|
||||
const step = width / (values.length - 1 || 1);
|
||||
const pts = values.map((v, i) => [i * step, height - ((v - min) / span) * (height - 4) - 2] as const);
|
||||
const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");
|
||||
const fillPath = fill ? path + ` L ${width} ${height} L 0 ${height} Z` : null;
|
||||
// width prop defines the path's coordinate space; the rendered SVG stretches
|
||||
// to fill its container so sparklines grow with their stat card.
|
||||
return (
|
||||
<svg
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
style={{ display: "block", overflow: "visible" }}
|
||||
>
|
||||
{fillPath && <path d={fillPath} fill={color} opacity="0.12" vectorEffect="non-scaling-stroke" />}
|
||||
<path
|
||||
d={path}
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
height = 160,
|
||||
color = "var(--sage)",
|
||||
labels = false,
|
||||
}: {
|
||||
data: { value: number; label?: string; muted?: boolean }[];
|
||||
height?: number;
|
||||
color?: string;
|
||||
labels?: boolean;
|
||||
}) {
|
||||
if (!data.length) return null;
|
||||
const max = Math.max(...data.map((d) => d.value), 0.001);
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 2, height, width: "100%" }}>
|
||||
{data.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, minWidth: 0 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: `${(d.value / max) * 100}%`,
|
||||
background: d.value > 0 ? color : "var(--line)",
|
||||
borderRadius: "2px 2px 0 0",
|
||||
minHeight: d.value > 0 ? 2 : 1,
|
||||
opacity: d.muted ? 0.4 : 1,
|
||||
}}
|
||||
/>
|
||||
{labels && d.label != null && (
|
||||
<div style={{ fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)" }}>{d.label}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Donut({
|
||||
segments,
|
||||
size = 160,
|
||||
thickness = 22,
|
||||
}: {
|
||||
segments: { label: string; value: number; color: string }[];
|
||||
size?: number;
|
||||
thickness?: number;
|
||||
}) {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
||||
const r = size / 2 - thickness / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ transform: "rotate(-90deg)" }}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
stroke="var(--line)"
|
||||
strokeWidth={thickness}
|
||||
fill="none"
|
||||
opacity="0.3"
|
||||
/>
|
||||
{segments.map((s, i) => {
|
||||
const len = (s.value / total) * c;
|
||||
const dash = `${len} ${c - len}`;
|
||||
const el = (
|
||||
<circle
|
||||
key={i}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
stroke={s.color}
|
||||
strokeWidth={thickness}
|
||||
fill="none"
|
||||
strokeDasharray={dash}
|
||||
strokeDashoffset={-offset}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
);
|
||||
offset += len;
|
||||
return el;
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card / Stat / Pill ────────────────────────────────────────────
|
||||
export function Card({
|
||||
children,
|
||||
style,
|
||||
padded = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
style?: CSSProperties;
|
||||
padded?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
padding: padded ? 24 : 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stat({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
sub,
|
||||
spark,
|
||||
accent,
|
||||
big,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
unit?: string;
|
||||
sub?: ReactNode;
|
||||
spark?: ReactNode;
|
||||
accent?: string;
|
||||
big?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{label}</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
|
||||
<div
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: big ? 44 : 32,
|
||||
lineHeight: 1,
|
||||
color: accent ?? "var(--ink)",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{unit && <div style={{ fontSize: 13, color: "var(--ink-3)", fontFamily: "var(--mono)" }}>{unit}</div>}
|
||||
</div>
|
||||
{sub && <div style={{ fontSize: 12, color: "var(--ink-3)" }}>{sub}</div>}
|
||||
{spark && <div style={{ marginTop: 4 }}>{spark}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PillTone = "neutral" | "sage" | "terra" | "amber" | "outline";
|
||||
export function Pill({
|
||||
children,
|
||||
tone = "neutral",
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: PillTone;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const tones: Record<PillTone, { bg: string; color: string; border?: string }> = {
|
||||
neutral: { bg: "var(--bg-3)", color: "var(--ink-2)" },
|
||||
sage: { bg: "var(--sage-soft)", color: "var(--sage)" },
|
||||
terra: { bg: "var(--terracotta-soft)", color: "var(--terracotta)" },
|
||||
amber: { bg: "var(--amber-soft)", color: "oklch(48% 0.10 75)" },
|
||||
outline: { bg: "transparent", color: "var(--ink-2)", border: "1px solid var(--line-strong)" },
|
||||
};
|
||||
const t = tones[tone];
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: t.bg,
|
||||
color: t.color,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.02em",
|
||||
border: t.border ?? "1px solid transparent",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Button ────────────────────────────────────────────────────────
|
||||
type BtnVariant = "primary" | "secondary" | "ghost" | "danger" | "sage";
|
||||
|
||||
export function Btn({
|
||||
children,
|
||||
variant = "ghost",
|
||||
icon,
|
||||
onClick,
|
||||
style,
|
||||
type,
|
||||
disabled,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
variant?: BtnVariant;
|
||||
icon?: string;
|
||||
onClick?: ButtonHTMLAttributes<HTMLButtonElement>["onClick"];
|
||||
style?: CSSProperties;
|
||||
type?: "button" | "submit" | "reset";
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const variants: Record<BtnVariant, CSSProperties> = {
|
||||
primary: disabled
|
||||
? { background: "var(--bg-3)", color: "var(--ink-3)", border: "1px solid var(--line-strong)" }
|
||||
: { background: "var(--ink)", color: "var(--bg)", border: "1px solid var(--ink)" },
|
||||
secondary: { background: "var(--surface)", color: "var(--ink)", border: "1px solid var(--line-strong)" },
|
||||
ghost: { background: "transparent", color: "var(--ink-2)", border: "1px solid transparent" },
|
||||
danger: { background: "var(--terracotta)", color: "oklch(98% 0.01 40)", border: "1px solid var(--terracotta)" },
|
||||
sage: { background: "var(--sage)", color: "oklch(98% 0.01 145)", border: "1px solid var(--sage)" },
|
||||
};
|
||||
const v = variants[variant];
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "8px 14px",
|
||||
borderRadius: "var(--r-md)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
transition: "all 120ms",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
...v,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{icon && <Icon name={icon} size={14} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Field / Input / Select / Textarea ────────────────────────────
|
||||
export const inputStyle: CSSProperties = {
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
padding: "10px 12px",
|
||||
fontSize: 13,
|
||||
color: "var(--ink)",
|
||||
outline: "none",
|
||||
fontFamily: "var(--sans)",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
hint,
|
||||
span = 1,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
hint?: string;
|
||||
span?: number;
|
||||
}) {
|
||||
return (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6, gridColumn: `span ${span}` }}>
|
||||
<span className="smallcaps" style={{ color: "var(--ink-3)" }}>{label}</span>
|
||||
{children}
|
||||
{hint && <span style={{ fontSize: 11, color: "var(--ink-3)" }}>{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
const { style, ...rest } = props;
|
||||
return <input style={{ ...inputStyle, ...style }} {...rest} />;
|
||||
}
|
||||
|
||||
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
const { style, children, ...rest } = props;
|
||||
return (
|
||||
<select style={{ ...inputStyle, appearance: "auto", ...style }} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const { style, ...rest } = props;
|
||||
return <textarea style={{ ...inputStyle, minHeight: 80, resize: "vertical", ...style }} {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// fmt.* — verbatim port from primitives.jsx
|
||||
export const fmt = {
|
||||
g(n: number | null | undefined): string {
|
||||
if (n == null) return "—";
|
||||
const trimmed = (+n).toFixed(2).replace(/\.?0+$/, "");
|
||||
return `${trimmed || "0"} g`;
|
||||
},
|
||||
money(n: number | null | undefined): string {
|
||||
if (n == null) return "—";
|
||||
return `$${(+n).toFixed(2)}`;
|
||||
},
|
||||
moneyShort(n: number | null | undefined): string {
|
||||
if (n == null) return "—";
|
||||
return n >= 100 ? `$${Math.round(n)}` : `$${(+n).toFixed(2)}`;
|
||||
},
|
||||
pct(n: number | null | undefined): string {
|
||||
if (n == null) return "—";
|
||||
return `${(+n).toFixed(1)}%`;
|
||||
},
|
||||
date(s: string | null | undefined): string {
|
||||
if (!s) return "—";
|
||||
const d = new Date(s);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
},
|
||||
dateShort(s: string | null | undefined): string {
|
||||
if (!s) return "—";
|
||||
const d = new Date(s);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
},
|
||||
daysAgo(s: string | null | undefined): string {
|
||||
if (!s) return "—";
|
||||
const ms = Date.now() - new Date(s).getTime();
|
||||
const d = Math.floor(ms / 86_400_000);
|
||||
if (d === 0) return "today";
|
||||
if (d === 1) return "yesterday";
|
||||
if (d < 30) return `${d}d ago`;
|
||||
if (d < 365) return `${Math.floor(d / 30)}mo ago`;
|
||||
return `${Math.floor(d / 365)}y ago`;
|
||||
},
|
||||
};
|
||||
|
||||
export const TYPE_GLYPHS: Record<string, string> = {
|
||||
Flower: "✿",
|
||||
Concentrate: "◆",
|
||||
Edible: "◐",
|
||||
Vaporizer: "▢",
|
||||
"Pre-roll": "│",
|
||||
Tincture: "◯",
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { App } from "./App.js";
|
||||
import "./tokens.css";
|
||||
import "./styles/global.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 30_000, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
const STORED_THEME = (localStorage.getItem("apothecary.theme") ?? "light") as "light" | "dark";
|
||||
document.documentElement.dataset.theme = STORED_THEME;
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,291 @@
|
||||
// computeStats — ported from primitives.jsx:41-235
|
||||
// Derives daily/weekly/monthly grams from purchase + audit history,
|
||||
// using estimated remaining for active items and full weight for consumed.
|
||||
// Gone items contribute spend but NOT grams (so daily averages stay clean).
|
||||
|
||||
import type { Bootstrap, Product } from "./types.js";
|
||||
import { TYPES, TODAY_STR, helpers } from "./types.js";
|
||||
|
||||
export interface Stats {
|
||||
dailyAvg: number;
|
||||
weeklyAvg: number;
|
||||
monthlyAvg: number;
|
||||
totalSpend: number;
|
||||
avgPerGram: number;
|
||||
spend7: number;
|
||||
spend30: number;
|
||||
spend90: number;
|
||||
goneSpend: number;
|
||||
inventoryValue: number;
|
||||
inventoryGrams: number;
|
||||
totalGrams: number;
|
||||
thcLast7: number;
|
||||
thcLast30: number;
|
||||
avgLifespan: number;
|
||||
favShop: [string, number];
|
||||
favBrand: [string, number];
|
||||
typeBreakdown: Record<string, number>;
|
||||
daysOfSupply: number;
|
||||
avgGap: number;
|
||||
series7: { date: string; grams: number }[];
|
||||
series30: { date: string; grams: number }[];
|
||||
series90: { date: string; grams: number }[];
|
||||
activeCount: number;
|
||||
consumedCount: number;
|
||||
goneCount: number;
|
||||
archivedCount: number;
|
||||
overdueAudits: Product[];
|
||||
lowStockBulk: Product[];
|
||||
lowStockDiscreteGroups: {
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
brandId: string | null;
|
||||
items: Product[];
|
||||
totalCount: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function computeStats(data: Bootstrap): Stats {
|
||||
const today = new Date(data.today || TODAY_STR);
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const products = data.products;
|
||||
const dayKey = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
const active = products.filter((p) => p.status === "active");
|
||||
const consumed = products.filter((p) => p.status === "consumed" && p.consumedDate);
|
||||
const gone = products.filter((p) => p.status === "gone");
|
||||
|
||||
const purchasesIn = (days: number) => {
|
||||
const cutoff = new Date(today);
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
return products.filter((p) => new Date(p.purchaseDate) >= cutoff);
|
||||
};
|
||||
const last7p = purchasesIn(7);
|
||||
const last30p = purchasesIn(30);
|
||||
const last90p = purchasesIn(90);
|
||||
|
||||
const bulkGrams = (p: Product): number => {
|
||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
||||
if (p.kind === "bulk") return p.weight;
|
||||
return (p.countOriginal || 0) * (p.unitWeight || 0);
|
||||
};
|
||||
const bulkGramsConsumed = (p: Product): number => {
|
||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
||||
if (p.kind === "bulk") return p.weight;
|
||||
return (p.countOriginal || 0) * (p.unitWeight || 0);
|
||||
};
|
||||
const bulkGramsUsedSoFar = (p: Product): number => {
|
||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
||||
if (p.kind === "bulk") {
|
||||
const est = helpers.estimatedRemaining(p, todayStr);
|
||||
return Math.max(0, p.weight - est);
|
||||
}
|
||||
const cur = p.countLastAudit ?? p.countOriginal;
|
||||
return Math.max(0, p.countOriginal - cur) * (p.unitWeight || 0);
|
||||
};
|
||||
|
||||
const dailyGramsAttribution: Record<string, number> = {};
|
||||
consumed.forEach((p) => {
|
||||
const g = bulkGramsConsumed(p);
|
||||
if (g <= 0 || !p.consumedDate) return;
|
||||
const start = new Date(p.purchaseDate);
|
||||
const end = new Date(p.consumedDate);
|
||||
const days = Math.max(1, Math.round((+end - +start) / 86_400_000));
|
||||
const perDay = g / days;
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(d.getDate() + i);
|
||||
const k = dayKey(d);
|
||||
dailyGramsAttribution[k] = (dailyGramsAttribution[k] || 0) + perDay;
|
||||
}
|
||||
});
|
||||
active.forEach((p) => {
|
||||
const used = bulkGramsUsedSoFar(p);
|
||||
if (used <= 0) return;
|
||||
const start = new Date(p.purchaseDate);
|
||||
const days = Math.max(1, Math.round((+today - +start) / 86_400_000));
|
||||
const perDay = used / days;
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(d.getDate() + i);
|
||||
const k = dayKey(d);
|
||||
dailyGramsAttribution[k] = (dailyGramsAttribution[k] || 0) + perDay;
|
||||
}
|
||||
});
|
||||
|
||||
const seriesFor = (days: number) => {
|
||||
const out: { date: string; grams: number }[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const k = dayKey(d);
|
||||
out.push({ date: k, grams: dailyGramsAttribution[k] || 0 });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const series7 = seriesFor(7);
|
||||
const series30 = seriesFor(30);
|
||||
const series90 = seriesFor(90);
|
||||
|
||||
const sumG = (xs: { grams: number }[]) => xs.reduce((s, x) => s + x.grams, 0);
|
||||
const dailyAvg = sumG(series30) / 30;
|
||||
const weeklyAvg = sumG(series30) / (30 / 7);
|
||||
const monthlyAvg = sumG(series90) / 3;
|
||||
|
||||
const totalSpend = products.reduce((s, p) => s + p.price, 0);
|
||||
const goneSpend = gone.reduce((s, p) => s + p.price, 0);
|
||||
const totalGrams = products.reduce((s, p) => s + bulkGrams(p), 0);
|
||||
const avgPerGram = totalGrams ? totalSpend / totalGrams : 0;
|
||||
const spend30 = last30p.reduce((s, p) => s + p.price, 0);
|
||||
const spend7 = last7p.reduce((s, p) => s + p.price, 0);
|
||||
const spend90 = last90p.reduce((s, p) => s + p.price, 0);
|
||||
|
||||
const inventoryValue = active.reduce(
|
||||
(s, p) => s + p.price * helpers.pctRemaining(p, todayStr),
|
||||
0,
|
||||
);
|
||||
|
||||
// Grams currently on hand: bulk uses estimated remaining; discrete uses
|
||||
// (units × per-unit weight). Tincture (ml) and edibles (count) are excluded
|
||||
// to match the existing `bulkGrams` convention used for $/g and totals.
|
||||
const inventoryGrams = active.reduce((s, p) => {
|
||||
if (p.type === "Tincture" || p.type === "Edible") return s;
|
||||
if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, todayStr);
|
||||
const cur = p.countLastAudit ?? p.countOriginal;
|
||||
return s + cur * (p.unitWeight || 0);
|
||||
}, 0);
|
||||
|
||||
const avgThc =
|
||||
products.length > 0 ? products.reduce((s, p) => s + p.thc, 0) / products.length : 20;
|
||||
const thcLast7 = Math.round(sumG(series7) * avgThc * 10);
|
||||
const thcLast30 = Math.round(sumG(series30) * avgThc * 10);
|
||||
|
||||
const lifespans = consumed.map((p) =>
|
||||
Math.max(
|
||||
1,
|
||||
Math.round((+new Date(p.consumedDate!) - +new Date(p.purchaseDate)) / 86_400_000),
|
||||
),
|
||||
);
|
||||
const avgLifespan =
|
||||
lifespans.length > 0 ? lifespans.reduce((a, b) => a + b, 0) / lifespans.length : 0;
|
||||
|
||||
const shopCount: Record<string, number> = {};
|
||||
const brandCount: Record<string, number> = {};
|
||||
products.forEach((p) => {
|
||||
if (p.shopId) shopCount[p.shopId] = (shopCount[p.shopId] || 0) + 1;
|
||||
if (p.brandId) brandCount[p.brandId] = (brandCount[p.brandId] || 0) + 1;
|
||||
});
|
||||
const topShopEntry = Object.entries(shopCount).sort((a, b) => b[1] - a[1])[0];
|
||||
const topBrandEntry = Object.entries(brandCount).sort((a, b) => b[1] - a[1])[0];
|
||||
const favShop: [string, number] = topShopEntry
|
||||
? [helpers.shopName(data, topShopEntry[0]), topShopEntry[1]]
|
||||
: ["—", 0];
|
||||
const favBrand: [string, number] = topBrandEntry
|
||||
? [helpers.brandName(data, topBrandEntry[0]), topBrandEntry[1]]
|
||||
: ["—", 0];
|
||||
|
||||
const typeBreakdown: Record<string, number> = {};
|
||||
active.forEach((p) => {
|
||||
let g: number;
|
||||
if (p.type === "Tincture") g = helpers.estimatedRemaining(p, todayStr) * 0.5;
|
||||
else if (p.type === "Edible")
|
||||
g = (p.countLastAudit ?? p.countOriginal) * 0.3;
|
||||
else if (p.kind === "bulk") g = helpers.estimatedRemaining(p, todayStr);
|
||||
else g = (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
||||
if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g;
|
||||
});
|
||||
|
||||
const flowerEquivalent = active
|
||||
.filter((p) => p.type === "Flower" || p.type === "Pre-roll")
|
||||
.reduce((s, p) => {
|
||||
if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, todayStr);
|
||||
return s + (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
||||
}, 0);
|
||||
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
|
||||
|
||||
const sortedDates = [...products]
|
||||
.sort((a, b) => +new Date(a.purchaseDate) - +new Date(b.purchaseDate))
|
||||
.map((p) => new Date(p.purchaseDate));
|
||||
const gaps: number[] = [];
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
gaps.push((+sortedDates[i]! - +sortedDates[i - 1]!) / 86_400_000);
|
||||
}
|
||||
const avgGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;
|
||||
|
||||
const overdueAudits = active.filter((p) => helpers.auditOverdue(p, todayStr));
|
||||
|
||||
const lowStockBulk = active.filter(
|
||||
(p) => p.kind === "bulk" && helpers.pctRemaining(p, todayStr) < 0.25,
|
||||
);
|
||||
|
||||
const discreteBrandGroups: Record<
|
||||
string,
|
||||
{ key: string; name: string; type: string; brandId: string | null; items: Product[]; totalCount: number }
|
||||
> = {};
|
||||
active
|
||||
.filter((p) => p.kind === "discrete")
|
||||
.forEach((p) => {
|
||||
const k = `${p.brandId}|${p.type}|${p.name}`;
|
||||
if (!discreteBrandGroups[k]) {
|
||||
discreteBrandGroups[k] = {
|
||||
key: k,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
brandId: p.brandId,
|
||||
items: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
discreteBrandGroups[k].items.push(p);
|
||||
discreteBrandGroups[k].totalCount += p.countLastAudit ?? p.countOriginal;
|
||||
});
|
||||
const lowStockDiscreteGroups = Object.values(discreteBrandGroups).filter(
|
||||
(g) => g.totalCount <= 2,
|
||||
);
|
||||
|
||||
return {
|
||||
dailyAvg,
|
||||
weeklyAvg,
|
||||
monthlyAvg,
|
||||
totalSpend,
|
||||
avgPerGram,
|
||||
spend7,
|
||||
spend30,
|
||||
spend90,
|
||||
goneSpend,
|
||||
inventoryValue,
|
||||
inventoryGrams,
|
||||
totalGrams,
|
||||
thcLast7,
|
||||
thcLast30,
|
||||
avgLifespan,
|
||||
favShop,
|
||||
favBrand,
|
||||
typeBreakdown,
|
||||
daysOfSupply,
|
||||
avgGap,
|
||||
series7,
|
||||
series30,
|
||||
series90,
|
||||
activeCount: active.length,
|
||||
consumedCount: consumed.length,
|
||||
goneCount: gone.length,
|
||||
archivedCount: consumed.length + gone.length,
|
||||
overdueAudits,
|
||||
lowStockBulk,
|
||||
lowStockDiscreteGroups,
|
||||
};
|
||||
}
|
||||
|
||||
// Display helpers used throughout the UI
|
||||
export function remainingShort(p: Product): string {
|
||||
const cfg = TYPES.find((t) => t.id === p.type);
|
||||
if (p.kind === "discrete") {
|
||||
const cur = p.countLastAudit ?? p.countOriginal;
|
||||
return `${cur} ct`;
|
||||
}
|
||||
const est = helpers.estimatedRemaining(p);
|
||||
const trimmed = est.toFixed(2).replace(/\.?0+$/, "") || "0";
|
||||
return `${trimmed} ${cfg?.unit ?? "g"}`;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/* App chrome — ported from Apothecary - Inventory.html lines 11–86 */
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 264px 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
padding: 28px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px 28px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--serif);
|
||||
font-size: 22px;
|
||||
font-style: italic;
|
||||
color: var(--ink);
|
||||
}
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
transition: background 100ms;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background: var(--bg-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
.nav-link.active {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.nav-section {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ink-3);
|
||||
padding: 16px 12px 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.inv-row:hover {
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: auto;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-right: none;
|
||||
z-index: 30;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.brand,
|
||||
.nav-section {
|
||||
display: none;
|
||||
}
|
||||
.nav-link {
|
||||
white-space: nowrap;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.main {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Apothecary / Botanical design tokens
|
||||
Warm earthy neutrals — parchment, ink, sage, terracotta */
|
||||
|
||||
:root {
|
||||
/* Surfaces — parchment-toned */
|
||||
--bg: oklch(96% 0.012 80); /* parchment */
|
||||
--bg-2: oklch(93% 0.014 80); /* slightly darker parchment */
|
||||
--bg-3: oklch(89% 0.016 78); /* card divider, hover */
|
||||
--surface: oklch(98% 0.008 82); /* card surface */
|
||||
--surface-sunken: oklch(91% 0.014 80);
|
||||
|
||||
/* Ink — warm near-blacks */
|
||||
--ink: oklch(22% 0.012 60); /* primary text */
|
||||
--ink-2: oklch(38% 0.012 60); /* secondary text */
|
||||
--ink-3: oklch(56% 0.014 65); /* tertiary, captions */
|
||||
--ink-4: oklch(72% 0.014 70); /* faint, dividers */
|
||||
|
||||
/* Lines */
|
||||
--line: oklch(82% 0.014 75);
|
||||
--line-strong: oklch(68% 0.016 70);
|
||||
|
||||
/* Accents — share chroma 0.08, lightness 52% */
|
||||
--sage: oklch(52% 0.06 145); /* primary action / good */
|
||||
--sage-2: oklch(64% 0.05 145);
|
||||
--sage-soft: oklch(88% 0.03 145);
|
||||
|
||||
--terracotta: oklch(58% 0.10 40); /* warning / consumed */
|
||||
--terracotta-soft: oklch(90% 0.04 40);
|
||||
|
||||
--amber: oklch(68% 0.10 75); /* low stock / attention */
|
||||
--amber-soft: oklch(91% 0.04 75);
|
||||
|
||||
--plum: oklch(48% 0.06 340); /* secondary accent */
|
||||
|
||||
/* Typography */
|
||||
--serif: "Cormorant Garamond", "GT Sectra", "Playfair Display", Georgia, serif;
|
||||
--sans: "Inter", "Söhne", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
|
||||
|
||||
/* Radii */
|
||||
--r-sm: 4px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 14px;
|
||||
--r-xl: 20px;
|
||||
|
||||
/* Shadow — subtle */
|
||||
--shadow-sm: 0 1px 2px oklch(20% 0.02 60 / 0.06);
|
||||
--shadow-md: 0 2px 8px oklch(20% 0.02 60 / 0.08);
|
||||
--shadow-lg: 0 8px 24px oklch(20% 0.02 60 / 0.10);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: oklch(20% 0.012 60);
|
||||
--bg-2: oklch(23% 0.012 60);
|
||||
--bg-3: oklch(27% 0.014 65);
|
||||
--surface: oklch(25% 0.012 60);
|
||||
--surface-sunken: oklch(18% 0.012 60);
|
||||
|
||||
--ink: oklch(94% 0.008 80);
|
||||
--ink-2: oklch(78% 0.012 75);
|
||||
--ink-3: oklch(62% 0.014 70);
|
||||
--ink-4: oklch(46% 0.014 65);
|
||||
|
||||
--line: oklch(34% 0.014 65);
|
||||
--line-strong: oklch(48% 0.016 65);
|
||||
|
||||
--sage: oklch(70% 0.07 145);
|
||||
--sage-2: oklch(60% 0.06 145);
|
||||
--sage-soft: oklch(32% 0.04 145);
|
||||
|
||||
--terracotta: oklch(70% 0.10 40);
|
||||
--terracotta-soft: oklch(32% 0.05 40);
|
||||
|
||||
--amber: oklch(78% 0.10 75);
|
||||
--amber-soft: oklch(32% 0.05 75);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
input, select, textarea { font-family: inherit; }
|
||||
|
||||
/* Subtle parchment texture */
|
||||
.parchment {
|
||||
background-image:
|
||||
radial-gradient(oklch(85% 0.03 75 / 0.15) 1px, transparent 1px),
|
||||
radial-gradient(oklch(80% 0.03 75 / 0.10) 1px, transparent 1px);
|
||||
background-size: 24px 24px, 36px 36px;
|
||||
background-position: 0 0, 12px 18px;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.serif { font-family: var(--serif); font-weight: 500; }
|
||||
.mono { font-family: var(--mono); font-feature-settings: "ss01", "cv11"; }
|
||||
.smallcaps {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.hairline { border-top: 1px solid var(--line); }
|
||||
@@ -0,0 +1,155 @@
|
||||
export type ProductStatus = "active" | "consumed" | "gone";
|
||||
export type ProductKind = "bulk" | "discrete";
|
||||
export type AuditMode = "weigh" | "estimate" | "presence";
|
||||
|
||||
export interface Audit {
|
||||
date: string;
|
||||
mode: AuditMode;
|
||||
value: number;
|
||||
prev: number | null;
|
||||
confirmedBy: string | null;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
sku: string;
|
||||
assetTag: string | null;
|
||||
name: string;
|
||||
brandId: string | null;
|
||||
shopId: string | null;
|
||||
binId: string | null;
|
||||
type: string;
|
||||
kind: ProductKind;
|
||||
weight: number;
|
||||
lastAuditWeight: number | null;
|
||||
countOriginal: number;
|
||||
countLastAudit: number | null;
|
||||
unitWeight: number;
|
||||
price: number;
|
||||
thc: number;
|
||||
cbd: number;
|
||||
totalCannabinoids: number;
|
||||
purchaseDate: string;
|
||||
status: ProductStatus;
|
||||
consumedDate: string | null;
|
||||
goneDate: string | null;
|
||||
rating: number | null;
|
||||
notes: string | null;
|
||||
strainId: string | null;
|
||||
audits: Audit[];
|
||||
}
|
||||
|
||||
export interface Strain {
|
||||
id: string;
|
||||
name: string;
|
||||
brandId: string | null;
|
||||
type: string;
|
||||
defaultThc: number | null;
|
||||
defaultCbd: number | null;
|
||||
defaultTotalCannabinoids: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Shop {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
}
|
||||
|
||||
export interface Bin {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
export interface TypeConfig {
|
||||
id: string;
|
||||
kind: ProductKind;
|
||||
auditMode: AuditMode;
|
||||
cadenceDays: number;
|
||||
unit: string;
|
||||
weighable: boolean;
|
||||
}
|
||||
|
||||
export interface Bootstrap {
|
||||
products: Product[];
|
||||
brands: Brand[];
|
||||
shops: Shop[];
|
||||
bins: Bin[];
|
||||
strains: Strain[];
|
||||
today: string;
|
||||
}
|
||||
|
||||
// Type config lives client-side — static, not user data.
|
||||
// Mirrors data.js TYPES array.
|
||||
export const TYPES: TypeConfig[] = [
|
||||
{ id: "Flower", kind: "bulk", auditMode: "weigh", cadenceDays: 14, unit: "g", weighable: true },
|
||||
{ id: "Concentrate", kind: "bulk", auditMode: "estimate", cadenceDays: 21, unit: "g", weighable: true },
|
||||
{ id: "Tincture", kind: "bulk", auditMode: "estimate", cadenceDays: 30, unit: "ml", weighable: false },
|
||||
{ id: "Pre-roll", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false },
|
||||
{ id: "Edible", kind: "discrete", auditMode: "presence", cadenceDays: 60, unit: "ct", weighable: false },
|
||||
{ id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false },
|
||||
];
|
||||
|
||||
export const TODAY_STR = "2026-04-25";
|
||||
|
||||
// Helpers — match data.js DATA_HELPERS API
|
||||
export const helpers = {
|
||||
shopName(data: { shops: Shop[] }, id: string | null): string {
|
||||
return data.shops.find((s) => s.id === id)?.name ?? "—";
|
||||
},
|
||||
brandName(data: { brands: Brand[] }, id: string | null): string {
|
||||
return data.brands.find((b) => b.id === id)?.name ?? "—";
|
||||
},
|
||||
typeConfig(id: string): TypeConfig {
|
||||
return TYPES.find((t) => t.id === id) ?? TYPES[0]!;
|
||||
},
|
||||
daysSince(iso: string | null, today = TODAY_STR): number {
|
||||
if (!iso) return Infinity;
|
||||
return Math.floor((+new Date(today) - +new Date(iso)) / 86_400_000);
|
||||
},
|
||||
lastAudit(p: Product): Audit | null {
|
||||
return p.audits.length > 0 ? p.audits[p.audits.length - 1]! : null;
|
||||
},
|
||||
daysSinceCheck(p: Product, today = TODAY_STR): number {
|
||||
const last = p.audits.length > 0 ? p.audits[p.audits.length - 1]!.date : p.purchaseDate;
|
||||
return Math.floor((+new Date(today) - +new Date(last)) / 86_400_000);
|
||||
},
|
||||
auditOverdue(p: Product, today = TODAY_STR): boolean {
|
||||
if (p.status !== "active") return false;
|
||||
const cfg = TYPES.find((t) => t.id === p.type);
|
||||
if (!cfg) return false;
|
||||
return this.daysSinceCheck(p, today) >= cfg.cadenceDays;
|
||||
},
|
||||
estimatedRemaining(p: Product, today = TODAY_STR): number {
|
||||
if (p.status !== "active") return 0;
|
||||
if (p.kind === "discrete") {
|
||||
return p.countLastAudit ?? p.countOriginal;
|
||||
}
|
||||
const last = this.lastAudit(p);
|
||||
const baseDate = last ? last.date : p.purchaseDate;
|
||||
const baseValue = last ? last.value : p.weight;
|
||||
const daysSinceBase = Math.max(
|
||||
0,
|
||||
Math.floor((+new Date(today) - +new Date(baseDate)) / 86_400_000),
|
||||
);
|
||||
const expectedLifespan =
|
||||
p.type === "Flower" ? 35 : p.type === "Concentrate" ? 40 : 90;
|
||||
const dailyBurn = p.weight / expectedLifespan;
|
||||
return Math.max(0, baseValue - dailyBurn * daysSinceBase);
|
||||
},
|
||||
pctRemaining(p: Product, today = TODAY_STR): number {
|
||||
if (p.kind === "discrete") {
|
||||
const cur = p.countLastAudit ?? p.countOriginal;
|
||||
return p.countOriginal > 0 ? cur / p.countOriginal : 0;
|
||||
}
|
||||
const est = this.estimatedRemaining(p, today);
|
||||
return p.weight > 0 ? est / p.weight : 0;
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:4000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user