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:
+25
-12
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user