Add SKUs section with list view, detail drawer, and CRUD modals
Build and push image / build (push) Successful in 51s
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:
+51
-2
@@ -1,14 +1,15 @@
|
||||
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 { 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 { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js";
|
||||
import { computeStats } from "./stats.js";
|
||||
import { Sidebar } from "./components/Sidebar.js";
|
||||
import { Dashboard } from "./views/Dashboard.js";
|
||||
import { Inventory } from "./views/Inventory.js";
|
||||
import { SkusView } from "./views/SkusView.js";
|
||||
import { BinsView } from "./views/BinsView.js";
|
||||
import { BrandsView } from "./views/BrandsView.js";
|
||||
import { ShopsView } from "./views/ShopsView.js";
|
||||
@@ -16,6 +17,7 @@ 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 { SkuDetail } from "./components/SkuDetail.js";
|
||||
import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js";
|
||||
import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js";
|
||||
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
EditBrandModal,
|
||||
EditShopModal,
|
||||
} from "./components/modals/CatalogModals.js";
|
||||
import { AddSkuModal, EditSkuModal } from "./components/modals/SkuModals.js";
|
||||
import { BulkEditModal } from "./components/modals/BulkEditModal.js";
|
||||
import { BulkConsumeModal } from "./components/modals/BulkConsumeModal.js";
|
||||
import { BulkCheckoutModal } from "./components/modals/BulkCheckoutModal.js";
|
||||
@@ -57,9 +60,12 @@ type ModalKey =
|
||||
| "bulkCheckout"
|
||||
| "bulkCheckin"
|
||||
| "bulkGone"
|
||||
| "addSku"
|
||||
| "editSku"
|
||||
| null;
|
||||
|
||||
export function App() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<Item | null>(null);
|
||||
const [modal, setModal] = useState<ModalKey>(null);
|
||||
const [modalItem, setModalItem] = useState<Item | null>(null);
|
||||
@@ -67,6 +73,8 @@ export function App() {
|
||||
const [modalBrand, setModalBrand] = useState<Brand | null>(null);
|
||||
const [modalShop, setModalShop] = useState<Shop | null>(null);
|
||||
const [bulkItems, setBulkItems] = useState<Item[]>([]);
|
||||
const [selectedSku, setSelectedSku] = useState<Product | null>(null);
|
||||
const [modalProduct, setModalProduct] = useState<Product | null>(null);
|
||||
|
||||
const [theme, setTheme] = useState<ThemeKey>(
|
||||
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
||||
@@ -100,6 +108,13 @@ export function App() {
|
||||
}
|
||||
}, [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 = () => {
|
||||
setModalItem(null);
|
||||
setModal("add");
|
||||
@@ -217,6 +232,13 @@ export function App() {
|
||||
onBulkGone={openBulkGone}
|
||||
/>
|
||||
} />
|
||||
<Route path="/skus" element={
|
||||
<SkusView
|
||||
data={data}
|
||||
onSelectSku={setSelectedSku}
|
||||
onAddSku={() => setModal("addSku")}
|
||||
/>
|
||||
} />
|
||||
<Route path="/custody" element={
|
||||
<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 === "edit" && modalItem && (
|
||||
<EditInventoryFlow data={data} item={modalItem} onClose={() => setModal(null)} />
|
||||
@@ -297,6 +341,11 @@ export function App() {
|
||||
{modal === "bulkGone" && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Icon } from "./primitives/index.js";
|
||||
export type ViewKey =
|
||||
| "dashboard"
|
||||
| "inventory"
|
||||
| "skus"
|
||||
| "custody"
|
||||
| "bins"
|
||||
| "shops"
|
||||
@@ -14,6 +15,7 @@ export type ViewKey =
|
||||
const NAV: { path: string; label: string; icon: string }[] = [
|
||||
{ path: "/", label: "Dashboard", icon: "home" },
|
||||
{ path: "/inventory", label: "Inventory", icon: "box" },
|
||||
{ path: "/skus", label: "SKUs", icon: "barcode" },
|
||||
{ path: "/custody", label: "My Custody", icon: "pocket" },
|
||||
{ path: "/bins", label: "Bins", icon: "bin" },
|
||||
{ path: "/shops", label: "Shops", icon: "shop" },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const ICON_PATHS: Record<string, string> = {
|
||||
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",
|
||||
shop: "M4 9V4h16v5M4 9v11h16V9M4 9h16M10 20v-6h4v6",
|
||||
barcode: "M4 4v16M8 4v16M11 4v16M14 4v16M18 4v16M20 4v16",
|
||||
};
|
||||
|
||||
export function Icon({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user