UX overhaul: routing, accessibility, feedback, and polish
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:
2026-05-04 18:54:49 -04:00
parent 80034b47c5
commit a82045d1bd
21 changed files with 640 additions and 145 deletions
+59 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 && (
+14 -7
View File
@@ -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>
+22 -23
View File
@@ -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>
); );
+76
View File
@@ -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>
);
}
+9
View File
@@ -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>
+50 -1
View File
@@ -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>
-1
View File
@@ -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
View File
@@ -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>,
); );
+54
View File
@@ -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;
+5
View File
@@ -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
View File
@@ -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>
); );
} }
+24 -9
View File
@@ -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>
); );
} }
+6 -6
View File
@@ -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
View File
@@ -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>
); );
+71 -3
View File
@@ -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>
+24 -9
View File
@@ -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>
); );
} }