Add SKUs section with list view, detail drawer, and CRUD modals
Build and push image / build (push) Successful in 51s

New catalog-level view for browsing, searching, and managing product SKUs
with per-SKU insights (spend, lifecycle, ratings) and full inventory linking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 10:12:35 -04:00
parent 564cae253a
commit 3bdf857099
6 changed files with 1153 additions and 2 deletions
+51 -2
View File
@@ -1,14 +1,15 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { api } from "./api.js"; import { api } from "./api.js";
import type { Bin, Bootstrap, Brand, Item, Shop } from "./types.js"; import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js";
import { enrichItems } from "./types.js"; import { enrichItems } from "./types.js";
import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js"; import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js";
import { computeStats } from "./stats.js"; import { computeStats } from "./stats.js";
import { Sidebar } from "./components/Sidebar.js"; import { Sidebar } from "./components/Sidebar.js";
import { Dashboard } from "./views/Dashboard.js"; import { Dashboard } from "./views/Dashboard.js";
import { Inventory } from "./views/Inventory.js"; import { Inventory } from "./views/Inventory.js";
import { SkusView } from "./views/SkusView.js";
import { BinsView } from "./views/BinsView.js"; import { BinsView } from "./views/BinsView.js";
import { BrandsView } from "./views/BrandsView.js"; import { BrandsView } from "./views/BrandsView.js";
import { ShopsView } from "./views/ShopsView.js"; import { ShopsView } from "./views/ShopsView.js";
@@ -16,6 +17,7 @@ import { ChartsView } from "./views/ChartsView.js";
import { SettingsView } from "./views/SettingsView.js"; import { SettingsView } from "./views/SettingsView.js";
import type { ThemeKey } from "./views/SettingsView.js"; import type { ThemeKey } from "./views/SettingsView.js";
import { ProductDetail } from "./components/ProductDetail.js"; import { ProductDetail } from "./components/ProductDetail.js";
import { SkuDetail } from "./components/SkuDetail.js";
import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js"; import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js";
import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js"; import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js";
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js"; import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
@@ -32,6 +34,7 @@ import {
EditBrandModal, EditBrandModal,
EditShopModal, EditShopModal,
} from "./components/modals/CatalogModals.js"; } from "./components/modals/CatalogModals.js";
import { AddSkuModal, EditSkuModal } from "./components/modals/SkuModals.js";
import { BulkEditModal } from "./components/modals/BulkEditModal.js"; import { BulkEditModal } from "./components/modals/BulkEditModal.js";
import { BulkConsumeModal } from "./components/modals/BulkConsumeModal.js"; import { BulkConsumeModal } from "./components/modals/BulkConsumeModal.js";
import { BulkCheckoutModal } from "./components/modals/BulkCheckoutModal.js"; import { BulkCheckoutModal } from "./components/modals/BulkCheckoutModal.js";
@@ -57,9 +60,12 @@ type ModalKey =
| "bulkCheckout" | "bulkCheckout"
| "bulkCheckin" | "bulkCheckin"
| "bulkGone" | "bulkGone"
| "addSku"
| "editSku"
| null; | null;
export function App() { export function App() {
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Item | null>(null); const [selected, setSelected] = useState<Item | null>(null);
const [modal, setModal] = useState<ModalKey>(null); const [modal, setModal] = useState<ModalKey>(null);
const [modalItem, setModalItem] = useState<Item | null>(null); const [modalItem, setModalItem] = useState<Item | null>(null);
@@ -67,6 +73,8 @@ export function App() {
const [modalBrand, setModalBrand] = useState<Brand | null>(null); const [modalBrand, setModalBrand] = useState<Brand | null>(null);
const [modalShop, setModalShop] = useState<Shop | null>(null); const [modalShop, setModalShop] = useState<Shop | null>(null);
const [bulkItems, setBulkItems] = useState<Item[]>([]); const [bulkItems, setBulkItems] = useState<Item[]>([]);
const [selectedSku, setSelectedSku] = useState<Product | null>(null);
const [modalProduct, setModalProduct] = useState<Product | null>(null);
const [theme, setTheme] = useState<ThemeKey>( const [theme, setTheme] = useState<ThemeKey>(
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light", () => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
@@ -100,6 +108,13 @@ export function App() {
} }
}, [data, items]); // eslint-disable-line react-hooks/exhaustive-deps }, [data, items]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (selectedSku && data) {
const fresh = data.products.find((p) => p.id === selectedSku.id);
if (fresh && fresh !== selectedSku) setSelectedSku(fresh);
}
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
const openAdd = () => { const openAdd = () => {
setModalItem(null); setModalItem(null);
setModal("add"); setModal("add");
@@ -217,6 +232,13 @@ export function App() {
onBulkGone={openBulkGone} onBulkGone={openBulkGone}
/> />
} /> } />
<Route path="/skus" element={
<SkusView
data={data}
onSelectSku={setSelectedSku}
onAddSku={() => setModal("addSku")}
/>
} />
<Route path="/custody" element={ <Route path="/custody" element={
<CustodyView data={data} onSelectItem={setSelected} onCheckin={openCheckin} onConsume={openConsume} onMarkGone={openMarkGone} /> <CustodyView data={data} onSelectItem={setSelected} onCheckin={openCheckin} onConsume={openConsume} onMarkGone={openMarkGone} />
} /> } />
@@ -250,6 +272,28 @@ export function App() {
/> />
)} )}
{selectedSku && (
<SkuDetail
product={selectedSku}
data={data}
onClose={() => setSelectedSku(null)}
onEdit={() => {
setModalProduct(selectedSku);
setModal("editSku");
}}
onDelete={() => {
api.deleteProduct(selectedSku.id).then(() => {
setSelectedSku(null);
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
});
}}
onSelectItem={(i) => {
setSelectedSku(null);
setSelected(i);
}}
/>
)}
{modal === "add" && <AddInventoryFlow data={data} onClose={() => setModal(null)} />} {modal === "add" && <AddInventoryFlow data={data} onClose={() => setModal(null)} />}
{modal === "edit" && modalItem && ( {modal === "edit" && modalItem && (
<EditInventoryFlow data={data} item={modalItem} onClose={() => setModal(null)} /> <EditInventoryFlow data={data} item={modalItem} onClose={() => setModal(null)} />
@@ -297,6 +341,11 @@ export function App() {
{modal === "bulkGone" && ( {modal === "bulkGone" && (
<BulkGoneModal data={data} items={bulkItems} onClose={() => setModal(null)} /> <BulkGoneModal data={data} items={bulkItems} onClose={() => setModal(null)} />
)} )}
{modal === "addSku" && <AddSkuModal data={data} onClose={() => setModal(null)} />}
{modal === "editSku" && modalProduct && (
<EditSkuModal data={data} product={modalProduct} onClose={() => setModal(null)} />
)}
</div> </div>
); );
} }
+2
View File
@@ -4,6 +4,7 @@ import { Icon } from "./primitives/index.js";
export type ViewKey = export type ViewKey =
| "dashboard" | "dashboard"
| "inventory" | "inventory"
| "skus"
| "custody" | "custody"
| "bins" | "bins"
| "shops" | "shops"
@@ -14,6 +15,7 @@ export type ViewKey =
const NAV: { path: string; label: string; icon: string }[] = [ const NAV: { path: string; label: string; icon: string }[] = [
{ path: "/", label: "Dashboard", icon: "home" }, { path: "/", label: "Dashboard", icon: "home" },
{ path: "/inventory", label: "Inventory", icon: "box" }, { path: "/inventory", label: "Inventory", icon: "box" },
{ path: "/skus", label: "SKUs", icon: "barcode" },
{ path: "/custody", label: "My Custody", icon: "pocket" }, { path: "/custody", label: "My Custody", icon: "pocket" },
{ path: "/bins", label: "Bins", icon: "bin" }, { path: "/bins", label: "Bins", icon: "bin" },
{ path: "/shops", label: "Shops", icon: "shop" }, { path: "/shops", label: "Shops", icon: "shop" },
+406
View File
@@ -0,0 +1,406 @@
import { useEffect } from "react";
import type { Bootstrap, Product, Item } from "../types.js";
import { TYPES, helpers, enrichItems } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js";
import { remainingShort } from "../stats.js";
export function SkuDetail({
product,
data,
onClose,
onEdit,
onDelete,
onSelectItem,
}: {
product: Product;
data: Bootstrap;
onClose: () => void;
onEdit: () => void;
onDelete: () => void;
onSelectItem: (i: Item) => void;
}) {
const strain = data.strains.find((s) => s.id === product.strainId);
const cfg = TYPES.find((t) => t.id === product.type);
const items = enrichItems(data).filter((i) => i.productId === product.id);
const active = items.filter((i) => i.status === "active" || i.status === "checked-out");
const consumed = items.filter((i) => i.status === "consumed");
const gone = items.filter((i) => i.status === "gone");
const hasItems = items.length > 0;
const totalSpend = items.reduce((s, i) => s + i.price, 0);
const avgPrice = hasItems ? totalSpend / items.length : 0;
let avgCostPerGram: number | null = null;
if (product.kind === "bulk") {
const totalGrams = items.reduce((s, i) => s + i.weight, 0);
if (totalGrams > 0) avgCostPerGram = totalSpend / totalGrams;
} else {
const totalGrams = items.reduce((s, i) => s + i.countOriginal * i.unitWeight, 0);
if (totalGrams > 0) avgCostPerGram = totalSpend / totalGrams;
}
const rated = items.filter((i) => i.rating != null);
const avgRating = rated.length > 0
? rated.reduce((s, i) => s + i.rating!, 0) / rated.length
: null;
const lifespans = consumed
.filter((i) => i.consumedDate)
.map((i) => Math.max(1, Math.round((+new Date(i.consumedDate!) - +new Date(i.purchaseDate)) / 86_400_000)));
const avgLifespan = lifespans.length > 0 ? lifespans.reduce((a, b) => a + b, 0) / lifespans.length : null;
const todayStr = getToday(getStoredTimezone());
const tz = getStoredTimezone();
const totalGramsConsumed = consumed.reduce((s, i) => {
if (i.kind === "bulk") return s + i.weight;
return s + i.countOriginal * i.unitWeight;
}, 0);
const totalDaysConsumed = lifespans.reduce((a, b) => a + b, 0);
const consumptionRate = totalDaysConsumed > 0 ? totalGramsConsumed / totalDaysConsumed : null;
const sortedItems = [...items].sort(
(a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate),
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const statCards: [string, React.ReactNode][] = [
["Purchases", String(items.length)],
["Total spent", hasItems ? fmt.money(totalSpend) : "—"],
["Avg price", hasItems ? fmt.money(avgPrice) : "—"],
[
`Avg /${cfg?.unit ?? "g"}`,
avgCostPerGram != null ? fmt.money(avgCostPerGram) : "—",
],
];
return (
<div
style={{
position: "fixed",
inset: 0,
background: "oklch(20% 0.02 60 / 0.4)",
zIndex: 50,
display: "flex",
justifyContent: "flex-end",
animation: "backdrop-in 200ms ease-out",
}}
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(720px, 100vw)",
height: "100%",
animation: "drawer-in 250ms ease-out",
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)" }}>
SKU · <span className="mono">{product.sku}</span>
</div>
<div style={{ display: "flex", gap: 6 }}>
<Btn variant="ghost" icon="edit" onClick={onEdit} />
<Btn
variant="ghost"
icon="bin"
disabled={hasItems}
onClick={onDelete}
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
/>
<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} · {product.kind}
</div>
</div>
<h1
className="serif"
style={{
fontSize: 48,
margin: "0 0 4px",
fontWeight: 500,
letterSpacing: "-0.02em",
lineHeight: 1.1,
}}
>
{strain?.name ?? "(unknown)"}
</h1>
<div style={{ fontSize: 16, color: "var(--ink-2)" }}>
{helpers.brandName(data, product.brandId)}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 6 }}>
Created {fmt.date(product.createdAt, tz)}
</div>
{hasItems && (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${statCards.length}, 1fr)`,
gap: 1,
marginTop: 32,
background: "var(--line)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
overflow: "hidden",
}}
>
{statCards.map(([l, v], i) => (
<div key={i} 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>
)}
{hasItems && (
<div style={{ marginTop: 28 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
Lifecycle
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginBottom: 16 }}>
{active.length > 0 && (
<Pill tone="sage">{active.length} active</Pill>
)}
{consumed.length > 0 && (
<Pill tone="terra">{consumed.length} consumed</Pill>
)}
{gone.length > 0 && (
<Pill tone="amber">{gone.length} gone</Pill>
)}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "14px 32px",
}}
>
<DetailRow label="Avg lifespan" value={avgLifespan != null ? `${Math.round(avgLifespan)} days` : "—"} />
<DetailRow
label="Consumption rate"
value={
consumptionRate != null
? `${consumptionRate.toFixed(2)} ${cfg?.unit ?? "g"}/day`
: "—"
}
/>
</div>
</div>
)}
{avgRating != null && (
<div style={{ marginTop: 28 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
Ratings
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 12 }}>
<div style={{ display: "flex", gap: 2 }}>
{[1, 2, 3, 4, 5].map((n) => (
<Icon
key={n}
name="star"
size={18}
color={n <= Math.round(avgRating) ? "var(--amber)" : "var(--ink-4)"}
/>
))}
</div>
<span className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
{avgRating.toFixed(1)}
</span>
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
from {rated.length} review{rated.length === 1 ? "" : "s"}
</span>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{rated
.sort((a, b) => +new Date(b.consumedDate ?? 0) - +new Date(a.consumedDate ?? 0))
.map((item) => (
<div
key={item.id}
style={{
padding: "8px 14px",
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
fontSize: 12,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span className="mono">{item.assetId}</span>
<span style={{ display: "flex", gap: 1 }}>
{[1, 2, 3, 4, 5].map((n) => (
<Icon
key={n}
name="star"
size={10}
color={n <= item.rating! ? "var(--amber)" : "var(--ink-4)"}
/>
))}
</span>
</div>
))}
</div>
</div>
)}
<div style={{ marginTop: 28 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
Strain defaults
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "14px 32px",
}}
>
<DetailRow label="Default THC" value={strain?.defaultThc != null ? `${strain.defaultThc.toFixed(1)}%` : "—"} />
<DetailRow label="Default CBD" value={strain?.defaultCbd != null ? `${strain.defaultCbd.toFixed(1)}%` : "—"} />
<DetailRow
label="Default total cannabinoids"
value={strain?.defaultTotalCannabinoids != null ? `${strain.defaultTotalCannabinoids.toFixed(1)}%` : "—"}
/>
{strain?.notes && <DetailRow label="Notes" value={strain.notes} />}
</div>
</div>
{hasItems && (
<div style={{ marginTop: 36 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
Inventory ({items.length})
</div>
<div
style={{
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
overflow: "hidden",
}}
>
{sortedItems.map((item, idx) => {
const isInactive = item.status !== "active" && item.status !== "checked-out";
return (
<div
key={item.id}
onClick={() => onSelectItem(item)}
className="inv-row"
style={{
padding: "12px 16px",
borderBottom: idx < sortedItems.length - 1 ? "1px solid var(--line)" : "none",
display: "grid",
gridTemplateColumns: "auto 1fr auto auto auto",
alignItems: "center",
gap: 12,
background: "var(--surface)",
cursor: "pointer",
opacity: isInactive ? 0.55 : 1,
}}
>
<span className="mono" style={{ fontSize: 12 }}>{item.assetId}</span>
<span style={{ fontSize: 13 }}>
{item.status === "consumed" && <Pill tone="terra" style={{ fontSize: 10 }}>Consumed</Pill>}
{item.status === "gone" && <Pill tone="amber" style={{ fontSize: 10 }}>Gone</Pill>}
{item.status === "checked-out" && <Pill tone="outline" style={{ fontSize: 10 }}>Checked out</Pill>}
{item.status === "active" && helpers.auditOverdue(item, todayStr) && (
<Pill tone="amber" style={{ fontSize: 10 }}>Audit due</Pill>
)}
{item.status === "active" && !helpers.auditOverdue(item, todayStr) && (
<Pill tone="sage" style={{ fontSize: 10 }}>Active</Pill>
)}
</span>
<span className="mono" style={{ fontSize: 12 }}>{fmt.money(item.price)}</span>
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
{fmt.dateShort(item.purchaseDate, tz)}
</span>
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
{(item.status === "active" || item.status === "checked-out")
? remainingShort(item)
: ""}
</span>
</div>
);
})}
</div>
</div>
)}
{!hasItems && (
<div
style={{
marginTop: 36,
padding: 40,
textAlign: "center",
color: "var(--ink-3)",
background: "var(--bg-2)",
borderRadius: "var(--r-md)",
border: "1px solid var(--line)",
}}
>
<div style={{ fontSize: 14, marginBottom: 4 }}>No inventory items yet</div>
<div style={{ fontSize: 12 }}>
Add inventory through the sidebar to start tracking this SKU.
</div>
</div>
)}
{hasItems && (
<div style={{ marginTop: 12, fontSize: 11, color: "var(--ink-3)", fontStyle: "italic" }}>
Cannot delete this SKU while it has inventory items.
</div>
)}
</div>
</div>
</div>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
paddingBottom: 12,
borderBottom: "1px solid var(--line)",
}}
>
<span style={{ color: "var(--ink-3)", fontSize: 12 }}>{label}</span>
<span style={{ fontSize: 13, fontWeight: 500, textAlign: "right" }}>{value}</span>
</div>
);
}
+329
View File
@@ -0,0 +1,329 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api.js";
import type { Bootstrap, Product } from "../../types.js";
import { TYPES } from "../../types.js";
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
export function AddSkuModal({
data,
onClose,
}: {
data: Bootstrap;
onClose: () => void;
}) {
const qc = useQueryClient();
const [sku, setSku] = useState("");
const [strainName, setStrainName] = useState("");
const [brandId, setBrandId] = useState<string>("");
const [typeId, setTypeId] = useState("Flower");
const [defaultThc, setDefaultThc] = useState("");
const [defaultCbd, setDefaultCbd] = useState("");
const selectedType = TYPES.find((t) => t.id === typeId) ?? TYPES[0]!;
const create = useMutation({
mutationFn: () =>
api.createProduct({
sku: sku.trim(),
strainName: strainName.trim(),
brandId: brandId || null,
type: typeId,
kind: selectedType.kind,
defaultThc: defaultThc ? parseFloat(defaultThc) : undefined,
defaultCbd: defaultCbd ? parseFloat(defaultCbd) : undefined,
defaultTotalCannabinoids:
defaultThc || defaultCbd
? (parseFloat(defaultThc) || 0) + (parseFloat(defaultCbd) || 0)
: undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
},
});
const matchingStrains = strainName.trim()
? data.strains.filter((s) =>
s.name.toLowerCase().includes(strainName.trim().toLowerCase()),
).slice(0, 5)
: [];
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 SKU" eyebrow="Catalog" onClose={onClose} />
<div style={{ padding: 32, display: "grid", gap: 16 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<Field label="SKU code">
<Input
autoFocus
value={sku}
onChange={(e) => setSku(e.target.value)}
placeholder="e.g. SKU-0042"
/>
</Field>
<Field label="Type">
<Select value={typeId} onChange={(e) => setTypeId(e.target.value)}>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.id} ({t.kind})
</option>
))}
</Select>
</Field>
</div>
<Field label="Strain name" hint={matchingStrains.length > 0 ? `Matching: ${matchingStrains.map((s) => s.name).join(", ")}` : undefined}>
<Input
value={strainName}
onChange={(e) => setStrainName(e.target.value)}
placeholder="e.g. Blue Dream"
/>
</Field>
<Field label="Brand">
<Select value={brandId} onChange={(e) => setBrandId(e.target.value)}>
<option value="">None</option>
{data.brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</Select>
</Field>
{selectedType.showCannabinoidPct && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<Field label="Default THC %" hint="Optional">
<Input
type="number"
step="0.1"
min="0"
max="100"
value={defaultThc}
onChange={(e) => setDefaultThc(e.target.value)}
placeholder="e.g. 22.5"
/>
</Field>
<Field label="Default CBD %" hint="Optional">
<Input
type="number"
step="0.1"
min="0"
max="100"
value={defaultCbd}
onChange={(e) => setDefaultCbd(e.target.value)}
placeholder="e.g. 0.5"
/>
</Field>
</div>
)}
{create.isError && (
<div style={{ fontSize: 12, color: "var(--terracotta)" }}>
{String(create.error instanceof Error ? create.error.message : create.error)}
</div>
)}
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>
Cancel
</Btn>
<Btn
variant="primary"
icon="check"
disabled={!sku.trim() || !strainName.trim() || create.isPending}
onClick={() => create.mutate()}
>
{create.isPending ? "Saving..." : "Add SKU"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
export function EditSkuModal({
data,
product,
onClose,
}: {
data: Bootstrap;
product: Product;
onClose: () => void;
}) {
const qc = useQueryClient();
const strain = data.strains.find((s) => s.id === product.strainId);
const [strainName, setStrainName] = useState(strain?.name ?? "");
const [brandId, setBrandId] = useState(product.brandId ?? "");
const [typeId, setTypeId] = useState(product.type);
const [defaultThc, setDefaultThc] = useState(
strain?.defaultThc != null ? String(strain.defaultThc) : "",
);
const [defaultCbd, setDefaultCbd] = useState(
strain?.defaultCbd != null ? String(strain.defaultCbd) : "",
);
const [defaultTotal, setDefaultTotal] = useState(
strain?.defaultTotalCannabinoids != null ? String(strain.defaultTotalCannabinoids) : "",
);
const [notes, setNotes] = useState(strain?.notes ?? "");
const selectedType = TYPES.find((t) => t.id === typeId) ?? TYPES[0]!;
const updateProduct = useMutation({
mutationFn: async () => {
await api.updateProduct(product.id, {
type: typeId,
kind: selectedType.kind,
strainName: strainName.trim(),
brandId: brandId || null,
});
if (strain) {
await api.updateStrain(strain.id, {
name: strainName.trim(),
defaultThc: defaultThc ? parseFloat(defaultThc) : null,
defaultCbd: defaultCbd ? parseFloat(defaultCbd) : null,
defaultTotalCannabinoids: defaultTotal ? parseFloat(defaultTotal) : null,
notes: notes.trim() || null,
});
}
},
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 SKU" eyebrow={`SKU · ${product.sku}`} onClose={onClose} />
<div style={{ padding: 32, display: "grid", gap: 16 }}>
<Field label="Strain name">
<Input
autoFocus
value={strainName}
onChange={(e) => setStrainName(e.target.value)}
placeholder="e.g. Blue Dream"
/>
</Field>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<Field label="Brand">
<Select value={brandId} onChange={(e) => setBrandId(e.target.value)}>
<option value="">None</option>
{data.brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</Select>
</Field>
<Field label="Type">
<Select value={typeId} onChange={(e) => setTypeId(e.target.value)}>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.id} ({t.kind})
</option>
))}
</Select>
</Field>
</div>
{selectedType.showCannabinoidPct && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
<Field label="Default THC %">
<Input
type="number"
step="0.1"
min="0"
max="100"
value={defaultThc}
onChange={(e) => setDefaultThc(e.target.value)}
/>
</Field>
<Field label="Default CBD %">
<Input
type="number"
step="0.1"
min="0"
max="100"
value={defaultCbd}
onChange={(e) => setDefaultCbd(e.target.value)}
/>
</Field>
<Field label="Default total %">
<Input
type="number"
step="0.1"
min="0"
max="100"
value={defaultTotal}
onChange={(e) => setDefaultTotal(e.target.value)}
/>
</Field>
</div>
)}
<Field label="Strain notes">
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional notes about this strain..."
/>
</Field>
{updateProduct.isError && (
<div style={{ fontSize: 12, color: "var(--terracotta)" }}>
{String(
updateProduct.error instanceof Error
? updateProduct.error.message
: updateProduct.error,
)}
</div>
)}
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>
Cancel
</Btn>
<Btn
variant="primary"
icon="check"
disabled={!strainName.trim() || updateProduct.isPending}
onClick={() => updateProduct.mutate()}
>
{updateProduct.isPending ? "Saving..." : "Save changes"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
+1
View File
@@ -25,6 +25,7 @@ const ICON_PATHS: Record<string, string> = {
tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01", tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01",
pocket: "M5 4h14v5l-2 3v5a3 3 0 01-3 3h-4a3 3 0 01-3-3v-5L5 9V4zM9 12v5M15 12v5", pocket: "M5 4h14v5l-2 3v5a3 3 0 01-3 3h-4a3 3 0 01-3-3v-5L5 9V4zM9 12v5M15 12v5",
shop: "M4 9V4h16v5M4 9v11h16V9M4 9h16M10 20v-6h4v6", shop: "M4 9V4h16v5M4 9v11h16V9M4 9h16M10 20v-6h4v6",
barcode: "M4 4v16M8 4v16M11 4v16M14 4v16M18 4v16M20 4v16",
}; };
export function Icon({ export function Icon({
+364
View File
@@ -0,0 +1,364 @@
import { useMemo, useState } from "react";
import type { Bootstrap, Product } from "../types.js";
import { TYPES, helpers } from "../types.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { getStoredTimezone } from "../tz.js";
import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js";
export interface SkuRow {
product: Product;
name: string;
brand: string;
itemCount: number;
activeCount: number;
totalSpend: number;
avgPrice: number;
avgCostPerGram: number | null;
lastPurchase: string | null;
avgRating: number | null;
ratingCount: number;
}
type SortKey = "name" | "items" | "spent" | "recent" | "rating";
const GRID_COLS = "32px 2fr 1fr 0.7fr 0.6fr 0.8fr 0.7fr 0.9fr";
function buildSkuRows(data: Bootstrap): SkuRow[] {
const strainMap = new Map(data.strains.map((s) => [s.id, s]));
return data.products.map((p) => {
const strain = strainMap.get(p.strainId);
const items = data.inventoryItems.filter((i) => i.productId === p.id);
const active = items.filter((i) => i.status === "active" || i.status === "checked-out");
const totalSpend = items.reduce((s, i) => s + i.price, 0);
const rated = items.filter((i) => i.rating != null);
const avgRating = rated.length > 0
? rated.reduce((s, i) => s + i.rating!, 0) / rated.length
: null;
let avgCostPerGram: number | null = null;
const cfg = TYPES.find((t) => t.id === p.type);
if (cfg && p.kind === "bulk") {
const totalGrams = items.reduce((s, i) => s + i.weight, 0);
if (totalGrams > 0) avgCostPerGram = totalSpend / totalGrams;
} else if (cfg && p.kind === "discrete") {
const totalGrams = items.reduce((s, i) => s + i.countOriginal * i.unitWeight, 0);
if (totalGrams > 0) avgCostPerGram = totalSpend / totalGrams;
}
const dates = items.map((i) => i.purchaseDate).sort();
const lastPurchase = dates.length > 0 ? dates[dates.length - 1]! : null;
return {
product: p,
name: strain?.name ?? "(unknown strain)",
brand: helpers.brandName(data, p.brandId),
itemCount: items.length,
activeCount: active.length,
totalSpend,
avgPrice: items.length > 0 ? totalSpend / items.length : 0,
avgCostPerGram,
lastPurchase,
avgRating,
ratingCount: rated.length,
};
});
}
export function SkusView({
data,
onSelectSku,
onAddSku,
}: {
data: Bootstrap;
onSelectSku: (p: Product) => void;
onAddSku: () => void;
}) {
const [typeFilter, setTypeFilter] = useState("all");
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<SortKey>("name");
const rows = useMemo(() => buildSkuRows(data), [data]);
const filtered = useMemo(() => {
let out = rows;
if (typeFilter !== "all") out = out.filter((r) => r.product.type === typeFilter);
if (search) {
const q = search.toLowerCase();
out = out.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.product.sku.toLowerCase().includes(q) ||
r.brand.toLowerCase().includes(q),
);
}
return out;
}, [rows, typeFilter, search]);
const sorted = useMemo(() => {
const copy = [...filtered];
if (sortBy === "name") copy.sort((a, b) => a.name.localeCompare(b.name));
else if (sortBy === "items") copy.sort((a, b) => b.itemCount - a.itemCount);
else if (sortBy === "spent") copy.sort((a, b) => b.totalSpend - a.totalSpend);
else if (sortBy === "recent")
copy.sort(
(a, b) =>
+(b.lastPurchase ? new Date(b.lastPurchase) : 0) -
+(a.lastPurchase ? new Date(a.lastPurchase) : 0),
);
else if (sortBy === "rating")
copy.sort((a, b) => (b.avgRating ?? -1) - (a.avgRating ?? -1));
return copy;
}, [filtered, 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)" }}>
{sorted.length} SKU{sorted.length === 1 ? "" : "s"}
</div>
<h1
className="serif"
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
>
SKUs
</h1>
</div>
<Btn variant="primary" icon="plus" onClick={onAddSku}>
Add SKU
</Btn>
</div>
<Card style={{ marginBottom: 14, padding: 14 }}>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<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, SKU, brand..."
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)",
}}
/>
{search && (
<button
onClick={() => setSearch("")}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 2,
display: "inline-flex",
color: "var(--ink-3)",
}}
>
<Icon name="close" size={12} />
</button>
)}
</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="name">Name (A-Z)</option>
<option value="items">Most items</option>
<option value="spent">Most spent</option>
<option value="recent">Recent purchase</option>
<option value="rating">Highest rated</option>
</Select>
</div>
</Card>
<Card padded={false}>
<SkuHeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && (
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
No SKUs match these filters.
</div>
)}
{sorted.map((r) => (
<SkuItemRow key={r.product.id} row={r} onClick={() => onSelectSku(r.product)} />
))}
</Card>
</div>
);
}
const COL_SORT: (SortKey | null)[] = [null, "name", null, null, "items", "spent", "rating", "recent"];
const COL_LABELS = ["", "Name", "Brand", "Type", "Items", "Spent", "Rating", "Last purchase"];
function SkuHeaderRow({
sortBy,
onSort,
}: {
sortBy: SortKey;
onSort: (k: SortKey) => void;
}) {
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",
alignItems: "center",
}}
>
{COL_LABELS.map((label, i) => {
const sk = COL_SORT[i];
if (!sk) return <div key={i}>{label}</div>;
const active = sortBy === sk;
return (
<button
key={i}
onClick={() => onSort(sk)}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
fontSize: "inherit",
textTransform: "inherit",
letterSpacing: "inherit",
fontWeight: active ? 600 : "inherit",
color: active ? "var(--ink)" : "var(--ink-3)",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
{label}
{active && <span style={{ fontSize: 9 }}></span>}
</button>
);
})}
</div>
);
}
function SkuItemRow({ row, onClick }: { row: SkuRow; onClick: () => void }) {
return (
<div
onClick={onClick}
className="inv-row"
style={{
display: "grid",
gridTemplateColumns: GRID_COLS,
columnGap: 16,
padding: "14px 20px",
borderBottom: "1px solid var(--line)",
alignItems: "center",
cursor: "pointer",
fontSize: 13,
}}
>
<div style={{ fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)" }}>
{TYPE_GLYPHS[row.product.type]}
</div>
<div style={{ minWidth: 0 }}>
<div
style={{
fontWeight: 500,
color: "var(--ink)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.name}
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)", fontFamily: "var(--mono)" }}>
{row.product.sku}
</div>
</div>
<div style={{ color: "var(--ink-2)" }}>{row.brand}</div>
<div style={{ color: "var(--ink-3)", fontSize: 12 }}>
{row.product.type} · {row.product.kind}
</div>
<div style={{ fontFamily: "var(--mono)" }}>
{row.itemCount}
{row.activeCount > 0 && row.activeCount < row.itemCount && (
<span style={{ color: "var(--ink-3)", fontSize: 11 }}> ({row.activeCount} active)</span>
)}
</div>
<div style={{ fontFamily: "var(--mono)" }}>{row.itemCount > 0 ? fmt.money(row.totalSpend) : "—"}</div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{row.avgRating != null ? (
<>
<Icon name="star" size={12} color="var(--amber)" />
<span style={{ fontFamily: "var(--mono)", fontSize: 12 }}>
{row.avgRating.toFixed(1)}
</span>
<span style={{ fontSize: 10, color: "var(--ink-3)" }}>({row.ratingCount})</span>
</>
) : (
<span style={{ color: "var(--ink-3)", fontSize: 12, fontStyle: "italic" }}></span>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
{row.lastPurchase ? fmt.dateShort(row.lastPurchase, getStoredTimezone()) : "—"}
</span>
<span
className="inv-row-chevron"
style={{ color: "var(--ink-3)", marginLeft: "auto", fontSize: 14 }}
>
</span>
</div>
</div>
);
}