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
+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 type { Bootstrap, Bin, Item } from "../types.js";
import { helpers, TODAY_STR, enrichItems } from "../types.js";
@@ -6,6 +6,7 @@ import { remainingShort } from "../stats.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { api } from "../api.js";
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
// Bins follow a "letter + number" naming convention (A1, A2, B1, …).
// Group by the letter prefix so each letter starts a new visual row,
@@ -51,19 +52,16 @@ export function BinsView({
const qc = useQueryClient();
const items = useMemo(() => enrichItems(data), [data]);
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
const remove = useMutation({
mutationFn: (id: string) => api.deleteBin(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
setConfirmDelete(null);
},
});
const handleDelete = (binId: string, binName: string, activeCount: number) => {
const msg =
activeCount > 0
? `Delete "${binName}"? ${activeCount} active item${activeCount === 1 ? "" : "s"} will be moved to Unassigned.`
: `Delete "${binName}"?`;
if (window.confirm(msg)) remove.mutate(binId);
};
const grouped = groupBins(data.bins);
return (
@@ -105,7 +103,7 @@ export function BinsView({
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${bins.length}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(auto-fill, minmax(280px, 1fr))`,
gap: 14,
}}
>
@@ -159,7 +157,7 @@ export function BinsView({
<Icon name="edit" size={14} />
</button>
<button
onClick={() => handleDelete(bin.id, bin.name, binItems.length)}
onClick={() => setConfirmDelete({ id: bin.id, name: bin.name, count: binItems.length })}
title="Remove bin"
aria-label={`Remove bin ${bin.name}`}
disabled={remove.isPending}
@@ -276,6 +274,21 @@ export function BinsView({
</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>
);
}
+24 -9
View File
@@ -1,7 +1,9 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Brand } from "../types.js";
import { api } from "../api.js";
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
export function BrandsView({
data,
@@ -13,18 +15,16 @@ export function BrandsView({
onEditBrand: (brand: Brand) => void;
}) {
const qc = useQueryClient();
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
const remove = useMutation({
mutationFn: (id: string) => api.deleteBrand(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
setConfirmDelete(null);
},
});
const handleDelete = (brandId: string, brandName: string, itemCount: number) => {
const tail = itemCount > 0
? ` ${itemCount} inventory item${itemCount === 1 ? "" : "s"} will be unbranded.`
: "";
if (window.confirm(`Delete "${brandName}"?${tail}`)) remove.mutate(brandId);
};
return (
<div
style={{
@@ -102,7 +102,7 @@ export function BrandsView({
<Icon name="edit" size={14} />
</button>
<button
onClick={() => handleDelete(b.id, b.name, itemCount)}
onClick={() => setConfirmDelete({ id: b.id, name: b.name, count: itemCount })}
title="Remove brand"
aria-label={`Remove brand ${b.name}`}
disabled={remove.isPending}
@@ -123,6 +123,21 @@ export function BrandsView({
})}
</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>
);
}
+6 -6
View File
@@ -68,7 +68,7 @@ export function Dashboard({
lineHeight: 1.1,
}}
>
Good evening.
{new Date().getHours() < 12 ? "Good morning." : new Date().getHours() < 17 ? "Good afternoon." : "Good evening."}
</h1>
</div>
@@ -85,7 +85,7 @@ export function Dashboard({
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
gap: 18,
marginBottom: 18,
}}
@@ -123,7 +123,7 @@ export function Dashboard({
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
gap: 18,
marginBottom: 18,
}}
@@ -181,7 +181,7 @@ export function Dashboard({
<div
style={{
display: "grid",
gridTemplateColumns: "2fr 1fr",
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
gap: 18,
marginBottom: 18,
}}
@@ -252,7 +252,7 @@ export function Dashboard({
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
gap: 18,
marginBottom: 18,
}}
@@ -280,7 +280,7 @@ export function Dashboard({
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1.4fr",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: 18,
}}
>
+51 -12
View File
@@ -183,6 +183,21 @@ export function Inventory({
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>
<Select
@@ -212,7 +227,7 @@ export function Inventory({
</Card>
<Card padded={false}>
<HeaderRow />
<HeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && (
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
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 (
<div
className="inv-header"
style={{
display: "grid",
gridTemplateColumns: GRID_COLS,
@@ -294,15 +313,34 @@ function HeaderRow() {
letterSpacing: "0.08em",
}}
>
<div></div>
<div>Item</div>
<div>Brand</div>
<div>Shop</div>
<div>THC %</div>
<div>Price</div>
<div>Remaining</div>
<div>Last checked</div>
<div>Bin</div>
{COL_LABELS.map((label, i) => {
const sk = COL_SORT[i];
if (!sk) return <div key={i}>{label}</div>;
const active = sortBy === sk;
return (
<button
key={i}
onClick={() => onSort(sk)}
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>
);
}
@@ -492,8 +530,9 @@ function ItemRow({
<span style={{ fontStyle: "italic" }}>never</span>
)}
</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>}
<span className="inv-row-chevron" style={{ color: "var(--ink-3)", marginLeft: "auto", fontSize: 14 }}></span>
</div>
</div>
);
+71 -3
View File
@@ -1,6 +1,74 @@
import type { Bootstrap } from "../types.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 function SettingsView({
@@ -69,15 +137,15 @@ export function SettingsView({
<Card style={{ marginBottom: 14 }}>
<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="Consumed" value={data.inventoryItems.filter((i) => i.status === "consumed").length} />
<Stat label="Gone" value={data.inventoryItems.filter((i) => i.status === "gone").length} />
<Stat label="Bins" value={data.bins.length} />
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="secondary">Export CSV</Btn>
<Btn variant="secondary">Export JSON</Btn>
<Btn variant="secondary" onClick={() => exportCSV(data)}>Export CSV</Btn>
<Btn variant="secondary" onClick={() => exportJSON(data)}>Export JSON</Btn>
</div>
</Card>
</div>
+24 -9
View File
@@ -1,7 +1,9 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Shop } from "../types.js";
import { api } from "../api.js";
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
export function ShopsView({
data,
@@ -13,18 +15,16 @@ export function ShopsView({
onEditShop: (shop: Shop) => void;
}) {
const qc = useQueryClient();
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
const remove = useMutation({
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 (
<div
style={{
@@ -101,7 +101,7 @@ export function ShopsView({
<Icon name="edit" size={14} />
</button>
<button
onClick={() => handleDelete(s.id, s.name, count)}
onClick={() => setConfirmDelete({ id: s.id, name: s.name, count })}
title="Remove shop"
aria-label={`Remove shop ${s.name}`}
disabled={remove.isPending}
@@ -122,6 +122,21 @@ export function ShopsView({
})}
</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>
);
}