From 8f09504f26bb881ab6d8d6b2ab22608d7adebcb6 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 8 May 2026 15:41:37 -0400 Subject: [PATCH] Overhaul brands tab with table view, search/sort, and detail drawer 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 --- web/src/App.tsx | 37 ++- web/src/components/BrandDetail.tsx | 352 ++++++++++++++++++++++++++ web/src/views/BrandsView.tsx | 384 ++++++++++++++++++++++------- 3 files changed, 677 insertions(+), 96 deletions(-) create mode 100644 web/src/components/BrandDetail.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 72415a0..7f683e7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,7 @@ 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 { BrandDetail } from "./components/BrandDetail.js"; import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js"; import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js"; import { ConsumeFlow } from "./components/modals/ConsumeFlow.js"; @@ -74,6 +75,7 @@ export function App() { const [modalShop, setModalShop] = useState(null); const [bulkItems, setBulkItems] = useState([]); const [selectedSku, setSelectedSku] = useState(null); + const [selectedBrand, setSelectedBrand] = useState(null); const [modalProduct, setModalProduct] = useState(null); const [theme, setTheme] = useState( @@ -115,6 +117,13 @@ export function App() { } }, [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 = () => { setModalItem(null); setModal("add"); @@ -257,7 +266,7 @@ export function App() { setModal("addShop")} onEditShop={(shop) => { setModalShop(shop); setModal("editShop"); }} /> } /> setModal("addBrand")} onEditBrand={(brand) => { setModalBrand(brand); setModal("editBrand"); }} /> + setModal("addBrand")} /> } /> } /> )} + {selectedBrand && ( + 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" && setModal(null)} />} {modal === "edit" && modalItem && ( setModal(null)} /> diff --git a/web/src/components/BrandDetail.tsx b/web/src/components/BrandDetail.tsx new file mode 100644 index 0000000..7c5d456 --- /dev/null +++ b/web/src/components/BrandDetail.tsx @@ -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 ( +
+
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)", + }} + > +
+
+ Brand +
+
+ + + +
+
+ +
+

+ {brand.name} +

+ + {hasItems && ( +
+ {statCards.map(([l, v], i) => ( +
+
{l}
+
+ {v} +
+
+ ))} +
+ )} + + {hasItems && ( +
+
+ Lifecycle +
+
+ {active.length > 0 && {active.length} active} + {consumed.length > 0 && {consumed.length} consumed} + {gone.length > 0 && {gone.length} gone} +
+
+ )} + + {avgRating != null && ( +
+
+ Ratings +
+
+
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+ + {avgRating.toFixed(1)} + + + from {rated.length} review{rated.length === 1 ? "" : "s"} + +
+
+ )} + + {products.length > 0 && ( +
+
+ SKUs ({products.length}) +
+
+ {products.map((p, idx) => { + const strain = strainMap.get(p.strainId); + const itemCount = allItems.filter((i) => i.productId === p.id).length; + return ( +
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", + }} + > + + {TYPE_GLYPHS[p.type]} + +
+
+ {strain?.name ?? "(unknown)"} +
+
+ {p.type} · {p.kind} +
+
+ + {p.sku} + + + {itemCount} item{itemCount === 1 ? "" : "s"} + + + › + +
+ ); + })} +
+
+ )} + + {hasItems && ( +
+
+ Recent purchases ({allItems.length}) +
+
+ {recentItems.map((item, idx) => { + const isInactive = item.status !== "active" && item.status !== "checked-out"; + return ( +
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, + }} + > + {item.assetId} + + {item.status === "consumed" && Consumed} + {item.status === "gone" && Gone} + {item.status === "checked-out" && Checked out} + {item.status === "active" && helpers.auditOverdue(item, todayStr) && ( + Audit due + )} + {item.status === "active" && !helpers.auditOverdue(item, todayStr) && ( + Active + )} + + {fmt.money(item.price)} + + {fmt.dateShort(item.purchaseDate, tz)} + + + {(item.status === "active" || item.status === "checked-out") + ? remainingShort(item) + : ""} + +
+ ); + })} +
+
+ )} + + {!hasItems && ( +
+
No inventory items yet
+
+ Products from this brand will appear here. +
+
+ )} + + {hasItems && ( +
+ Cannot delete this brand while it has associated inventory items. +
+ )} +
+
+
+ ); +} diff --git a/web/src/views/BrandsView.tsx b/web/src/views/BrandsView.tsx index fb51c58..6aaadbf 100644 --- a/web/src/views/BrandsView.tsx +++ b/web/src/views/BrandsView.tsx @@ -1,29 +1,99 @@ -import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; import type { Bootstrap, Brand } from "../types.js"; -import { api } from "../api.js"; -import { Btn, Card, Pill, Icon } from "../components/primitives/index.js"; -import { ConfirmDialog } from "../components/modals/ConfirmDialog.js"; +import { fmt } from "../format.js"; +import { getStoredTimezone } from "../tz.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(); + 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(); + 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({ data, + onSelectBrand, onAddBrand, - onEditBrand, }: { data: Bootstrap; + onSelectBrand: (brand: Brand) => void; onAddBrand: () => void; - onEditBrand: (brand: Brand) => void; }) { - const qc = useQueryClient(); - const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null); + const [search, setSearch] = useState(""); + const [sortBy, setSortBy] = useState("name"); - const remove = useMutation({ - mutationFn: (id: string) => api.deleteBrand(id), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["bootstrap"] }); - setConfirmDelete(null); - }, - }); + const rows = useMemo(() => buildBrandRows(data), [data]); + + const filtered = useMemo(() => { + if (!search) return rows; + 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 (
-
+
- {data.brands.length} brand{data.brands.length === 1 ? "" : "s"} + {sorted.length} brand{sorted.length === 1 ? "" : "s"}

- New brand -
-
- Brands you've purchased from. Used in the brand dropdown when adding a new product. + + New brand +
{data.brands.length === 0 ? ( @@ -57,87 +133,205 @@ export function BrandsView({
Add a brand to start tagging your purchases.
- Add your first brand + + Add your first brand + ) : ( -
- {data.brands.map((b) => { - // Count instances whose product points at this brand. - const productIds = new Set( - data.products.filter((p) => p.brandId === b.id).map((p) => p.id), - ); - const itemCount = data.inventoryItems.filter((i) => - productIds.has(i.productId), - ).length; - return ( - -
-
- {b.name} -
-
- - {itemCount} purchase{itemCount === 1 ? "" : "s"} - - - -
- ); - })} -
- )} + /> + {search && ( + + )} +
- {confirmDelete && ( - 0 - ? `${confirmDelete.count} inventory item${confirmDelete.count === 1 ? "" : "s"} will be unbranded.` - : "This brand will be permanently removed." - } - confirmLabel="Delete brand" - onConfirm={() => remove.mutate(confirmDelete.id)} - onCancel={() => setConfirmDelete(null)} - isPending={remove.isPending} - /> + +
+ + + + + {sorted.length === 0 && ( +
+ No brands match these filters. +
+ )} + {sorted.map((r) => ( + onSelectBrand(r.brand)} /> + ))} +
+ )} ); } + +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 ( +
+ {COL_LABELS.map((label, i) => { + const sk = COL_SORT[i]; + if (!sk) return
{label}
; + const active = sortBy === sk; + return ( + + ); + })} +
+ ); +} + +function BrandItemRow({ row, onClick }: { row: BrandRow; onClick: () => void }) { + return ( +
+
+ {row.brand.name} +
+
{row.skuCount}
+
{row.itemCount}
+
+ {row.itemCount > 0 ? fmt.money(row.totalSpend) : "—"} +
+
+ {row.avgRating != null ? ( + <> + + + {row.avgRating.toFixed(1)} + + ({row.ratingCount}) + + ) : ( + + )} +
+
+ + {row.lastPurchase ? fmt.dateShort(row.lastPurchase, getStoredTimezone()) : "—"} + + + › + +
+
+ ); +}