Overhaul brands tab with table view, search/sort, and detail drawer
Build and push image / build (push) Successful in 52s
Build and push image / build (push) Successful in 52s
Replaces the simple card grid with a table matching the inventory and SKU view patterns: sortable columns (name, SKUs, items, spend, rating, last purchase), search by brand name, and a right-side detail drawer showing aggregate stats, SKU list, and recent inventory items with cross-navigation to the SKU and inventory detail drawers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+36
-1
@@ -18,6 +18,7 @@ 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 { SkuDetail } from "./components/SkuDetail.js";
|
||||||
|
import { BrandDetail } from "./components/BrandDetail.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";
|
||||||
@@ -74,6 +75,7 @@ export function App() {
|
|||||||
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 [selectedSku, setSelectedSku] = useState<Product | null>(null);
|
||||||
|
const [selectedBrand, setSelectedBrand] = useState<Brand | null>(null);
|
||||||
const [modalProduct, setModalProduct] = useState<Product | null>(null);
|
const [modalProduct, setModalProduct] = useState<Product | null>(null);
|
||||||
|
|
||||||
const [theme, setTheme] = useState<ThemeKey>(
|
const [theme, setTheme] = useState<ThemeKey>(
|
||||||
@@ -115,6 +117,13 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBrand && data) {
|
||||||
|
const fresh = data.brands.find((b) => b.id === selectedBrand.id);
|
||||||
|
if (fresh && fresh !== selectedBrand) setSelectedBrand(fresh);
|
||||||
|
}
|
||||||
|
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
setModalItem(null);
|
setModalItem(null);
|
||||||
setModal("add");
|
setModal("add");
|
||||||
@@ -257,7 +266,7 @@ export function App() {
|
|||||||
<ShopsView data={data} onAddShop={() => setModal("addShop")} onEditShop={(shop) => { setModalShop(shop); setModal("editShop"); }} />
|
<ShopsView data={data} onAddShop={() => setModal("addShop")} onEditShop={(shop) => { setModalShop(shop); setModal("editShop"); }} />
|
||||||
} />
|
} />
|
||||||
<Route path="/brands" element={
|
<Route path="/brands" element={
|
||||||
<BrandsView data={data} onAddBrand={() => setModal("addBrand")} onEditBrand={(brand) => { setModalBrand(brand); setModal("editBrand"); }} />
|
<BrandsView data={data} onSelectBrand={setSelectedBrand} onAddBrand={() => setModal("addBrand")} />
|
||||||
} />
|
} />
|
||||||
<Route path="/charts" element={<ChartsView data={data} stats={stats} />} />
|
<Route path="/charts" element={<ChartsView data={data} stats={stats} />} />
|
||||||
<Route path="/settings" element={
|
<Route path="/settings" element={
|
||||||
@@ -302,6 +311,32 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedBrand && (
|
||||||
|
<BrandDetail
|
||||||
|
brand={selectedBrand}
|
||||||
|
data={data}
|
||||||
|
onClose={() => setSelectedBrand(null)}
|
||||||
|
onEdit={() => {
|
||||||
|
setModalBrand(selectedBrand);
|
||||||
|
setModal("editBrand");
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
api.deleteBrand(selectedBrand.id).then(() => {
|
||||||
|
setSelectedBrand(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSelectSku={(p) => {
|
||||||
|
setSelectedBrand(null);
|
||||||
|
setSelectedSku(p);
|
||||||
|
}}
|
||||||
|
onSelectItem={(i) => {
|
||||||
|
setSelectedBrand(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)} />
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Bootstrap, Brand, 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 BrandDetail({
|
||||||
|
brand,
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onSelectSku,
|
||||||
|
onSelectItem,
|
||||||
|
}: {
|
||||||
|
brand: Brand;
|
||||||
|
data: Bootstrap;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onSelectSku: (p: Product) => void;
|
||||||
|
onSelectItem: (i: Item) => void;
|
||||||
|
}) {
|
||||||
|
const products = data.products.filter((p) => p.brandId === brand.id);
|
||||||
|
const strainMap = new Map(data.strains.map((s) => [s.id, s]));
|
||||||
|
const allItems = enrichItems(data).filter((i) => i.brandId === brand.id);
|
||||||
|
const hasItems = allItems.length > 0;
|
||||||
|
|
||||||
|
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
|
const consumed = allItems.filter((i) => i.status === "consumed");
|
||||||
|
const gone = allItems.filter((i) => i.status === "gone");
|
||||||
|
|
||||||
|
const totalSpend = allItems.reduce((s, i) => s + i.price, 0);
|
||||||
|
const avgPrice = hasItems ? totalSpend / allItems.length : 0;
|
||||||
|
|
||||||
|
const rated = allItems.filter((i) => i.rating != null);
|
||||||
|
const avgRating =
|
||||||
|
rated.length > 0 ? rated.reduce((s, i) => s + i.rating!, 0) / rated.length : null;
|
||||||
|
|
||||||
|
const sortedItems = [...allItems].sort(
|
||||||
|
(a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate),
|
||||||
|
);
|
||||||
|
const recentItems = sortedItems.slice(0, 20);
|
||||||
|
|
||||||
|
const todayStr = getToday(getStoredTimezone());
|
||||||
|
const tz = getStoredTimezone();
|
||||||
|
|
||||||
|
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][] = [
|
||||||
|
["SKUs", String(products.length)],
|
||||||
|
["Purchases", String(allItems.length)],
|
||||||
|
["Total spent", hasItems ? fmt.money(totalSpend) : "—"],
|
||||||
|
["Avg price", hasItems ? fmt.money(avgPrice) : "—"],
|
||||||
|
];
|
||||||
|
|
||||||
|
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)" }}>
|
||||||
|
Brand
|
||||||
|
</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" }}>
|
||||||
|
<h1
|
||||||
|
className="serif"
|
||||||
|
style={{
|
||||||
|
fontSize: 48,
|
||||||
|
margin: "0 0 4px",
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brand.name}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{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" }}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 }}>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{products.length > 0 && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
SKUs ({products.length})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{products.map((p, idx) => {
|
||||||
|
const strain = strainMap.get(p.strainId);
|
||||||
|
const itemCount = allItems.filter((i) => i.productId === p.id).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => onSelectSku(p)}
|
||||||
|
className="inv-row"
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: idx < products.length - 1 ? "1px solid var(--line)" : "none",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "24px 1fr auto auto auto",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
background: "var(--surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: "var(--serif)", fontSize: 16, color: "var(--ink-3)" }}>
|
||||||
|
{TYPE_GLYPHS[p.type]}
|
||||||
|
</span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 13,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{strain?.name ?? "(unknown)"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||||
|
{p.type} · {p.kind}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>
|
||||||
|
{p.sku}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
{itemCount} item{itemCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="inv-row-chevron"
|
||||||
|
style={{ color: "var(--ink-3)", fontSize: 14 }}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||||||
|
Recent purchases ({allItems.length})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recentItems.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 < recentItems.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 }}>
|
||||||
|
Products from this brand will appear here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop: 12, fontSize: 11, color: "var(--ink-3)", fontStyle: "italic" }}>
|
||||||
|
Cannot delete this brand while it has associated inventory items.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+289
-95
@@ -1,29 +1,99 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { Bootstrap, Brand } from "../types.js";
|
import type { Bootstrap, Brand } from "../types.js";
|
||||||
import { api } from "../api.js";
|
import { fmt } from "../format.js";
|
||||||
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
|
import { getStoredTimezone } from "../tz.js";
|
||||||
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
|
import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js";
|
||||||
|
|
||||||
|
interface BrandRow {
|
||||||
|
brand: Brand;
|
||||||
|
skuCount: number;
|
||||||
|
itemCount: number;
|
||||||
|
totalSpend: number;
|
||||||
|
avgRating: number | null;
|
||||||
|
ratingCount: number;
|
||||||
|
lastPurchase: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortKey = "name" | "skus" | "items" | "spent" | "rating" | "recent";
|
||||||
|
|
||||||
|
const GRID_COLS = "2fr 0.7fr 0.7fr 0.8fr 0.8fr 1fr";
|
||||||
|
|
||||||
|
function buildBrandRows(data: Bootstrap): BrandRow[] {
|
||||||
|
const productsByBrand = new Map<string, typeof data.products>();
|
||||||
|
for (const p of data.products) {
|
||||||
|
if (!p.brandId) continue;
|
||||||
|
const arr = productsByBrand.get(p.brandId);
|
||||||
|
if (arr) arr.push(p);
|
||||||
|
else productsByBrand.set(p.brandId, [p]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsByProduct = new Map<string, typeof data.inventoryItems>();
|
||||||
|
for (const i of data.inventoryItems) {
|
||||||
|
const arr = itemsByProduct.get(i.productId);
|
||||||
|
if (arr) arr.push(i);
|
||||||
|
else itemsByProduct.set(i.productId, [i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.brands.map((brand) => {
|
||||||
|
const products = productsByBrand.get(brand.id) ?? [];
|
||||||
|
const items = products.flatMap((p) => itemsByProduct.get(p.id) ?? []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const dates = items.map((i) => i.purchaseDate).sort();
|
||||||
|
const lastPurchase = dates.length > 0 ? dates[dates.length - 1]! : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
brand,
|
||||||
|
skuCount: products.length,
|
||||||
|
itemCount: items.length,
|
||||||
|
totalSpend,
|
||||||
|
avgRating,
|
||||||
|
ratingCount: rated.length,
|
||||||
|
lastPurchase,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function BrandsView({
|
export function BrandsView({
|
||||||
data,
|
data,
|
||||||
|
onSelectBrand,
|
||||||
onAddBrand,
|
onAddBrand,
|
||||||
onEditBrand,
|
|
||||||
}: {
|
}: {
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
|
onSelectBrand: (brand: Brand) => void;
|
||||||
onAddBrand: () => void;
|
onAddBrand: () => void;
|
||||||
onEditBrand: (brand: Brand) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const [search, setSearch] = useState("");
|
||||||
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
|
const [sortBy, setSortBy] = useState<SortKey>("name");
|
||||||
|
|
||||||
const remove = useMutation({
|
const rows = useMemo(() => buildBrandRows(data), [data]);
|
||||||
mutationFn: (id: string) => api.deleteBrand(id),
|
|
||||||
onSuccess: () => {
|
const filtered = useMemo(() => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
if (!search) return rows;
|
||||||
setConfirmDelete(null);
|
const q = search.toLowerCase();
|
||||||
},
|
return rows.filter((r) => r.brand.name.toLowerCase().includes(q));
|
||||||
});
|
}, [rows, search]);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const copy = [...filtered];
|
||||||
|
if (sortBy === "name") copy.sort((a, b) => a.brand.name.localeCompare(b.brand.name));
|
||||||
|
else if (sortBy === "skus") copy.sort((a, b) => b.skuCount - a.skuCount);
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -33,10 +103,17 @@ export function BrandsView({
|
|||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
{data.brands.length} brand{data.brands.length === 1 ? "" : "s"}
|
{sorted.length} brand{sorted.length === 1 ? "" : "s"}
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="serif"
|
className="serif"
|
||||||
@@ -45,10 +122,9 @@ export function BrandsView({
|
|||||||
Brands
|
Brands
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Btn variant="primary" icon="plus" onClick={onAddBrand}>New brand</Btn>
|
<Btn variant="primary" icon="plus" onClick={onAddBrand}>
|
||||||
</div>
|
New brand
|
||||||
<div style={{ fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600 }}>
|
</Btn>
|
||||||
Brands you've purchased from. Used in the brand dropdown when adding a new product.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.brands.length === 0 ? (
|
{data.brands.length === 0 ? (
|
||||||
@@ -57,87 +133,205 @@ export function BrandsView({
|
|||||||
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
|
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
|
||||||
Add a brand to start tagging your purchases.
|
Add a brand to start tagging your purchases.
|
||||||
</div>
|
</div>
|
||||||
<Btn variant="primary" icon="plus" onClick={onAddBrand}>Add your first brand</Btn>
|
<Btn variant="primary" icon="plus" onClick={onAddBrand}>
|
||||||
|
Add your first brand
|
||||||
|
</Btn>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<>
|
||||||
style={{
|
<Card style={{ marginBottom: 14, padding: 14 }}>
|
||||||
display: "grid",
|
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
<div
|
||||||
gap: 14,
|
style={{
|
||||||
}}
|
flex: 1,
|
||||||
>
|
minWidth: 220,
|
||||||
{data.brands.map((b) => {
|
display: "flex",
|
||||||
// Count instances whose product points at this brand.
|
alignItems: "center",
|
||||||
const productIds = new Set(
|
gap: 8,
|
||||||
data.products.filter((p) => p.brandId === b.id).map((p) => p.id),
|
background: "var(--bg-2)",
|
||||||
);
|
border: "1px solid var(--line)",
|
||||||
const itemCount = data.inventoryItems.filter((i) =>
|
borderRadius: "var(--r-md)",
|
||||||
productIds.has(i.productId),
|
padding: "0 10px",
|
||||||
).length;
|
}}
|
||||||
return (
|
>
|
||||||
<Card key={b.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<input
|
||||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1.1 }}>
|
placeholder="Search by brand name..."
|
||||||
{b.name}
|
value={search}
|
||||||
</div>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
</div>
|
|
||||||
<Pill tone="outline">
|
|
||||||
{itemCount} purchase{itemCount === 1 ? "" : "s"}
|
|
||||||
</Pill>
|
|
||||||
<button
|
|
||||||
onClick={() => onEditBrand(b)}
|
|
||||||
title="Edit brand"
|
|
||||||
aria-label={`Edit brand ${b.name}`}
|
|
||||||
style={{
|
style={{
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
border: "none",
|
||||||
padding: 4,
|
outline: "none",
|
||||||
borderRadius: "var(--r-sm)",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--ink-3)",
|
|
||||||
display: "inline-flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="edit" size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete({ id: b.id, name: b.name, count: itemCount })}
|
|
||||||
title="Remove brand"
|
|
||||||
aria-label={`Remove brand ${b.name}`}
|
|
||||||
disabled={remove.isPending}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
border: "none",
|
padding: "8px 0",
|
||||||
padding: 4,
|
fontSize: 13,
|
||||||
borderRadius: "var(--r-sm)",
|
flex: 1,
|
||||||
cursor: remove.isPending ? "wait" : "pointer",
|
color: "var(--ink)",
|
||||||
color: "var(--ink-3)",
|
|
||||||
display: "inline-flex",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Icon name="bin" size={14} />
|
{search && (
|
||||||
</button>
|
<button
|
||||||
</Card>
|
onClick={() => setSearch("")}
|
||||||
);
|
style={{
|
||||||
})}
|
background: "transparent",
|
||||||
</div>
|
border: "none",
|
||||||
)}
|
cursor: "pointer",
|
||||||
|
padding: 2,
|
||||||
|
display: "inline-flex",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{confirmDelete && (
|
<Select
|
||||||
<ConfirmDialog
|
value={sortBy}
|
||||||
title={`Delete "${confirmDelete.name}"?`}
|
onChange={(e) => setSortBy(e.target.value as SortKey)}
|
||||||
message={
|
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
|
||||||
confirmDelete.count > 0
|
>
|
||||||
? `${confirmDelete.count} inventory item${confirmDelete.count === 1 ? "" : "s"} will be unbranded.`
|
<option value="name">Name (A-Z)</option>
|
||||||
: "This brand will be permanently removed."
|
<option value="skus">Most SKUs</option>
|
||||||
}
|
<option value="items">Most items</option>
|
||||||
confirmLabel="Delete brand"
|
<option value="spent">Most spent</option>
|
||||||
onConfirm={() => remove.mutate(confirmDelete.id)}
|
<option value="recent">Recent purchase</option>
|
||||||
onCancel={() => setConfirmDelete(null)}
|
<option value="rating">Highest rated</option>
|
||||||
isPending={remove.isPending}
|
</Select>
|
||||||
/>
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padded={false}>
|
||||||
|
<BrandHeaderRow sortBy={sortBy} onSort={setSortBy} />
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
||||||
|
No brands match these filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sorted.map((r) => (
|
||||||
|
<BrandItemRow key={r.brand.id} row={r} onClick={() => onSelectBrand(r.brand)} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COL_SORT: (SortKey | null)[] = ["name", "skus", "items", "spent", "rating", "recent"];
|
||||||
|
const COL_LABELS = ["Name", "SKUs", "Items", "Spent", "Rating", "Last purchase"];
|
||||||
|
|
||||||
|
function BrandHeaderRow({
|
||||||
|
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 BrandItemRow({ row, onClick }: { row: BrandRow; 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={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--ink)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.brand.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: "var(--mono)" }}>{row.skuCount}</div>
|
||||||
|
<div style={{ fontFamily: "var(--mono)" }}>{row.itemCount}</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