From 9e31a6ad00aa1b159da07255941df4c4320c6f61 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 8 May 2026 15:50:25 -0400 Subject: [PATCH] Overhaul shops tab with table view, search/sort, and detail drawer Replaces the card grid with a table matching the brands/SKU view patterns: sortable columns (name, items, spend, rating, last purchase), search by name or location, and a right-side detail drawer showing aggregate stats, lifecycle pills, unique brands list, and recent inventory items with cross-navigation to the brand and inventory detail drawers. Location appears as a subtitle under the shop name in table rows. Co-Authored-By: Claude Opus 4.6 --- web/src/App.tsx | 37 ++- web/src/components/ShopDetail.tsx | 337 ++++++++++++++++++++++++++ web/src/views/ShopsView.tsx | 381 ++++++++++++++++++++++-------- 3 files changed, 660 insertions(+), 95 deletions(-) create mode 100644 web/src/components/ShopDetail.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 7f683e7..ae747d7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,6 +19,7 @@ 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 { ShopDetail } from "./components/ShopDetail.js"; import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js"; import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js"; import { ConsumeFlow } from "./components/modals/ConsumeFlow.js"; @@ -76,6 +77,7 @@ export function App() { const [bulkItems, setBulkItems] = useState([]); const [selectedSku, setSelectedSku] = useState(null); const [selectedBrand, setSelectedBrand] = useState(null); + const [selectedShop, setSelectedShop] = useState(null); const [modalProduct, setModalProduct] = useState(null); const [theme, setTheme] = useState( @@ -124,6 +126,13 @@ export function App() { } }, [data]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (selectedShop && data) { + const fresh = data.shops.find((s) => s.id === selectedShop.id); + if (fresh && fresh !== selectedShop) setSelectedShop(fresh); + } + }, [data]); // eslint-disable-line react-hooks/exhaustive-deps + const openAdd = () => { setModalItem(null); setModal("add"); @@ -263,7 +272,7 @@ export function App() { setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} /> } /> setModal("addShop")} onEditShop={(shop) => { setModalShop(shop); setModal("editShop"); }} /> + setModal("addShop")} /> } /> setModal("addBrand")} /> @@ -337,6 +346,32 @@ export function App() { /> )} + {selectedShop && ( + setSelectedShop(null)} + onEdit={() => { + setModalShop(selectedShop); + setModal("editShop"); + }} + onDelete={() => { + api.deleteShop(selectedShop.id).then(() => { + setSelectedShop(null); + queryClient.invalidateQueries({ queryKey: ["bootstrap"] }); + }); + }} + onSelectBrand={(b) => { + setSelectedShop(null); + setSelectedBrand(b); + }} + onSelectItem={(i) => { + setSelectedShop(null); + setSelected(i); + }} + /> + )} + {modal === "add" && setModal(null)} />} {modal === "edit" && modalItem && ( setModal(null)} /> diff --git a/web/src/components/ShopDetail.tsx b/web/src/components/ShopDetail.tsx new file mode 100644 index 0000000..0d2c4b7 --- /dev/null +++ b/web/src/components/ShopDetail.tsx @@ -0,0 +1,337 @@ +import { useEffect } from "react"; +import type { Bootstrap, Shop, Brand, Product, Item } from "../types.js"; +import { helpers, enrichItems } from "../types.js"; +import { getToday, getStoredTimezone } from "../tz.js"; +import { fmt } from "../format.js"; +import { Btn, Pill, Icon } from "./primitives/index.js"; +import { remainingShort } from "../stats.js"; + +export function ShopDetail({ + shop, + data, + onClose, + onEdit, + onDelete, + onSelectBrand, + onSelectItem, +}: { + shop: Shop; + data: Bootstrap; + onClose: () => void; + onEdit: () => void; + onDelete: () => void; + onSelectBrand: (b: Brand) => void; + onSelectItem: (i: Item) => void; +}) { + const allItems = enrichItems(data).filter((i) => i.shopId === shop.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 brandIds = [...new Set(allItems.map((i) => i.brandId).filter(Boolean))] as string[]; + const brands = brandIds + .map((id) => data.brands.find((b) => b.id === id)) + .filter(Boolean) as Brand[]; + + 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][] = [ + ["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)", + }} + > +
+
+ Shop +
+
+ + + +
+
+ +
+

+ {shop.name} +

+ {shop.location && ( +
+ {shop.location} +
+ )} + + {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"} + +
+
+ )} + + {brands.length > 0 && ( +
+
+ Brands ({brands.length}) +
+
+ {brands.map((b, idx) => { + const brandItemCount = allItems.filter((i) => i.brandId === b.id).length; + return ( +
onSelectBrand(b)} + className="inv-row" + style={{ + padding: "12px 16px", + borderBottom: idx < brands.length - 1 ? "1px solid var(--line)" : "none", + display: "grid", + gridTemplateColumns: "1fr auto auto", + alignItems: "center", + gap: 12, + background: "var(--surface)", + cursor: "pointer", + }} + > +
{b.name}
+ + {brandItemCount} item{brandItemCount === 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
+
+ Purchases from this shop will appear here. +
+
+ )} + + {hasItems && ( +
+ Cannot delete this shop while it has associated inventory items. +
+ )} +
+
+
+ ); +} diff --git a/web/src/views/ShopsView.tsx b/web/src/views/ShopsView.tsx index 9428d3f..ee4c17c 100644 --- a/web/src/views/ShopsView.tsx +++ b/web/src/views/ShopsView.tsx @@ -1,29 +1,92 @@ -import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; import type { Bootstrap, Shop } 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 ShopRow { + shop: Shop; + itemCount: number; + totalSpend: number; + avgRating: number | null; + ratingCount: number; + lastPurchase: string | null; +} + +type SortKey = "name" | "items" | "spent" | "rating" | "recent"; + +const GRID_COLS = "2fr 0.7fr 0.8fr 0.8fr 1fr"; + +function buildShopRows(data: Bootstrap): ShopRow[] { + const itemsByShop = new Map(); + for (const i of data.inventoryItems) { + if (!i.shopId) continue; + const arr = itemsByShop.get(i.shopId); + if (arr) arr.push(i); + else itemsByShop.set(i.shopId, [i]); + } + + return data.shops.map((shop) => { + const items = itemsByShop.get(shop.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 { + shop, + itemCount: items.length, + totalSpend, + avgRating, + ratingCount: rated.length, + lastPurchase, + }; + }); +} export function ShopsView({ data, + onSelectShop, onAddShop, - onEditShop, }: { data: Bootstrap; + onSelectShop: (shop: Shop) => void; onAddShop: () => void; - onEditShop: (shop: Shop) => 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.deleteShop(id), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["bootstrap"] }); - setConfirmDelete(null); - }, - }); + const rows = useMemo(() => buildShopRows(data), [data]); + + const filtered = useMemo(() => { + if (!search) return rows; + const q = search.toLowerCase(); + return rows.filter( + (r) => + r.shop.name.toLowerCase().includes(q) || + (r.shop.location && r.shop.location.toLowerCase().includes(q)), + ); + }, [rows, search]); + + const sorted = useMemo(() => { + const copy = [...filtered]; + if (sortBy === "name") copy.sort((a, b) => a.shop.name.localeCompare(b.shop.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 (
-
+
- {data.shops.length} shop{data.shops.length === 1 ? "" : "s"} + {sorted.length} shop{sorted.length === 1 ? "" : "s"}

- New shop -
-
- Where you've purchased from. Used in the shop dropdown when adding a new product. + + New shop +
{data.shops.length === 0 ? ( @@ -57,86 +126,210 @@ export function ShopsView({
Add a shop to start logging where each purchase came from.
- Add your first shop + + Add your first shop + ) : ( -
- {data.shops.map((s) => { - const count = data.inventoryItems.filter((i) => i.shopId === s.id).length; - return ( - -
-
- {s.name} -
- {s.location && ( -
- {s.location} -
- )} -
- - {count} purchase{count === 1 ? "" : "s"} - - - -
- ); - })} -
- )} + /> + {search && ( + + )} +
- {confirmDelete && ( - 0 - ? `${confirmDelete.count} product${confirmDelete.count === 1 ? "" : "s"} will lose this shop.` - : "This shop will be permanently removed." - } - confirmLabel="Delete shop" - onConfirm={() => remove.mutate(confirmDelete.id)} - onCancel={() => setConfirmDelete(null)} - isPending={remove.isPending} - /> + +
+ + + + + {sorted.length === 0 && ( +
+ No shops match these filters. +
+ )} + {sorted.map((r) => ( + onSelectShop(r.shop)} /> + ))} +
+ )} ); } + +const COL_SORT: (SortKey | null)[] = ["name", "items", "spent", "rating", "recent"]; +const COL_LABELS = ["Name", "Items", "Spent", "Rating", "Last purchase"]; + +function ShopHeaderRow({ + 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 ShopItemRow({ row, onClick }: { row: ShopRow; onClick: () => void }) { + return ( +
+
+
+ {row.shop.name} +
+ {row.shop.location && ( +
+ {row.shop.location} +
+ )} +
+
{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()) : "—"} + + + › + +
+
+ ); +}