From a82045d1bd923801aa194554b05900796591b0b6 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 4 May 2026 18:54:49 -0400 Subject: [PATCH] UX overhaul: routing, accessibility, feedback, and polish Add react-router-dom for URL-based navigation with browser back/forward, deep links, and bookmarks. Replace window.confirm() with styled ConfirmDialog. Add toast notifications and success feedback on consume/audit/gone flows. Add escape-to-close and focus trapping on modals. Add entrance animations for drawers, modals, and toasts. Make grids responsive, add sortable inventory headers, working CSV/JSON export, time-aware greeting, focus-visible outlines, search clear button, and hover chevrons on inventory rows. Co-Authored-By: Claude Opus 4.6 --- web/package-lock.json | 60 +++++++++- web/package.json | 3 +- web/src/App.tsx | 120 ++++++++++---------- web/src/components/ProductDetail.tsx | 21 ++-- web/src/components/Sidebar.tsx | 45 ++++---- web/src/components/Toast.tsx | 76 +++++++++++++ web/src/components/modals/AuditFlow.tsx | 9 ++ web/src/components/modals/ConfirmDialog.tsx | 62 ++++++++++ web/src/components/modals/ConsumeFlow.tsx | 9 ++ web/src/components/modals/MarkGoneFlow.tsx | 9 ++ web/src/components/modals/ModalChrome.tsx | 51 ++++++++- web/src/components/primitives/index.tsx | 1 - web/src/main.tsx | 8 +- web/src/styles/global.css | 54 +++++++++ web/src/tokens.css | 5 + web/src/views/BinsView.tsx | 37 ++++-- web/src/views/BrandsView.tsx | 33 ++++-- web/src/views/Dashboard.tsx | 12 +- web/src/views/Inventory.tsx | 63 ++++++++-- web/src/views/SettingsView.tsx | 74 +++++++++++- web/src/views/ShopsView.tsx | 33 ++++-- 21 files changed, 640 insertions(+), 145 deletions(-) create mode 100644 web/src/components/Toast.tsx create mode 100644 web/src/components/modals/ConfirmDialog.tsx diff --git a/web/package-lock.json b/web/package-lock.json index ff7f690..15f241f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@tanstack/react-query": "^5.62.7", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@types/react": "^18.3.17", @@ -1341,6 +1342,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1605,6 +1619,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -1669,6 +1721,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/web/package.json b/web/package.json index 7a87c92..905b208 100644 --- a/web/package.json +++ b/web/package.json @@ -12,7 +12,8 @@ "dependencies": { "@tanstack/react-query": "^5.62.7", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@types/react": "^18.3.17", diff --git a/web/src/App.tsx b/web/src/App.tsx index ad847d4..8f56092 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { Routes, Route } from "react-router-dom"; import { api } from "./api.js"; import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js"; import { enrichItems } from "./types.js"; import { computeStats } from "./stats.js"; import { Sidebar } from "./components/Sidebar.js"; -import type { ViewKey } from "./components/Sidebar.js"; import { Dashboard } from "./views/Dashboard.js"; import { Inventory } from "./views/Inventory.js"; import { BinsView } from "./views/BinsView.js"; @@ -46,7 +46,6 @@ type ModalKey = | null; export function App() { - const [view, setView] = useState("dashboard"); const [selected, setSelected] = useState(null); const [modal, setModal] = useState(null); const [modalItem, setModalItem] = useState(null); @@ -113,15 +112,50 @@ export function App() { if (isLoading) { return ( -
- Loading… +
+
+ A +
+
Apothecary
+
+ Loading… +
); } if (error || !data || !stats) { return ( -
- Failed to load: {String(error ?? "no data")} +
+
A
+
+ Failed to load +
+
+ {String(error ?? "No data received from server.")} +
); } @@ -129,65 +163,33 @@ export function App() { return (
openConsume()} onAudit={() => openAudit()} />
- {view === "dashboard" && ( - - )} - {view === "inventory" && ( - openAudit()} - /> - )} - {view === "bins" && ( - setModal("addBin")} - onEditBin={(bin) => { - setModalBin(bin); - setModal("editBin"); - }} - /> - )} - {view === "shops" && ( - setModal("addShop")} - onEditShop={(shop) => { - setModalShop(shop); - setModal("editShop"); - }} - /> - )} - {view === "brands" && ( - setModal("addBrand")} - onEditBrand={(brand) => { - setModalBrand(brand); - setModal("editBrand"); - }} - /> - )} - {view === "charts" && } - {view === "settings" && ( - - )} + + + } /> + openAudit()} /> + } /> + setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} /> + } /> + setModal("addShop")} onEditShop={(shop) => { setModalShop(shop); setModal("editShop"); }} /> + } /> + setModal("addBrand")} onEditBrand={(brand) => { setModalBrand(brand); setModal("editBrand"); }} /> + } /> + } /> + + } /> +
{selected && ( diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index 6de3081..74738bb 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import type { Bootstrap, Item, Product } from "../types.js"; import { TYPES, helpers, TODAY_STR } from "../types.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; @@ -36,6 +37,14 @@ export function ProductDetail({ const isActive = item.status === "active"; + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + // Sibling instances of the same product (excluding this one) — useful for // seeing previous purchases of the same SKU. const siblings = data.inventoryItems.filter( @@ -90,6 +99,7 @@ export function ProductDetail({ zIndex: 50, display: "flex", justifyContent: "flex-end", + animation: "backdrop-in 200ms ease-out", }} onClick={onClose} > @@ -98,6 +108,7 @@ export function ProductDetail({ style={{ width: "min(720px, 100vw)", height: "100%", + animation: "drawer-in 250ms ease-out", background: "var(--bg)", borderLeft: "1px solid var(--line)", overflow: "auto", @@ -120,7 +131,7 @@ export function ProductDetail({
Inventory · {item.assetId}
-
+
{isActive && ( onAudit(item)}> Audit @@ -132,13 +143,9 @@ export function ProductDetail({ )} {isActive && ( - onMarkGone(item)}> - Mark gone - + onMarkGone(item)} /> )} - onEdit(item)}> - Edit - + onEdit(item)} />
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 5c2e7f2..2afac7d 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { NavLink } from "react-router-dom"; import { Icon } from "./primitives/index.js"; export type ViewKey = @@ -9,14 +10,14 @@ export type ViewKey = | "charts" | "settings"; -const NAV: { key: ViewKey; label: string; icon: string }[] = [ - { key: "dashboard", label: "Dashboard", icon: "home" }, - { key: "inventory", label: "Inventory", icon: "box" }, - { key: "bins", label: "Bins", icon: "bin" }, - { key: "shops", label: "Shops", icon: "shop" }, - { key: "brands", label: "Brands", icon: "tag" }, - { key: "charts", label: "Patterns", icon: "chart" }, - { key: "settings", label: "Settings", icon: "settings" }, +const NAV: { path: string; label: string; icon: string }[] = [ + { path: "/", label: "Dashboard", icon: "home" }, + { path: "/inventory", label: "Inventory", icon: "box" }, + { path: "/bins", label: "Bins", icon: "bin" }, + { path: "/shops", label: "Shops", icon: "shop" }, + { path: "/brands", label: "Brands", icon: "tag" }, + { path: "/charts", label: "Patterns", icon: "chart" }, + { path: "/settings", label: "Settings", icon: "settings" }, ]; const BRAND = "Apothecary"; @@ -38,14 +39,10 @@ const TAGLINES = [ const TAGLINE = TAGLINES[Math.floor(Math.random() * TAGLINES.length)]!; export function Sidebar({ - view, - onNav, onAddProduct, onMarkFinished, onAudit, }: { - view: ViewKey; - onNav: (k: ViewKey) => void; onAddProduct: () => void; onMarkFinished: () => void; onAudit: () => void; @@ -73,24 +70,26 @@ export function Sidebar({
Workspace
{NAV.map((n) => ( - + ))}
Quick
- - - ); diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx new file mode 100644 index 0000000..9e906fe --- /dev/null +++ b/web/src/components/Toast.tsx @@ -0,0 +1,76 @@ +import { createContext, useCallback, useContext, useState } from "react"; +import { Icon } from "./primitives/index.js"; + +type ToastType = "success" | "error"; + +interface Toast { + id: number; + message: string; + type: ToastType; +} + +const ToastContext = createContext<{ + toast: (message: string, type?: ToastType) => void; +}>({ toast: () => {} }); + +export const useToast = () => useContext(ToastContext); + +let nextId = 0; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const toast = useCallback((message: string, type: ToastType = "success") => { + const id = nextId++; + setToasts((prev) => [...prev, { id, message, type }]); + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); + }, []); + + return ( + + {children} + {toasts.length > 0 && ( +
+ {toasts.map((t) => ( +
+ + {t.message} +
+ ))} +
+ )} +
+ ); +} diff --git a/web/src/components/modals/AuditFlow.tsx b/web/src/components/modals/AuditFlow.tsx index 8ab8b1f..09bc148 100644 --- a/web/src/components/modals/AuditFlow.tsx +++ b/web/src/components/modals/AuditFlow.tsx @@ -6,6 +6,7 @@ import { api } from "../../api.js"; import { Btn, Field, Input, Select } from "../primitives/index.js"; import { ScanField, type ScanResult } from "../ScanField.js"; import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; +import { useToast } from "../Toast.js"; const AUDIT_MODE_LABELS: Record = { weigh: { @@ -32,6 +33,7 @@ export function AuditFlow({ item: Item | null; }) { const qc = useQueryClient(); + const { toast } = useToast(); const allItems = enrichItems(data); const overdueFirst = [...allItems] .filter((i) => i.status === "active") @@ -52,6 +54,7 @@ export function AuditFlow({ return helpers.estimatedRemaining(i, TODAY_STR).toFixed(2); }; const [value, setValue] = useState(initialValueFor(item)); + const [error, setError] = useState(null); useEffect(() => { setValue(initialValueFor(item)); @@ -67,8 +70,10 @@ export function AuditFlow({ }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); + toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`); onClose(); }, + onError: (e: Error) => setError(e.message), }); const handleScan = (result: ScanResult) => { @@ -244,6 +249,10 @@ export function AuditFlow({
+ + {error && ( +
{error}
+ )} diff --git a/web/src/components/modals/ConfirmDialog.tsx b/web/src/components/modals/ConfirmDialog.tsx new file mode 100644 index 0000000..9a42f4b --- /dev/null +++ b/web/src/components/modals/ConfirmDialog.tsx @@ -0,0 +1,62 @@ +import { Btn } from "../primitives/index.js"; +import { ModalBackdrop, ModalFooter } from "./ModalChrome.js"; + +export function ConfirmDialog({ + title, + message, + confirmLabel = "Delete", + confirmVariant = "danger", + onConfirm, + onCancel, + isPending = false, +}: { + title: string; + message: string; + confirmLabel?: string; + confirmVariant?: "danger" | "primary"; + onConfirm: () => void; + onCancel: () => void; + isPending?: boolean; +}) { + return ( + +
+
+

+ {title} +

+

+ {message} +

+
+ +
+
+ + Cancel + + + {isPending ? "Deleting…" : confirmLabel} + +
+ +
+ + ); +} diff --git a/web/src/components/modals/ConsumeFlow.tsx b/web/src/components/modals/ConsumeFlow.tsx index 2511018..8e9b99d 100644 --- a/web/src/components/modals/ConsumeFlow.tsx +++ b/web/src/components/modals/ConsumeFlow.tsx @@ -8,6 +8,7 @@ import { api } from "../../api.js"; import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js"; import { ScanField, type ScanResult } from "../ScanField.js"; import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; +import { useToast } from "../Toast.js"; export function ConsumeFlow({ data, @@ -19,12 +20,14 @@ export function ConsumeFlow({ item: Item | null; }) { const qc = useQueryClient(); + const { toast } = useToast(); const allItems = enrichItems(data); const active = allItems.filter((i) => i.status === "active"); const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? ""); const [rating, setRating] = useState(4); const [notes, setNotes] = useState(""); const [date, setDate] = useState(TODAY_STR); + const [error, setError] = useState(null); const item = allItems.find((i) => i.id === itemId); @@ -32,8 +35,10 @@ export function ConsumeFlow({ mutationFn: () => api.finishInventoryItem(itemId, { date, rating, notes }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); + toast(`Marked ${item?.name ?? "item"} as consumed — ${rating}/5 stars`); onClose(); }, + onError: (e: Error) => setError(e.message), }); const handleScan = (result: ScanResult) => { @@ -163,6 +168,10 @@ export function ConsumeFlow({ />
+ + {error && ( +
{error}
+ )} diff --git a/web/src/components/modals/MarkGoneFlow.tsx b/web/src/components/modals/MarkGoneFlow.tsx index 040367e..e512983 100644 --- a/web/src/components/modals/MarkGoneFlow.tsx +++ b/web/src/components/modals/MarkGoneFlow.tsx @@ -6,6 +6,7 @@ import { remainingShort } from "../../stats.js"; import { api } from "../../api.js"; import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js"; import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js"; +import { useToast } from "../Toast.js"; const REASONS: [string, string][] = [ ["lost", "Lost / misplaced"], @@ -25,20 +26,24 @@ export function MarkGoneFlow({ item: Item | null; }) { const qc = useQueryClient(); + const { toast } = useToast(); const allItems = enrichItems(data); const active = allItems.filter((i) => i.status === "active"); const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? ""); const [reason, setReason] = useState("lost"); const [notes, setNotes] = useState(""); const [date, setDate] = useState(TODAY_STR); + const [error, setError] = useState(null); const item = allItems.find((i) => i.id === itemId); const mark = useMutation({ mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); + toast(`Marked ${item?.name ?? "item"} as gone`); onClose(); }, + onError: (e: Error) => setError(e.message), }); if (!item) return null; @@ -109,6 +114,10 @@ export function MarkGoneFlow({ /> + + {error && ( +
{error}
+ )} diff --git a/web/src/components/modals/ModalChrome.tsx b/web/src/components/modals/ModalChrome.tsx index ced3ac7..66e4f66 100644 --- a/web/src/components/modals/ModalChrome.tsx +++ b/web/src/components/modals/ModalChrome.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import { Btn } from "../primitives/index.js"; export function ModalBackdrop({ @@ -7,8 +8,55 @@ export function ModalBackdrop({ children: React.ReactNode; onClose: () => void; }) { + const backdropRef = useRef(null); + const previousFocus = useRef(null); + + useEffect(() => { + previousFocus.current = document.activeElement; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + return; + } + if (e.key === "Tab" && backdropRef.current) { + const focusable = backdropRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusable.length === 0) return; + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + + const firstFocusable = backdropRef.current?.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + firstFocusable?.focus(); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + if (previousFocus.current instanceof HTMLElement) { + previousFocus.current.focus(); + } + }; + }, [onClose]); + return (
e.stopPropagation()} - style={{ width: "100%", display: "flex", justifyContent: "center" }} + style={{ width: "100%", display: "flex", justifyContent: "center", animation: "modal-in 200ms ease-out" }} > {children}
diff --git a/web/src/components/primitives/index.tsx b/web/src/components/primitives/index.tsx index 3fed8b8..597e4db 100644 --- a/web/src/components/primitives/index.tsx +++ b/web/src/components/primitives/index.tsx @@ -364,7 +364,6 @@ export const inputStyle: CSSProperties = { padding: "10px 12px", fontSize: 13, color: "var(--ink)", - outline: "none", fontFamily: "var(--sans)", width: "100%", }; diff --git a/web/src/main.tsx b/web/src/main.tsx index 54d7248..889ef86 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,9 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; import { App } from "./App.js"; +import { ToastProvider } from "./components/Toast.js"; import "./tokens.css"; import "./styles/global.css"; @@ -15,7 +17,11 @@ document.documentElement.dataset.theme = STORED_THEME; createRoot(document.getElementById("root")!).render( - + + + + + , ); diff --git a/web/src/styles/global.css b/web/src/styles/global.css index a914ec8..9ac55e7 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -68,9 +68,49 @@ padding: 16px 12px 6px; font-weight: 500; } +.nav-divider { + display: none; +} .inv-row:hover { background: var(--bg-2); } +.inv-row .inv-row-chevron { + opacity: 0; + transition: opacity 100ms; +} +.inv-row:hover .inv-row-chevron { + opacity: 1; +} + +@keyframes toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +@keyframes backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes modal-in { + from { opacity: 0; transform: scale(0.97) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} +@keyframes drawer-in { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +@media (max-width: 1200px) { + .inv-row > :nth-child(4), + .inv-header > :nth-child(4) { display: none; } /* Shop */ + .inv-row > :nth-child(8), + .inv-header > :nth-child(8) { display: none; } /* Last checked */ +} @media (max-width: 880px) { .app-shell { @@ -97,6 +137,20 @@ .nav-link { white-space: nowrap; padding: 8px 12px; + flex-direction: column; + font-size: 10px; + gap: 2px; + align-items: center; + } + .nav-label { + display: none; + } + .nav-divider { + width: 1px; + height: 24px; + background: var(--line); + flex-shrink: 0; + align-self: center; } .main { padding-bottom: 60px; diff --git a/web/src/tokens.css b/web/src/tokens.css index 589c779..e60ad1a 100644 --- a/web/src/tokens.css +++ b/web/src/tokens.css @@ -92,6 +92,11 @@ html, body { button { font-family: inherit; cursor: pointer; } input, select, textarea { font-family: inherit; } +:focus-visible { + outline: 2px solid var(--sage); + outline-offset: 2px; +} + /* Subtle parchment texture */ .parchment { background-image: diff --git a/web/src/views/BinsView.tsx b/web/src/views/BinsView.tsx index da19428..160bfca 100644 --- a/web/src/views/BinsView.tsx +++ b/web/src/views/BinsView.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Bin, Item } from "../types.js"; import { helpers, TODAY_STR, enrichItems } from "../types.js"; @@ -6,6 +6,7 @@ import { remainingShort } from "../stats.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; import { api } from "../api.js"; import { Btn, Card, Pill, Icon } from "../components/primitives/index.js"; +import { ConfirmDialog } from "../components/modals/ConfirmDialog.js"; // Bins follow a "letter + number" naming convention (A1, A2, B1, …). // Group by the letter prefix so each letter starts a new visual row, @@ -51,19 +52,16 @@ export function BinsView({ const qc = useQueryClient(); const items = useMemo(() => enrichItems(data), [data]); + const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null); + const remove = useMutation({ mutationFn: (id: string) => api.deleteBin(id), - onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + setConfirmDelete(null); + }, }); - const handleDelete = (binId: string, binName: string, activeCount: number) => { - const msg = - activeCount > 0 - ? `Delete "${binName}"? ${activeCount} active item${activeCount === 1 ? "" : "s"} will be moved to Unassigned.` - : `Delete "${binName}"?`; - if (window.confirm(msg)) remove.mutate(binId); - }; - const grouped = groupBins(data.bins); return ( @@ -105,7 +103,7 @@ export function BinsView({
@@ -159,7 +157,7 @@ export function BinsView({
))} + + {confirmDelete && ( + 0 + ? `${confirmDelete.count} active item${confirmDelete.count === 1 ? "" : "s"} will be moved to Unassigned.` + : "This bin will be permanently removed." + } + confirmLabel="Delete bin" + onConfirm={() => remove.mutate(confirmDelete.id)} + onCancel={() => setConfirmDelete(null)} + isPending={remove.isPending} + /> + )} ); } diff --git a/web/src/views/BrandsView.tsx b/web/src/views/BrandsView.tsx index e092f46..fb51c58 100644 --- a/web/src/views/BrandsView.tsx +++ b/web/src/views/BrandsView.tsx @@ -1,7 +1,9 @@ +import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; 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"; export function BrandsView({ data, @@ -13,18 +15,16 @@ export function BrandsView({ onEditBrand: (brand: Brand) => void; }) { const qc = useQueryClient(); + const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null); + const remove = useMutation({ mutationFn: (id: string) => api.deleteBrand(id), - onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["bootstrap"] }); + setConfirmDelete(null); + }, }); - const handleDelete = (brandId: string, brandName: string, itemCount: number) => { - const tail = itemCount > 0 - ? ` ${itemCount} inventory item${itemCount === 1 ? "" : "s"} will be unbranded.` - : ""; - if (window.confirm(`Delete "${brandName}"?${tail}`)) remove.mutate(brandId); - }; - return (
)} + + {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} + /> + )} ); } diff --git a/web/src/views/Dashboard.tsx b/web/src/views/Dashboard.tsx index 5f68d53..3c8fd42 100644 --- a/web/src/views/Dashboard.tsx +++ b/web/src/views/Dashboard.tsx @@ -68,7 +68,7 @@ export function Dashboard({ lineHeight: 1.1, }} > - Good evening. + {new Date().getHours() < 12 ? "Good morning." : new Date().getHours() < 17 ? "Good afternoon." : "Good evening."} @@ -85,7 +85,7 @@ export function Dashboard({
diff --git a/web/src/views/Inventory.tsx b/web/src/views/Inventory.tsx index 2ad1851..0bbdfe6 100644 --- a/web/src/views/Inventory.tsx +++ b/web/src/views/Inventory.tsx @@ -183,6 +183,21 @@ export function Inventory({ color: "var(--ink)", }} /> + {search && ( + + )}