Fix 15 UX friction points across modals, navigation, and accessibility
Build and push image / build (push) Successful in 49s
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:
+10
-2
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 (A–Z)</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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user