UX overhaul: routing, accessibility, feedback, and polish
Build and push image / build (push) Successful in 50s
Build and push image / build (push) Successful in 50s
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 <noreply@anthropic.com>
This commit is contained in:
Generated
+59
-1
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.17",
|
"@types/react": "^18.3.17",
|
||||||
@@ -1341,6 +1342,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1605,6 +1619,44 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.2",
|
"version": "4.60.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
|
||||||
@@ -1669,6 +1721,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.17",
|
"@types/react": "^18.3.17",
|
||||||
|
|||||||
+61
-59
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js";
|
import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js";
|
||||||
import { enrichItems } from "./types.js";
|
import { enrichItems } from "./types.js";
|
||||||
import { computeStats } from "./stats.js";
|
import { computeStats } from "./stats.js";
|
||||||
import { Sidebar } from "./components/Sidebar.js";
|
import { Sidebar } from "./components/Sidebar.js";
|
||||||
import type { ViewKey } from "./components/Sidebar.js";
|
|
||||||
import { Dashboard } from "./views/Dashboard.js";
|
import { Dashboard } from "./views/Dashboard.js";
|
||||||
import { Inventory } from "./views/Inventory.js";
|
import { Inventory } from "./views/Inventory.js";
|
||||||
import { BinsView } from "./views/BinsView.js";
|
import { BinsView } from "./views/BinsView.js";
|
||||||
@@ -46,7 +46,6 @@ type ModalKey =
|
|||||||
| null;
|
| null;
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [view, setView] = useState<ViewKey>("dashboard");
|
|
||||||
const [selected, setSelected] = useState<Item | null>(null);
|
const [selected, setSelected] = useState<Item | null>(null);
|
||||||
const [modal, setModal] = useState<ModalKey>(null);
|
const [modal, setModal] = useState<ModalKey>(null);
|
||||||
const [modalItem, setModalItem] = useState<Item | null>(null);
|
const [modalItem, setModalItem] = useState<Item | null>(null);
|
||||||
@@ -113,15 +112,50 @@ export function App() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="parchment" style={{ padding: 60, color: "var(--ink-3)" }}>
|
<div
|
||||||
Loading…
|
className="parchment"
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="brand-mark"
|
||||||
|
style={{ width: 56, height: 56, fontSize: 28, animation: "pulse 2s ease-in-out infinite" }}
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
<div className="serif" style={{ fontSize: 22, color: "var(--ink)" }}>Apothecary</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.1em" }}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error || !data || !stats) {
|
if (error || !data || !stats) {
|
||||||
return (
|
return (
|
||||||
<div className="parchment" style={{ padding: 60, color: "var(--terracotta)" }}>
|
<div
|
||||||
Failed to load: {String(error ?? "no data")}
|
className="parchment"
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="brand-mark" style={{ width: 56, height: 56, fontSize: 28 }}>A</div>
|
||||||
|
<div className="serif" style={{ fontSize: 18, color: "var(--terracotta)" }}>
|
||||||
|
Failed to load
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--ink-3)", maxWidth: 400, textAlign: "center" }}>
|
||||||
|
{String(error ?? "No data received from server.")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,65 +163,33 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell" data-screen-label="App">
|
<div className="app-shell" data-screen-label="App">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
view={view}
|
|
||||||
onNav={setView}
|
|
||||||
onAddProduct={openAdd}
|
onAddProduct={openAdd}
|
||||||
onMarkFinished={() => openConsume()}
|
onMarkFinished={() => openConsume()}
|
||||||
onAudit={() => openAudit()}
|
onAudit={() => openAudit()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="main parchment" style={{ minWidth: 0 }}>
|
<main className="main parchment" style={{ minWidth: 0 }}>
|
||||||
{view === "dashboard" && (
|
<Routes>
|
||||||
<Dashboard
|
<Route path="/" element={
|
||||||
data={data}
|
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onSelectItem={setSelected} />
|
||||||
stats={stats}
|
} />
|
||||||
onAuditItem={openAudit}
|
<Route path="/inventory" element={
|
||||||
onSelectItem={setSelected}
|
<Inventory data={data} onSelectItem={setSelected} onAddInventory={openAdd} onAuditNew={() => openAudit()} />
|
||||||
/>
|
} />
|
||||||
)}
|
<Route path="/bins" element={
|
||||||
{view === "inventory" && (
|
<BinsView data={data} onSelectItem={setSelected} onAddBin={() => setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} />
|
||||||
<Inventory
|
} />
|
||||||
data={data}
|
<Route path="/shops" element={
|
||||||
onSelectItem={setSelected}
|
<ShopsView data={data} onAddShop={() => setModal("addShop")} onEditShop={(shop) => { setModalShop(shop); setModal("editShop"); }} />
|
||||||
onAddInventory={openAdd}
|
} />
|
||||||
onAuditNew={() => openAudit()}
|
<Route path="/brands" element={
|
||||||
/>
|
<BrandsView data={data} onAddBrand={() => setModal("addBrand")} onEditBrand={(brand) => { setModalBrand(brand); setModal("editBrand"); }} />
|
||||||
)}
|
} />
|
||||||
{view === "bins" && (
|
<Route path="/charts" element={<ChartsView data={data} stats={stats} />} />
|
||||||
<BinsView
|
<Route path="/settings" element={
|
||||||
data={data}
|
<SettingsView data={data} theme={theme} onThemeChange={setTheme} />
|
||||||
onSelectItem={setSelected}
|
} />
|
||||||
onAddBin={() => setModal("addBin")}
|
</Routes>
|
||||||
onEditBin={(bin) => {
|
|
||||||
setModalBin(bin);
|
|
||||||
setModal("editBin");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{view === "shops" && (
|
|
||||||
<ShopsView
|
|
||||||
data={data}
|
|
||||||
onAddShop={() => setModal("addShop")}
|
|
||||||
onEditShop={(shop) => {
|
|
||||||
setModalShop(shop);
|
|
||||||
setModal("editShop");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{view === "brands" && (
|
|
||||||
<BrandsView
|
|
||||||
data={data}
|
|
||||||
onAddBrand={() => setModal("addBrand")}
|
|
||||||
onEditBrand={(brand) => {
|
|
||||||
setModalBrand(brand);
|
|
||||||
setModal("editBrand");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{view === "charts" && <ChartsView data={data} stats={stats} />}
|
|
||||||
{view === "settings" && (
|
|
||||||
<SettingsView data={data} theme={theme} onThemeChange={setTheme} />
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{selected && (
|
{selected && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import type { Bootstrap, Item, Product } from "../types.js";
|
import type { Bootstrap, Item, Product } from "../types.js";
|
||||||
import { TYPES, helpers, TODAY_STR } from "../types.js";
|
import { TYPES, helpers, TODAY_STR } from "../types.js";
|
||||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
@@ -36,6 +37,14 @@ export function ProductDetail({
|
|||||||
|
|
||||||
const isActive = item.status === "active";
|
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
|
// Sibling instances of the same product (excluding this one) — useful for
|
||||||
// seeing previous purchases of the same SKU.
|
// seeing previous purchases of the same SKU.
|
||||||
const siblings = data.inventoryItems.filter(
|
const siblings = data.inventoryItems.filter(
|
||||||
@@ -90,6 +99,7 @@ export function ProductDetail({
|
|||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
|
animation: "backdrop-in 200ms ease-out",
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
@@ -98,6 +108,7 @@ export function ProductDetail({
|
|||||||
style={{
|
style={{
|
||||||
width: "min(720px, 100vw)",
|
width: "min(720px, 100vw)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
animation: "drawer-in 250ms ease-out",
|
||||||
background: "var(--bg)",
|
background: "var(--bg)",
|
||||||
borderLeft: "1px solid var(--line)",
|
borderLeft: "1px solid var(--line)",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
@@ -120,7 +131,7 @@ export function ProductDetail({
|
|||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
Inventory · <span className="mono">{item.assetId}</span>
|
Inventory · <span className="mono">{item.assetId}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<Btn variant="ghost" icon="check" onClick={() => onAudit(item)}>
|
<Btn variant="ghost" icon="check" onClick={() => onAudit(item)}>
|
||||||
Audit
|
Audit
|
||||||
@@ -132,13 +143,9 @@ export function ProductDetail({
|
|||||||
</Btn>
|
</Btn>
|
||||||
)}
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)}>
|
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)} />
|
||||||
Mark gone
|
|
||||||
</Btn>
|
|
||||||
)}
|
)}
|
||||||
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)}>
|
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)} />
|
||||||
Edit
|
|
||||||
</Btn>
|
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
<Btn variant="ghost" icon="close" onClick={onClose} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NavLink } from "react-router-dom";
|
||||||
import { Icon } from "./primitives/index.js";
|
import { Icon } from "./primitives/index.js";
|
||||||
|
|
||||||
export type ViewKey =
|
export type ViewKey =
|
||||||
@@ -9,14 +10,14 @@ export type ViewKey =
|
|||||||
| "charts"
|
| "charts"
|
||||||
| "settings";
|
| "settings";
|
||||||
|
|
||||||
const NAV: { key: ViewKey; label: string; icon: string }[] = [
|
const NAV: { path: string; label: string; icon: string }[] = [
|
||||||
{ key: "dashboard", label: "Dashboard", icon: "home" },
|
{ path: "/", label: "Dashboard", icon: "home" },
|
||||||
{ key: "inventory", label: "Inventory", icon: "box" },
|
{ path: "/inventory", label: "Inventory", icon: "box" },
|
||||||
{ key: "bins", label: "Bins", icon: "bin" },
|
{ path: "/bins", label: "Bins", icon: "bin" },
|
||||||
{ key: "shops", label: "Shops", icon: "shop" },
|
{ path: "/shops", label: "Shops", icon: "shop" },
|
||||||
{ key: "brands", label: "Brands", icon: "tag" },
|
{ path: "/brands", label: "Brands", icon: "tag" },
|
||||||
{ key: "charts", label: "Patterns", icon: "chart" },
|
{ path: "/charts", label: "Patterns", icon: "chart" },
|
||||||
{ key: "settings", label: "Settings", icon: "settings" },
|
{ path: "/settings", label: "Settings", icon: "settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const BRAND = "Apothecary";
|
const BRAND = "Apothecary";
|
||||||
@@ -38,14 +39,10 @@ const TAGLINES = [
|
|||||||
const TAGLINE = TAGLINES[Math.floor(Math.random() * TAGLINES.length)]!;
|
const TAGLINE = TAGLINES[Math.floor(Math.random() * TAGLINES.length)]!;
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
view,
|
|
||||||
onNav,
|
|
||||||
onAddProduct,
|
onAddProduct,
|
||||||
onMarkFinished,
|
onMarkFinished,
|
||||||
onAudit,
|
onAudit,
|
||||||
}: {
|
}: {
|
||||||
view: ViewKey;
|
|
||||||
onNav: (k: ViewKey) => void;
|
|
||||||
onAddProduct: () => void;
|
onAddProduct: () => void;
|
||||||
onMarkFinished: () => void;
|
onMarkFinished: () => void;
|
||||||
onAudit: () => void;
|
onAudit: () => void;
|
||||||
@@ -73,24 +70,26 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
<div className="nav-section">Workspace</div>
|
<div className="nav-section">Workspace</div>
|
||||||
{NAV.map((n) => (
|
{NAV.map((n) => (
|
||||||
<button
|
<NavLink
|
||||||
key={n.key}
|
key={n.path}
|
||||||
className={"nav-link " + (view === n.key ? "active" : "")}
|
to={n.path}
|
||||||
onClick={() => onNav(n.key)}
|
end={n.path === "/"}
|
||||||
|
className={({ isActive }) => "nav-link " + (isActive ? "active" : "")}
|
||||||
>
|
>
|
||||||
<Icon name={n.icon} size={16} />
|
<Icon name={n.icon} size={16} />
|
||||||
{n.label}
|
{n.label}
|
||||||
</button>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
<div className="nav-section">Quick</div>
|
<div className="nav-section">Quick</div>
|
||||||
<button className="nav-link" onClick={onAddProduct}>
|
<div className="nav-divider" />
|
||||||
<Icon name="plus" size={16} /> Add product
|
<button className="nav-link" onClick={onAddProduct} title="Add product">
|
||||||
|
<Icon name="plus" size={16} /> <span className="nav-label">Add product</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="nav-link" onClick={onAudit}>
|
<button className="nav-link" onClick={onAudit} title="Audit">
|
||||||
<Icon name="search" size={16} /> Audit
|
<Icon name="search" size={16} /> <span className="nav-label">Audit</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="nav-link" onClick={onMarkFinished}>
|
<button className="nav-link" onClick={onMarkFinished} title="Mark consumed">
|
||||||
<Icon name="check" size={16} /> Mark consumed
|
<Icon name="check" size={16} /> <span className="nav-label">Mark consumed</span>
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<Toast[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ToastContext.Provider value={{ toast }}>
|
||||||
|
{children}
|
||||||
|
{toasts.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 24,
|
||||||
|
right: 24,
|
||||||
|
zIndex: 100,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
style={{
|
||||||
|
pointerEvents: "auto",
|
||||||
|
padding: "12px 18px",
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${t.type === "error" ? "var(--terracotta)" : "var(--sage)"}`,
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
boxShadow: "var(--shadow-md)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink)",
|
||||||
|
maxWidth: 360,
|
||||||
|
animation: "toast-in 200ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={t.type === "error" ? "close" : "check"}
|
||||||
|
size={16}
|
||||||
|
color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"}
|
||||||
|
/>
|
||||||
|
{t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { api } from "../../api.js";
|
|||||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||||
import { ScanField, type ScanResult } from "../ScanField.js";
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
|
const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
|
||||||
weigh: {
|
weigh: {
|
||||||
@@ -32,6 +33,7 @@ export function AuditFlow({
|
|||||||
item: Item | null;
|
item: Item | null;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
const allItems = enrichItems(data);
|
const allItems = enrichItems(data);
|
||||||
const overdueFirst = [...allItems]
|
const overdueFirst = [...allItems]
|
||||||
.filter((i) => i.status === "active")
|
.filter((i) => i.status === "active")
|
||||||
@@ -52,6 +54,7 @@ export function AuditFlow({
|
|||||||
return helpers.estimatedRemaining(i, TODAY_STR).toFixed(2);
|
return helpers.estimatedRemaining(i, TODAY_STR).toFixed(2);
|
||||||
};
|
};
|
||||||
const [value, setValue] = useState<string>(initialValueFor(item));
|
const [value, setValue] = useState<string>(initialValueFor(item));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(initialValueFor(item));
|
setValue(initialValueFor(item));
|
||||||
@@ -67,8 +70,10 @@ export function AuditFlow({
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleScan = (result: ScanResult) => {
|
const handleScan = (result: ScanResult) => {
|
||||||
@@ -244,6 +249,10 @@ export function AuditFlow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<ModalBackdrop onClose={onCancel}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(460px, 96vw)",
|
||||||
|
margin: "120px 20px",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: "28px 32px 8px" }}>
|
||||||
|
<h2
|
||||||
|
className="serif"
|
||||||
|
style={{ fontSize: 24, margin: 0, fontWeight: 500, lineHeight: 1.2 }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--ink-2)", marginTop: 10, lineHeight: 1.5 }}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ModalFooter>
|
||||||
|
<div />
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Btn variant="ghost" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
variant={confirmVariant}
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{isPending ? "Deleting…" : confirmLabel}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { api } from "../../api.js";
|
|||||||
import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js";
|
import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js";
|
||||||
import { ScanField, type ScanResult } from "../ScanField.js";
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
export function ConsumeFlow({
|
export function ConsumeFlow({
|
||||||
data,
|
data,
|
||||||
@@ -19,12 +20,14 @@ export function ConsumeFlow({
|
|||||||
item: Item | null;
|
item: Item | null;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
const allItems = enrichItems(data);
|
const allItems = enrichItems(data);
|
||||||
const active = allItems.filter((i) => i.status === "active");
|
const active = allItems.filter((i) => i.status === "active");
|
||||||
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
||||||
const [rating, setRating] = useState(4);
|
const [rating, setRating] = useState(4);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [date, setDate] = useState(TODAY_STR);
|
const [date, setDate] = useState(TODAY_STR);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const item = allItems.find((i) => i.id === itemId);
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
|
||||||
@@ -32,8 +35,10 @@ export function ConsumeFlow({
|
|||||||
mutationFn: () => api.finishInventoryItem(itemId, { date, rating, notes }),
|
mutationFn: () => api.finishInventoryItem(itemId, { date, rating, notes }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Marked ${item?.name ?? "item"} as consumed — ${rating}/5 stars`);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleScan = (result: ScanResult) => {
|
const handleScan = (result: ScanResult) => {
|
||||||
@@ -163,6 +168,10 @@ export function ConsumeFlow({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { remainingShort } from "../../stats.js";
|
|||||||
import { api } from "../../api.js";
|
import { api } from "../../api.js";
|
||||||
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
const REASONS: [string, string][] = [
|
const REASONS: [string, string][] = [
|
||||||
["lost", "Lost / misplaced"],
|
["lost", "Lost / misplaced"],
|
||||||
@@ -25,20 +26,24 @@ export function MarkGoneFlow({
|
|||||||
item: Item | null;
|
item: Item | null;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
const allItems = enrichItems(data);
|
const allItems = enrichItems(data);
|
||||||
const active = allItems.filter((i) => i.status === "active");
|
const active = allItems.filter((i) => i.status === "active");
|
||||||
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
||||||
const [reason, setReason] = useState("lost");
|
const [reason, setReason] = useState("lost");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [date, setDate] = useState(TODAY_STR);
|
const [date, setDate] = useState(TODAY_STR);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const item = allItems.find((i) => i.id === itemId);
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
|
||||||
const mark = useMutation({
|
const mark = useMutation({
|
||||||
mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }),
|
mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
toast(`Marked ${item?.name ?? "item"} as gone`);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
@@ -109,6 +114,10 @@ export function MarkGoneFlow({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { Btn } from "../primitives/index.js";
|
import { Btn } from "../primitives/index.js";
|
||||||
|
|
||||||
export function ModalBackdrop({
|
export function ModalBackdrop({
|
||||||
@@ -7,8 +8,55 @@ export function ModalBackdrop({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
const previousFocus = useRef<Element | null>(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<HTMLElement>(
|
||||||
|
'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<HTMLElement>(
|
||||||
|
'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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={backdropRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -18,12 +66,13 @@ export function ModalBackdrop({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
|
animation: "backdrop-in 200ms ease-out",
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ width: "100%", display: "flex", justifyContent: "center" }}
|
style={{ width: "100%", display: "flex", justifyContent: "center", animation: "modal-in 200ms ease-out" }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -364,7 +364,6 @@ export const inputStyle: CSSProperties = {
|
|||||||
padding: "10px 12px",
|
padding: "10px 12px",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: "var(--ink)",
|
color: "var(--ink)",
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--sans)",
|
fontFamily: "var(--sans)",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-1
@@ -1,7 +1,9 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
|
import { ToastProvider } from "./components/Toast.js";
|
||||||
import "./tokens.css";
|
import "./tokens.css";
|
||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
|
|
||||||
@@ -15,7 +17,11 @@ document.documentElement.dataset.theme = STORED_THEME;
|
|||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,9 +68,49 @@
|
|||||||
padding: 16px 12px 6px;
|
padding: 16px 12px 6px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.nav-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.inv-row:hover {
|
.inv-row:hover {
|
||||||
background: var(--bg-2);
|
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) {
|
@media (max-width: 880px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
@@ -97,6 +137,20 @@
|
|||||||
.nav-link {
|
.nav-link {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 8px 12px;
|
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 {
|
.main {
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ html, body {
|
|||||||
button { font-family: inherit; cursor: pointer; }
|
button { font-family: inherit; cursor: pointer; }
|
||||||
input, select, textarea { font-family: inherit; }
|
input, select, textarea { font-family: inherit; }
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--sage);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Subtle parchment texture */
|
/* Subtle parchment texture */
|
||||||
.parchment {
|
.parchment {
|
||||||
background-image:
|
background-image:
|
||||||
|
|||||||
+25
-12
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Bin, Item } from "../types.js";
|
import type { Bootstrap, Bin, Item } from "../types.js";
|
||||||
import { helpers, TODAY_STR, enrichItems } 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 { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { Btn, Card, Pill, Icon } from "../components/primitives/index.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, …).
|
// Bins follow a "letter + number" naming convention (A1, A2, B1, …).
|
||||||
// Group by the letter prefix so each letter starts a new visual row,
|
// Group by the letter prefix so each letter starts a new visual row,
|
||||||
@@ -51,19 +52,16 @@ export function BinsView({
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const items = useMemo(() => enrichItems(data), [data]);
|
const items = useMemo(() => enrichItems(data), [data]);
|
||||||
|
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => api.deleteBin(id),
|
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);
|
const grouped = groupBins(data.bins);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -105,7 +103,7 @@ export function BinsView({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: `repeat(${bins.length}, minmax(0, 1fr))`,
|
gridTemplateColumns: `repeat(auto-fill, minmax(280px, 1fr))`,
|
||||||
gap: 14,
|
gap: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -159,7 +157,7 @@ export function BinsView({
|
|||||||
<Icon name="edit" size={14} />
|
<Icon name="edit" size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(bin.id, bin.name, binItems.length)}
|
onClick={() => setConfirmDelete({ id: bin.id, name: bin.name, count: binItems.length })}
|
||||||
title="Remove bin"
|
title="Remove bin"
|
||||||
aria-label={`Remove bin ${bin.name}`}
|
aria-label={`Remove bin ${bin.name}`}
|
||||||
disabled={remove.isPending}
|
disabled={remove.isPending}
|
||||||
@@ -276,6 +274,21 @@ export function BinsView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`Delete "${confirmDelete.name}"?`}
|
||||||
|
message={
|
||||||
|
confirmDelete.count > 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
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 { api } from "../api.js";
|
||||||
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
|
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
|
||||||
|
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
|
||||||
|
|
||||||
export function BrandsView({
|
export function BrandsView({
|
||||||
data,
|
data,
|
||||||
@@ -13,18 +15,16 @@ export function BrandsView({
|
|||||||
onEditBrand: (brand: Brand) => void;
|
onEditBrand: (brand: Brand) => void;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => api.deleteBrand(id),
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -102,7 +102,7 @@ export function BrandsView({
|
|||||||
<Icon name="edit" size={14} />
|
<Icon name="edit" size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(b.id, b.name, itemCount)}
|
onClick={() => setConfirmDelete({ id: b.id, name: b.name, count: itemCount })}
|
||||||
title="Remove brand"
|
title="Remove brand"
|
||||||
aria-label={`Remove brand ${b.name}`}
|
aria-label={`Remove brand ${b.name}`}
|
||||||
disabled={remove.isPending}
|
disabled={remove.isPending}
|
||||||
@@ -123,6 +123,21 @@ export function BrandsView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`Delete "${confirmDelete.name}"?`}
|
||||||
|
message={
|
||||||
|
confirmDelete.count > 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function Dashboard({
|
|||||||
lineHeight: 1.1,
|
lineHeight: 1.1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Good evening.
|
{new Date().getHours() < 12 ? "Good morning." : new Date().getHours() < 17 ? "Good afternoon." : "Good evening."}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export function Dashboard({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(4, 1fr)",
|
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||||
gap: 18,
|
gap: 18,
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
}}
|
}}
|
||||||
@@ -123,7 +123,7 @@ export function Dashboard({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(4, 1fr)",
|
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||||
gap: 18,
|
gap: 18,
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
}}
|
}}
|
||||||
@@ -181,7 +181,7 @@ export function Dashboard({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "2fr 1fr",
|
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
|
||||||
gap: 18,
|
gap: 18,
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
}}
|
}}
|
||||||
@@ -252,7 +252,7 @@ export function Dashboard({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr 1fr",
|
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
||||||
gap: 18,
|
gap: 18,
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
}}
|
}}
|
||||||
@@ -280,7 +280,7 @@ export function Dashboard({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr 1.4fr",
|
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
||||||
gap: 18,
|
gap: 18,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
+51
-12
@@ -183,6 +183,21 @@ export function Inventory({
|
|||||||
color: "var(--ink)",
|
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>
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
@@ -212,7 +227,7 @@ export function Inventory({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padded={false}>
|
<Card padded={false}>
|
||||||
<HeaderRow />
|
<HeaderRow sortBy={sortBy} onSort={setSortBy} />
|
||||||
{sorted.length === 0 && (
|
{sorted.length === 0 && (
|
||||||
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
|
||||||
No items match these filters.
|
No items match these filters.
|
||||||
@@ -278,9 +293,13 @@ function Segmented<T extends string>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeaderRow() {
|
const COL_SORT: (SortKey | null)[] = [null, "name", null, null, "thc", "price", "remaining", "audit", null];
|
||||||
|
const COL_LABELS = ["", "Item", "Brand", "Shop", "THC %", "Price", "Remaining", "Last checked", "Bin"];
|
||||||
|
|
||||||
|
function HeaderRow({ sortBy, onSort }: { sortBy: SortKey; onSort: (k: SortKey) => void }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="inv-header"
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: GRID_COLS,
|
gridTemplateColumns: GRID_COLS,
|
||||||
@@ -294,15 +313,34 @@ function HeaderRow() {
|
|||||||
letterSpacing: "0.08em",
|
letterSpacing: "0.08em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div></div>
|
{COL_LABELS.map((label, i) => {
|
||||||
<div>Item</div>
|
const sk = COL_SORT[i];
|
||||||
<div>Brand</div>
|
if (!sk) return <div key={i}>{label}</div>;
|
||||||
<div>Shop</div>
|
const active = sortBy === sk;
|
||||||
<div>THC %</div>
|
return (
|
||||||
<div>Price</div>
|
<button
|
||||||
<div>Remaining</div>
|
key={i}
|
||||||
<div>Last checked</div>
|
onClick={() => onSort(sk)}
|
||||||
<div>Bin</div>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -492,8 +530,9 @@ function ItemRow({
|
|||||||
<span style={{ fontStyle: "italic" }}>never</span>
|
<span style={{ fontStyle: "italic" }}>never</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
<div style={{ fontSize: 12, color: "var(--ink-3)", display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
{bin ? bin.name : <span style={{ fontStyle: "italic" }}>—</span>}
|
{bin ? bin.name : <span style={{ fontStyle: "italic" }}>—</span>}
|
||||||
|
<span className="inv-row-chevron" style={{ color: "var(--ink-3)", marginLeft: "auto", fontSize: 14 }}>›</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,74 @@
|
|||||||
import type { Bootstrap } from "../types.js";
|
import type { Bootstrap } from "../types.js";
|
||||||
import { Btn, Card, Stat } from "../components/primitives/index.js";
|
import { Btn, Card, Stat } from "../components/primitives/index.js";
|
||||||
|
|
||||||
|
function download(filename: string, content: string, mime: string) {
|
||||||
|
const blob = new Blob([content], { type: mime });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCSV(data: Bootstrap) {
|
||||||
|
const header = [
|
||||||
|
"asset_id", "sku", "strain", "type", "kind", "brand", "shop", "bin",
|
||||||
|
"status", "price", "weight", "thc", "cbd", "total_cannabinoids",
|
||||||
|
"count_original", "count_last_audit", "unit_weight",
|
||||||
|
"purchase_date", "consumed_date", "gone_date", "rating", "notes",
|
||||||
|
];
|
||||||
|
const esc = (v: string | null | undefined) => {
|
||||||
|
if (v == null) return "";
|
||||||
|
const s = String(v);
|
||||||
|
return s.includes(",") || s.includes('"') || s.includes("\n")
|
||||||
|
? `"${s.replace(/"/g, '""')}"`
|
||||||
|
: s;
|
||||||
|
};
|
||||||
|
const rows = data.inventoryItems.map((i) => {
|
||||||
|
const product = data.products.find((p) => p.id === i.productId);
|
||||||
|
const strain = data.strains.find((s) => s.id === product?.strainId);
|
||||||
|
const brand = data.brands.find((b) => b.id === product?.brandId);
|
||||||
|
const shop = data.shops.find((s) => s.id === i.shopId);
|
||||||
|
const bin = data.bins.find((b) => b.id === i.binId);
|
||||||
|
return [
|
||||||
|
i.assetId, product?.sku ?? "", strain?.name ?? "",
|
||||||
|
product?.type ?? "", product?.kind ?? "",
|
||||||
|
brand?.name ?? "", shop?.name ?? "", bin?.name ?? "",
|
||||||
|
i.status, i.price, i.weight, i.thc, i.cbd, i.totalCannabinoids,
|
||||||
|
i.countOriginal, i.countLastAudit ?? "", i.unitWeight,
|
||||||
|
i.purchaseDate, i.consumedDate ?? "", i.goneDate ?? "",
|
||||||
|
i.rating ?? "", esc(i.notes),
|
||||||
|
].map((v) => esc(String(v))).join(",");
|
||||||
|
});
|
||||||
|
const csv = [header.join(","), ...rows].join("\n");
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
download(`apothecary-export-${date}.csv`, csv, "text/csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportJSON(data: Bootstrap) {
|
||||||
|
const enriched = data.inventoryItems.map((i) => {
|
||||||
|
const product = data.products.find((p) => p.id === i.productId);
|
||||||
|
const strain = data.strains.find((s) => s.id === product?.strainId);
|
||||||
|
const brand = data.brands.find((b) => b.id === product?.brandId);
|
||||||
|
const shop = data.shops.find((s) => s.id === i.shopId);
|
||||||
|
const bin = data.bins.find((b) => b.id === i.binId);
|
||||||
|
return {
|
||||||
|
...i,
|
||||||
|
strain: strain?.name ?? null,
|
||||||
|
sku: product?.sku ?? null,
|
||||||
|
type: product?.type ?? null,
|
||||||
|
kind: product?.kind ?? null,
|
||||||
|
brand: brand?.name ?? null,
|
||||||
|
shop: shop?.name ?? null,
|
||||||
|
bin: bin?.name ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const json = JSON.stringify({ exportedAt: new Date().toISOString(), items: enriched }, null, 2);
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
download(`apothecary-export-${date}.json`, json, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
export type ThemeKey = "light" | "dark";
|
export type ThemeKey = "light" | "dark";
|
||||||
|
|
||||||
export function SettingsView({
|
export function SettingsView({
|
||||||
@@ -69,15 +137,15 @@ export function SettingsView({
|
|||||||
|
|
||||||
<Card style={{ marginBottom: 14 }}>
|
<Card style={{ marginBottom: 14 }}>
|
||||||
<div className="serif" style={{ fontSize: 22, marginBottom: 16 }}>Library</div>
|
<div className="serif" style={{ fontSize: 22, marginBottom: 16 }}>Library</div>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 16 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 12, marginBottom: 16 }}>
|
||||||
<Stat label="Active" value={data.inventoryItems.filter((i) => i.status === "active").length} />
|
<Stat label="Active" value={data.inventoryItems.filter((i) => i.status === "active").length} />
|
||||||
<Stat label="Consumed" value={data.inventoryItems.filter((i) => i.status === "consumed").length} />
|
<Stat label="Consumed" value={data.inventoryItems.filter((i) => i.status === "consumed").length} />
|
||||||
<Stat label="Gone" value={data.inventoryItems.filter((i) => i.status === "gone").length} />
|
<Stat label="Gone" value={data.inventoryItems.filter((i) => i.status === "gone").length} />
|
||||||
<Stat label="Bins" value={data.bins.length} />
|
<Stat label="Bins" value={data.bins.length} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Btn variant="secondary">Export CSV</Btn>
|
<Btn variant="secondary" onClick={() => exportCSV(data)}>Export CSV</Btn>
|
||||||
<Btn variant="secondary">Export JSON</Btn>
|
<Btn variant="secondary" onClick={() => exportJSON(data)}>Export JSON</Btn>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Shop } from "../types.js";
|
import type { Bootstrap, Shop } from "../types.js";
|
||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
|
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
|
||||||
|
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
|
||||||
|
|
||||||
export function ShopsView({
|
export function ShopsView({
|
||||||
data,
|
data,
|
||||||
@@ -13,18 +15,16 @@ export function ShopsView({
|
|||||||
onEditShop: (shop: Shop) => void;
|
onEditShop: (shop: Shop) => void;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => api.deleteShop(id),
|
mutationFn: (id: string) => api.deleteShop(id),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
setConfirmDelete(null);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDelete = (shopId: string, shopName: string, productCount: number) => {
|
|
||||||
const tail = productCount > 0
|
|
||||||
? ` ${productCount} product${productCount === 1 ? "" : "s"} will lose this shop.`
|
|
||||||
: "";
|
|
||||||
if (window.confirm(`Delete "${shopName}"?${tail}`)) remove.mutate(shopId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -101,7 +101,7 @@ export function ShopsView({
|
|||||||
<Icon name="edit" size={14} />
|
<Icon name="edit" size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(s.id, s.name, count)}
|
onClick={() => setConfirmDelete({ id: s.id, name: s.name, count })}
|
||||||
title="Remove shop"
|
title="Remove shop"
|
||||||
aria-label={`Remove shop ${s.name}`}
|
aria-label={`Remove shop ${s.name}`}
|
||||||
disabled={remove.isPending}
|
disabled={remove.isPending}
|
||||||
@@ -122,6 +122,21 @@ export function ShopsView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`Delete "${confirmDelete.name}"?`}
|
||||||
|
message={
|
||||||
|
confirmDelete.count > 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user