Fix 15 UX friction points across modals, navigation, and accessibility
Build and push image / build (push) Successful in 49s

Addresses bulk operation safety (confirmation for consume/gone), drawer
action hierarchy, exit animations, sidebar action distinction, toast
dismissibility and ARIA, retry affordance, sort direction toggle,
sequential audit queue from dashboard, mobile nav separation, price label
disambiguation, grouped-view sort consistency, footer context hints, bin
deletion feedback, segmented control ARIA, and drawer focus trapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 10:44:19 -04:00
parent 3bdf857099
commit 00a76a10d7
16 changed files with 356 additions and 78 deletions
+10 -2
View File
@@ -129,8 +129,16 @@ export function App() {
setSelected(null);
setModal("gone");
};
const [auditQueue, setAuditQueue] = useState<Item[]>([]);
const openAudit = (i?: Item) => {
setModalItem(i ?? null);
setAuditQueue([]);
setModal("audit");
};
const openAuditQueue = (queue: Item[]) => {
if (queue.length === 0) return;
setModalItem(queue[0]!);
setAuditQueue(queue);
setModal("audit");
};
const openCheckout = (i?: Item) => {
@@ -217,7 +225,7 @@ export function App() {
<main className="main parchment" style={{ minWidth: 0 }}>
<Routes>
<Route path="/" element={
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onSelectItem={setSelected} />
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onAuditQueue={openAuditQueue} onSelectItem={setSelected} />
} />
<Route path="/inventory" element={
<Inventory
@@ -305,7 +313,7 @@ export function App() {
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
)}
{modal === "audit" && (
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} />
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} queue={auditQueue.length > 0 ? auditQueue : undefined} />
)}
{modal === "checkout" && (
<CheckoutFlow data={data} onClose={() => setModal(null)} item={modalItem} />
+44 -22
View File
@@ -4,6 +4,8 @@ import { TYPES, helpers } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js";
import { useExitAnimation } from "../hooks/useExitAnimation.js";
import { useFocusTrap } from "../hooks/useFocusTrap.js";
// Right-side drawer for an inventory instance. Shows the asset id and
// product context up top, then per-batch fields (price, THC, weight),
@@ -40,14 +42,16 @@ export function ProductDetail({
const isActive = item.status === "active";
const isCheckedOut = item.status === "checked-out";
const { closing, triggerClose } = useExitAnimation(220, onClose);
const trapRef = useFocusTrap<HTMLDivElement>();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "Escape") triggerClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
}, [triggerClose]);
// Sibling instances of the same product (excluding this one) — useful for
// seeing previous purchases of the same SKU.
@@ -101,6 +105,9 @@ export function ProductDetail({
return (
<div
ref={trapRef}
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
@@ -108,16 +115,16 @@ export function ProductDetail({
zIndex: 50,
display: "flex",
justifyContent: "flex-end",
animation: "backdrop-in 200ms ease-out",
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
}}
onClick={onClose}
onClick={triggerClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(720px, 100vw)",
height: "100%",
animation: "drawer-in 250ms ease-out",
animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
background: "var(--bg)",
borderLeft: "1px solid var(--line)",
overflow: "auto",
@@ -140,32 +147,47 @@ export function ProductDetail({
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Inventory · <span className="mono">{item.assetId}</span>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}>
{isActive && (
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{isActive && overdue && (
<Btn variant="sage" icon="search" onClick={() => onAudit(item)}>
Audit
</Btn>
)}
{isActive && !overdue && (
<Btn variant="secondary" icon="pocket" onClick={() => onCheckout(item)}>
Check out
</Btn>
)}
{isCheckedOut && (
<Btn variant="sage" icon="pocket" onClick={() => onCheckin(item)}>
Check in
</Btn>
)}
<div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 2px" }} />
{isActive && !overdue && (
<Btn variant="ghost" icon="search" onClick={() => onAudit(item)}>
Audit
</Btn>
)}
{isActive && overdue && (
<Btn variant="ghost" icon="pocket" onClick={() => onCheckout(item)}>
Check out
</Btn>
)}
{isActive && (
<Btn variant="ghost" icon="check" onClick={() => onAudit(item)}>
Audit
</Btn>
)}
{isCheckedOut && (
<Btn variant="sage" icon="check" onClick={() => onCheckin(item)}>
Check in
{(isActive || isCheckedOut) && (
<Btn variant="ghost" icon="leaf" onClick={() => onConsume(item)}>
Consume
</Btn>
)}
{(isActive || isCheckedOut) && (
<Btn variant="secondary" icon="check" onClick={() => onConsume(item)}>
Mark consumed
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)}>
Gone
</Btn>
)}
{(isActive || isCheckedOut) && (
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)} />
)}
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)} />
<Btn variant="ghost" icon="close" onClick={onClose} />
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)}>
Edit
</Btn>
<Btn variant="ghost" icon="close" onClick={triggerClose} />
</div>
</div>
+4 -4
View File
@@ -88,16 +88,16 @@ export function Sidebar({
))}
<div className="nav-section">Quick</div>
<div className="nav-divider" />
<button className="nav-link" onClick={onAddProduct} title="Add product">
<button className="nav-link nav-action" onClick={onAddProduct} title="Add product">
<Icon name="plus" size={16} /> <span className="nav-label">Add product</span>
</button>
<button className="nav-link" onClick={onAudit} title="Audit">
<button className="nav-link nav-action" onClick={onAudit} title="Audit">
<Icon name="search" size={16} /> <span className="nav-label">Audit</span>
</button>
<button className="nav-link" onClick={onCheckout} title="Check out">
<button className="nav-link nav-action" onClick={onCheckout} title="Check out">
<Icon name="pocket" size={16} /> <span className="nav-label">Check out</span>
</button>
<button className="nav-link" onClick={onMarkFinished} title="Mark consumed">
<button className="nav-link nav-action" onClick={onMarkFinished} title="Mark consumed">
<Icon name="check" size={16} /> <span className="nav-label">Mark consumed</span>
</button>
</aside>
+47 -5
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useState } from "react";
import { createContext, useCallback, useContext, useRef, useState } from "react";
import { Icon } from "./primitives/index.js";
type ToastType = "success" | "error";
@@ -19,18 +19,44 @@ let nextId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer) clearTimeout(timer);
timers.current.delete(id);
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const startTimer = useCallback((id: number) => {
const timer = setTimeout(() => {
timers.current.delete(id);
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
timers.current.set(id, timer);
}, []);
const pauseTimer = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer) {
clearTimeout(timer);
timers.current.delete(id);
}
}, []);
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);
}, []);
startTimer(id);
}, [startTimer]);
return (
<ToastContext.Provider value={{ toast }}>
{children}
{toasts.length > 0 && (
<div
role="status"
aria-live="polite"
style={{
position: "fixed",
bottom: 24,
@@ -39,12 +65,13 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
display: "flex",
flexDirection: "column",
gap: 8,
pointerEvents: "none",
}}
>
{toasts.map((t) => (
<div
key={t.id}
onMouseEnter={() => pauseTimer(t.id)}
onMouseLeave={() => startTimer(t.id)}
style={{
pointerEvents: "auto",
padding: "12px 18px",
@@ -66,7 +93,22 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
size={16}
color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"}
/>
{t.message}
<span style={{ flex: 1 }}>{t.message}</span>
<button
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 2,
display: "inline-flex",
color: "var(--ink-3)",
flexShrink: 0,
}}
>
<Icon name="close" size={12} />
</button>
</div>
))}
</div>
@@ -607,7 +607,10 @@ function InstanceDetailsStep({
/>
</Field>
)}
<Field label="Price ($)">
<Field
label={isDiscrete ? "Price per unit ($)" : "Total price ($)"}
hint={isDiscrete && form.price > 0 ? `1 × ${fmt.money(form.price)} = ${fmt.money(form.price)} total` : undefined}
>
<Input
type="number"
step="0.01"
+24 -3
View File
@@ -28,10 +28,12 @@ export function AuditFlow({
data,
onClose,
item: initialItem,
queue,
}: {
data: Bootstrap;
onClose: () => void;
item: Item | null;
queue?: Item[];
}) {
const qc = useQueryClient();
const { toast } = useToast();
@@ -40,6 +42,7 @@ export function AuditFlow({
.filter((i) => i.status === "active")
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
const [queueIdx, setQueueIdx] = useState(0);
const [itemId, setItemId] = useState(initialItem?.id ?? "");
const [date, setDate] = useState(getToday(getStoredTimezone()));
const [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset");
@@ -66,6 +69,7 @@ export function AuditFlow({
setValue(initialValueFor(item));
setInputMode(item?.containerWeight != null ? "container" : "direct");
setContainerTotal("");
setError(null);
}, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps
const tare = item ? helpers.tare(item) : null;
@@ -92,7 +96,14 @@ export function AuditFlow({
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`);
onClose();
if (queue && queueIdx + 1 < queue.length) {
const nextItem = queue[queueIdx + 1]!;
setQueueIdx((i) => i + 1);
setItemId(nextItem.id);
setError(null);
} else {
onClose();
}
},
onError: (e: Error) => setError(e.message),
});
@@ -131,7 +142,11 @@ export function AuditFlow({
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader title={item ? ml.title : "Audit"} eyebrow="" onClose={onClose} />
<ModalHeader
title={item ? ml.title : "Audit"}
eyebrow={queue && queue.length > 1 ? `${queueIdx + 1} of ${queue.length} overdue` : ""}
onClose={onClose}
/>
<div style={{ padding: 32 }}>
<ScanField
@@ -314,7 +329,13 @@ export function AuditFlow({
disabled={audit.isPending || !item}
onClick={() => audit.mutate()}
>
{audit.isPending ? "Saving…" : "Save audit"}
{audit.isPending
? "Saving…"
: error
? "Try again"
: queue && queueIdx + 1 < queue.length
? "Save & next"
: "Save audit"}
</Btn>
</div>
</ModalFooter>
+26 -3
View File
@@ -123,8 +123,29 @@ export function BulkConsumeModal({
</Field>
</div>
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
Items: {eligible.map((i) => i.name).join(", ")}
<div
style={{
marginTop: 20,
padding: 14,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 8 }}>
Items to consume
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{eligible.map((i) => (
<div key={i.id} style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontWeight: 500 }}>{i.name}</span>
<span className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>{i.assetId}</span>
</div>
))}
</div>
<div style={{ marginTop: 10, fontSize: 11, color: "var(--terracotta)", fontWeight: 500 }}>
This cannot be undone.
</div>
</div>
</>
)}
@@ -135,7 +156,9 @@ export function BulkConsumeModal({
</div>
<ModalFooter>
<div />
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{eligible.length} item{eligible.length === 1 ? "" : "s"} will be permanently archived.
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
+26 -3
View File
@@ -131,8 +131,29 @@ export function BulkGoneModal({
/>
</Field>
</div>
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
Items: {eligible.map((i) => i.name).join(", ")}
<div
style={{
marginTop: 20,
padding: 14,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 8 }}>
Items to mark gone
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{eligible.map((i) => (
<div key={i.id} style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontWeight: 500 }}>{i.name}</span>
<span className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>{i.assetId}</span>
</div>
))}
</div>
<div style={{ marginTop: 10, fontSize: 11, color: "var(--terracotta)", fontWeight: 500 }}>
This cannot be undone.
</div>
</div>
</>
)}
@@ -143,7 +164,9 @@ export function BulkGoneModal({
</div>
<ModalFooter>
<div />
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{eligible.length} item{eligible.length === 1 ? "" : "s"} will be permanently archived.
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
+4 -2
View File
@@ -166,7 +166,9 @@ export function ConsumeFlow({
</div>
<ModalFooter>
<div />
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{item ? `Lasted ${lifespan} day${lifespan === 1 ? "" : "s"} from purchase.` : ""}
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
@@ -175,7 +177,7 @@ export function ConsumeFlow({
disabled={finish.isPending || !item}
onClick={() => finish.mutate()}
>
{finish.isPending ? "Saving…" : "Mark consumed"}
{finish.isPending ? "Saving…" : error ? "Try again" : "Mark consumed"}
</Btn>
</div>
</ModalFooter>
+24 -12
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef } from "react";
import { createContext, useContext, useEffect, useRef } from "react";
import { useExitAnimation } from "../../hooks/useExitAnimation.js";
import { Btn } from "../primitives/index.js";
const ModalCloseCtx = createContext<(() => void) | null>(null);
export function ModalBackdrop({
children,
onClose,
@@ -10,6 +13,7 @@ export function ModalBackdrop({
}) {
const backdropRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<Element | null>(null);
const { closing, triggerClose } = useExitAnimation(180, onClose);
useEffect(() => {
previousFocus.current = document.activeElement;
@@ -17,7 +21,7 @@ export function ModalBackdrop({
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
triggerClose();
return;
}
if (e.key === "Tab" && backdropRef.current) {
@@ -50,7 +54,7 @@ export function ModalBackdrop({
previousFocus.current.focus();
}
};
}, [onClose]);
}, [triggerClose]);
return (
<div
@@ -66,16 +70,23 @@ export function ModalBackdrop({
justifyContent: "center",
alignItems: "flex-start",
overflow: "auto",
animation: "backdrop-in 200ms ease-out",
animation: closing ? "backdrop-out 180ms ease-in forwards" : "backdrop-in 200ms ease-out",
}}
onClick={onClose}
onClick={triggerClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{ width: "100%", display: "flex", justifyContent: "center", animation: "modal-in 200ms ease-out" }}
>
{children}
</div>
<ModalCloseCtx.Provider value={triggerClose}>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
display: "flex",
justifyContent: "center",
animation: closing ? "modal-out 180ms ease-in forwards" : "modal-in 200ms ease-out",
}}
>
{children}
</div>
</ModalCloseCtx.Provider>
</div>
);
}
@@ -91,6 +102,7 @@ export function ModalHeader({
eyebrowColor?: string;
onClose: () => void;
}) {
const animatedClose = useContext(ModalCloseCtx);
return (
<div
style={{
@@ -111,7 +123,7 @@ export function ModalHeader({
{title}
</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
<Btn variant="ghost" icon="close" onClick={animatedClose ?? onClose} />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { useCallback, useState } from "react";
export function useExitAnimation(durationMs: number, onDone: () => void) {
const [closing, setClosing] = useState(false);
const triggerClose = useCallback(() => {
setClosing(true);
setTimeout(onDone, durationMs);
}, [durationMs, onDone]);
return { closing, triggerClose };
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
export function useFocusTrap<T extends HTMLElement>() {
const ref = useRef<T>(null);
const previousFocus = useRef<Element | null>(null);
useEffect(() => {
previousFocus.current = document.activeElement;
const el = ref.current;
if (!el) return;
const firstFocusable = el.querySelector<HTMLElement>(FOCUSABLE);
firstFocusable?.focus();
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !el) return;
const focusable = el.querySelectorAll<HTMLElement>(FOCUSABLE);
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", handleTab);
return () => {
document.removeEventListener("keydown", handleTab);
if (previousFocus.current instanceof HTMLElement) {
previousFocus.current.focus();
}
};
}, []);
return ref;
}
+27
View File
@@ -69,6 +69,16 @@
padding: 16px 12px 6px;
font-weight: 500;
}
.nav-action {
border: 1px dashed var(--line-strong);
color: var(--ink-2);
font-size: 13px;
}
.nav-action:hover {
border-style: solid;
background: var(--sage-soft);
color: var(--ink);
}
.nav-divider {
display: none;
}
@@ -109,6 +119,18 @@
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes backdrop-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modal-out {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.97) translateY(8px); }
}
@keyframes drawer-out {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
@media (max-width: 1200px) {
.inv-row > :nth-child(5),
@@ -151,12 +173,17 @@
display: none;
}
.nav-divider {
display: block;
width: 1px;
height: 24px;
background: var(--line);
flex-shrink: 0;
align-self: center;
}
.nav-action {
border-style: solid;
border-color: var(--line);
}
.main {
padding-bottom: 60px;
}
+3
View File
@@ -8,6 +8,7 @@ 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";
import { useToast } from "../components/Toast.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,6 +52,7 @@ export function BinsView({
onEditBin: (bin: Bin) => void;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const items = useMemo(() => enrichItems(data), [data]);
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
@@ -58,6 +60,7 @@ export function BinsView({
const remove = useMutation({
mutationFn: (id: string) => api.deleteBin(id),
onSuccess: () => {
toast(`Deleted bin "${confirmDelete?.name ?? ""}"`);
qc.invalidateQueries({ queryKey: ["bootstrap"] });
setConfirmDelete(null);
},
+4 -2
View File
@@ -20,11 +20,13 @@ export function Dashboard({
data,
stats,
onAuditItem,
onAuditQueue,
onSelectItem,
}: {
data: Bootstrap;
stats: Stats;
onAuditItem: (i: Item) => void;
onAuditQueue: (items: Item[]) => void;
onSelectItem: (i: Item) => void;
}) {
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
@@ -174,8 +176,8 @@ export function Dashboard({
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
</div>
</div>
<Btn variant="secondary" icon="check" onClick={() => onAuditItem(overdue[0]!)}>
Run audit
<Btn variant="secondary" icon="search" onClick={() => onAuditQueue(overdue)}>
Audit {overdue.length > 1 ? `all ${overdue.length}` : ""}
</Btn>
</div>
</Card>
+54 -19
View File
@@ -40,6 +40,7 @@ export function Inventory({
const [filter, setFilter] = useState<FilterKey>("active");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<SortKey>("recent");
const [sortAsc, setSortAsc] = useState(false);
const [search, setSearch] = useState("");
const [view, setView] = useState<ViewKey>(
() => (localStorage.getItem("apothecary.inventoryView") as ViewKey | null) ?? "flat",
@@ -49,14 +50,15 @@ export function Inventory({
}, [view]);
const sortFn = (a: Item, b: Item) => {
if (sortBy === "recent") return +new Date(b.purchaseDate) - +new Date(a.purchaseDate);
if (sortBy === "name") return a.name.localeCompare(b.name);
if (sortBy === "thc") return b.thc - a.thc;
const dir = sortAsc ? -1 : 1;
if (sortBy === "recent") return dir * (+new Date(b.purchaseDate) - +new Date(a.purchaseDate));
if (sortBy === "name") return dir * a.name.localeCompare(b.name);
if (sortBy === "thc") return dir * (b.thc - a.thc);
if (sortBy === "remaining")
return helpers.estimatedRemaining(b, getToday(getStoredTimezone())) - helpers.estimatedRemaining(a, getToday(getStoredTimezone()));
if (sortBy === "price") return b.price - a.price;
return dir * (helpers.estimatedRemaining(b, getToday(getStoredTimezone())) - helpers.estimatedRemaining(a, getToday(getStoredTimezone())));
if (sortBy === "price") return dir * (b.price - a.price);
if (sortBy === "audit")
return helpers.daysSinceCheck(b, getToday(getStoredTimezone())) - helpers.daysSinceCheck(a, getToday(getStoredTimezone()));
return dir * (helpers.daysSinceCheck(b, getToday(getStoredTimezone())) - helpers.daysSinceCheck(a, getToday(getStoredTimezone())));
return 0;
};
@@ -84,7 +86,7 @@ export function Inventory({
return out;
}, [items, data, filter, typeFilter, search]);
const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy]);
const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy, sortAsc]);
type Group = {
productId: string;
@@ -112,12 +114,11 @@ export function Inventory({
});
}
out.sort((a, b) => {
const aMax = Math.max(...a.items.map((p) => +new Date(p.purchaseDate)));
const bMax = Math.max(...b.items.map((p) => +new Date(p.purchaseDate)));
return bMax - aMax;
if (a.items.length === 0 || b.items.length === 0) return 0;
return sortFn(a.items[0]!, b.items[0]!);
});
return out;
}, [filtered, sortBy]);
}, [filtered, sortBy, sortAsc]);
// All visible item IDs in display order (flat or grouped)
const visibleIds = useMemo(() => {
@@ -255,15 +256,23 @@ export function Inventory({
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
onChange={(e) => {
const key = e.target.value as SortKey;
if (key === sortBy) {
setSortAsc((prev) => !prev);
} else {
setSortBy(key);
setSortAsc(false);
}
}}
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
>
<option value="recent">Recent first</option>
<option value="name">Name (AZ)</option>
<option value="thc">THC % (high)</option>
<option value="remaining">Remaining (high)</option>
<option value="price">Price (high)</option>
<option value="audit">Audit overdue first</option>
<option value="thc">THC %</option>
<option value="remaining">Remaining</option>
<option value="price">Price</option>
<option value="audit">Audit overdue</option>
</Select>
</div>
</Card>
@@ -271,7 +280,15 @@ export function Inventory({
<Card padded={false}>
<HeaderRow
sortBy={sortBy}
onSort={setSortBy}
sortAsc={sortAsc}
onSort={(key) => {
if (key === sortBy) {
setSortAsc((prev) => !prev);
} else {
setSortBy(key);
setSortAsc(false);
}
}}
isAllSelected={isAllSelected}
isIndeterminate={isIndeterminate}
onToggleAll={toggleAll}
@@ -346,8 +363,20 @@ function Segmented<T extends string>({
options: [T, string][];
onChange: (v: T) => void;
}) {
const handleKeyDown = (e: React.KeyboardEvent, idx: number) => {
let next = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % options.length;
if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + options.length) % options.length;
if (next >= 0) {
e.preventDefault();
onChange(options[next]![0]);
(e.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();
}
};
return (
<div
role="tablist"
style={{
display: "inline-flex",
background: "var(--bg-2)",
@@ -356,10 +385,14 @@ function Segmented<T extends string>({
padding: 3,
}}
>
{options.map(([k, l]) => (
{options.map(([k, l], i) => (
<button
key={k}
role="tab"
aria-selected={value === k}
tabIndex={value === k ? 0 : -1}
onClick={() => onChange(k)}
onKeyDown={(e) => handleKeyDown(e, i)}
style={{
padding: "6px 14px",
fontSize: 12,
@@ -384,12 +417,14 @@ const COL_LABELS = ["", "", "Item", "Brand", "Shop", "THC", "Price", "Remaining"
function HeaderRow({
sortBy,
sortAsc,
onSort,
isAllSelected,
isIndeterminate,
onToggleAll,
}: {
sortBy: SortKey;
sortAsc: boolean;
onSort: (k: SortKey) => void;
isAllSelected: boolean;
isIndeterminate: boolean;
@@ -447,7 +482,7 @@ function HeaderRow({
}}
>
{label}
{active && <span style={{ fontSize: 9 }}></span>}
{active && <span style={{ fontSize: 9 }}>{sortAsc ? "▲" : "▼"}</span>}
</button>
);
})}