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);
|
setSelected(null);
|
||||||
setModal("gone");
|
setModal("gone");
|
||||||
};
|
};
|
||||||
|
const [auditQueue, setAuditQueue] = useState<Item[]>([]);
|
||||||
const openAudit = (i?: Item) => {
|
const openAudit = (i?: Item) => {
|
||||||
setModalItem(i ?? null);
|
setModalItem(i ?? null);
|
||||||
|
setAuditQueue([]);
|
||||||
|
setModal("audit");
|
||||||
|
};
|
||||||
|
const openAuditQueue = (queue: Item[]) => {
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
setModalItem(queue[0]!);
|
||||||
|
setAuditQueue(queue);
|
||||||
setModal("audit");
|
setModal("audit");
|
||||||
};
|
};
|
||||||
const openCheckout = (i?: Item) => {
|
const openCheckout = (i?: Item) => {
|
||||||
@@ -217,7 +225,7 @@ export function App() {
|
|||||||
<main className="main parchment" style={{ minWidth: 0 }}>
|
<main className="main parchment" style={{ minWidth: 0 }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={
|
<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={
|
<Route path="/inventory" element={
|
||||||
<Inventory
|
<Inventory
|
||||||
@@ -305,7 +313,7 @@ export function App() {
|
|||||||
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
||||||
)}
|
)}
|
||||||
{modal === "audit" && (
|
{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" && (
|
{modal === "checkout" && (
|
||||||
<CheckoutFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
<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 { getToday, getStoredTimezone } from "../tz.js";
|
||||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
import { Btn, Pill, Icon } from "./primitives/index.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
|
// Right-side drawer for an inventory instance. Shows the asset id and
|
||||||
// product context up top, then per-batch fields (price, THC, weight),
|
// product context up top, then per-batch fields (price, THC, weight),
|
||||||
@@ -40,14 +42,16 @@ export function ProductDetail({
|
|||||||
|
|
||||||
const isActive = item.status === "active";
|
const isActive = item.status === "active";
|
||||||
const isCheckedOut = item.status === "checked-out";
|
const isCheckedOut = item.status === "checked-out";
|
||||||
|
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
||||||
|
const trapRef = useFocusTrap<HTMLDivElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") triggerClose();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onClose]);
|
}, [triggerClose]);
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -101,6 +105,9 @@ export function ProductDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={trapRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -108,16 +115,16 @@ export function ProductDetail({
|
|||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
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
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
width: "min(720px, 100vw)",
|
width: "min(720px, 100vw)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
animation: "drawer-in 250ms ease-out",
|
animation: closing ? "drawer-out 220ms ease-in forwards" : "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",
|
||||||
@@ -140,32 +147,47 @@ 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, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
{isActive && (
|
{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)}>
|
<Btn variant="ghost" icon="pocket" onClick={() => onCheckout(item)}>
|
||||||
Check out
|
Check out
|
||||||
</Btn>
|
</Btn>
|
||||||
)}
|
)}
|
||||||
{isActive && (
|
{(isActive || isCheckedOut) && (
|
||||||
<Btn variant="ghost" icon="check" onClick={() => onAudit(item)}>
|
<Btn variant="ghost" icon="leaf" onClick={() => onConsume(item)}>
|
||||||
Audit
|
Consume
|
||||||
</Btn>
|
|
||||||
)}
|
|
||||||
{isCheckedOut && (
|
|
||||||
<Btn variant="sage" icon="check" onClick={() => onCheckin(item)}>
|
|
||||||
Check in
|
|
||||||
</Btn>
|
</Btn>
|
||||||
)}
|
)}
|
||||||
{(isActive || isCheckedOut) && (
|
{(isActive || isCheckedOut) && (
|
||||||
<Btn variant="secondary" icon="check" onClick={() => onConsume(item)}>
|
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)}>
|
||||||
Mark consumed
|
Gone
|
||||||
</Btn>
|
</Btn>
|
||||||
)}
|
)}
|
||||||
{(isActive || isCheckedOut) && (
|
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)}>
|
||||||
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)} />
|
Edit
|
||||||
)}
|
</Btn>
|
||||||
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)} />
|
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -88,16 +88,16 @@ export function Sidebar({
|
|||||||
))}
|
))}
|
||||||
<div className="nav-section">Quick</div>
|
<div className="nav-section">Quick</div>
|
||||||
<div className="nav-divider" />
|
<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>
|
<Icon name="plus" size={16} /> <span className="nav-label">Add product</span>
|
||||||
</button>
|
</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>
|
<Icon name="search" size={16} /> <span className="nav-label">Audit</span>
|
||||||
</button>
|
</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>
|
<Icon name="pocket" size={16} /> <span className="nav-label">Check out</span>
|
||||||
</button>
|
</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>
|
<Icon name="check" size={16} /> <span className="nav-label">Mark consumed</span>
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</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";
|
import { Icon } from "./primitives/index.js";
|
||||||
|
|
||||||
type ToastType = "success" | "error";
|
type ToastType = "success" | "error";
|
||||||
@@ -19,18 +19,44 @@ let nextId = 0;
|
|||||||
|
|
||||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
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 toast = useCallback((message: string, type: ToastType = "success") => {
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
setToasts((prev) => [...prev, { id, message, type }]);
|
setToasts((prev) => [...prev, { id, message, type }]);
|
||||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
|
startTimer(id);
|
||||||
}, []);
|
}, [startTimer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={{ toast }}>
|
<ToastContext.Provider value={{ toast }}>
|
||||||
{children}
|
{children}
|
||||||
{toasts.length > 0 && (
|
{toasts.length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
bottom: 24,
|
bottom: 24,
|
||||||
@@ -39,12 +65,13 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
onMouseEnter={() => pauseTimer(t.id)}
|
||||||
|
onMouseLeave={() => startTimer(t.id)}
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
padding: "12px 18px",
|
padding: "12px 18px",
|
||||||
@@ -66,7 +93,22 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
size={16}
|
size={16}
|
||||||
color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"}
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -607,7 +607,10 @@ function InstanceDetailsStep({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ export function AuditFlow({
|
|||||||
data,
|
data,
|
||||||
onClose,
|
onClose,
|
||||||
item: initialItem,
|
item: initialItem,
|
||||||
|
queue,
|
||||||
}: {
|
}: {
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
item: Item | null;
|
item: Item | null;
|
||||||
|
queue?: Item[];
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -40,6 +42,7 @@ export function AuditFlow({
|
|||||||
.filter((i) => i.status === "active")
|
.filter((i) => i.status === "active")
|
||||||
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
|
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
|
||||||
|
|
||||||
|
const [queueIdx, setQueueIdx] = useState(0);
|
||||||
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||||
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
const [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset");
|
const [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset");
|
||||||
@@ -66,6 +69,7 @@ export function AuditFlow({
|
|||||||
setValue(initialValueFor(item));
|
setValue(initialValueFor(item));
|
||||||
setInputMode(item?.containerWeight != null ? "container" : "direct");
|
setInputMode(item?.containerWeight != null ? "container" : "direct");
|
||||||
setContainerTotal("");
|
setContainerTotal("");
|
||||||
|
setError(null);
|
||||||
}, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const tare = item ? helpers.tare(item) : null;
|
const tare = item ? helpers.tare(item) : null;
|
||||||
@@ -92,7 +96,14 @@ export function AuditFlow({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`);
|
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),
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
@@ -131,7 +142,11 @@ export function AuditFlow({
|
|||||||
boxShadow: "var(--shadow-lg)",
|
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 }}>
|
<div style={{ padding: 32 }}>
|
||||||
<ScanField
|
<ScanField
|
||||||
@@ -314,7 +329,13 @@ export function AuditFlow({
|
|||||||
disabled={audit.isPending || !item}
|
disabled={audit.isPending || !item}
|
||||||
onClick={() => audit.mutate()}
|
onClick={() => audit.mutate()}
|
||||||
>
|
>
|
||||||
{audit.isPending ? "Saving…" : "Save audit"}
|
{audit.isPending
|
||||||
|
? "Saving…"
|
||||||
|
: error
|
||||||
|
? "Try again"
|
||||||
|
: queue && queueIdx + 1 < queue.length
|
||||||
|
? "Save & next"
|
||||||
|
: "Save audit"}
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
@@ -123,8 +123,29 @@ export function BulkConsumeModal({
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
<div
|
||||||
Items: {eligible.map((i) => i.name).join(", ")}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +156,9 @@ export function BulkConsumeModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalFooter>
|
<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 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
<Btn
|
<Btn
|
||||||
|
|||||||
@@ -131,8 +131,29 @@ export function BulkGoneModal({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
<div
|
||||||
Items: {eligible.map((i) => i.name).join(", ")}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -143,7 +164,9 @@ export function BulkGoneModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalFooter>
|
<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 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
<Btn
|
<Btn
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ export function ConsumeFlow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalFooter>
|
<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 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
<Btn
|
<Btn
|
||||||
@@ -175,7 +177,7 @@ export function ConsumeFlow({
|
|||||||
disabled={finish.isPending || !item}
|
disabled={finish.isPending || !item}
|
||||||
onClick={() => finish.mutate()}
|
onClick={() => finish.mutate()}
|
||||||
>
|
>
|
||||||
{finish.isPending ? "Saving…" : "Mark consumed"}
|
{finish.isPending ? "Saving…" : error ? "Try again" : "Mark consumed"}
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
</ModalFooter>
|
</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";
|
import { Btn } from "../primitives/index.js";
|
||||||
|
|
||||||
|
const ModalCloseCtx = createContext<(() => void) | null>(null);
|
||||||
|
|
||||||
export function ModalBackdrop({
|
export function ModalBackdrop({
|
||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -10,6 +13,7 @@ export function ModalBackdrop({
|
|||||||
}) {
|
}) {
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
const previousFocus = useRef<Element | null>(null);
|
const previousFocus = useRef<Element | null>(null);
|
||||||
|
const { closing, triggerClose } = useExitAnimation(180, onClose);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
previousFocus.current = document.activeElement;
|
previousFocus.current = document.activeElement;
|
||||||
@@ -17,7 +21,7 @@ export function ModalBackdrop({
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClose();
|
triggerClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Tab" && backdropRef.current) {
|
if (e.key === "Tab" && backdropRef.current) {
|
||||||
@@ -50,7 +54,7 @@ export function ModalBackdrop({
|
|||||||
previousFocus.current.focus();
|
previousFocus.current.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [triggerClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -66,16 +70,23 @@ export function ModalBackdrop({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
overflow: "auto",
|
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
|
<ModalCloseCtx.Provider value={triggerClose}>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<div
|
||||||
style={{ width: "100%", display: "flex", justifyContent: "center", animation: "modal-in 200ms ease-out" }}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
style={{
|
||||||
{children}
|
width: "100%",
|
||||||
</div>
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
animation: closing ? "modal-out 180ms ease-in forwards" : "modal-in 200ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</ModalCloseCtx.Provider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,6 +102,7 @@ export function ModalHeader({
|
|||||||
eyebrowColor?: string;
|
eyebrowColor?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const animatedClose = useContext(ModalCloseCtx);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -111,7 +123,7 @@ export function ModalHeader({
|
|||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
<Btn variant="ghost" icon="close" onClick={animatedClose ?? onClose} />
|
||||||
</div>
|
</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;
|
padding: 16px 12px 6px;
|
||||||
font-weight: 500;
|
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 {
|
.nav-divider {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -109,6 +119,18 @@
|
|||||||
from { transform: translateY(100%); opacity: 0; }
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
to { transform: translateY(0); opacity: 1; }
|
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) {
|
@media (max-width: 1200px) {
|
||||||
.inv-row > :nth-child(5),
|
.inv-row > :nth-child(5),
|
||||||
@@ -151,12 +173,17 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.nav-divider {
|
.nav-divider {
|
||||||
|
display: block;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
.nav-action {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
.main {
|
.main {
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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";
|
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
|
||||||
|
import { useToast } from "../components/Toast.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,6 +52,7 @@ export function BinsView({
|
|||||||
onEditBin: (bin: Bin) => void;
|
onEditBin: (bin: Bin) => void;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
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 [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
|
||||||
@@ -58,6 +60,7 @@ export function BinsView({
|
|||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => api.deleteBin(id),
|
mutationFn: (id: string) => api.deleteBin(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
toast(`Deleted bin "${confirmDelete?.name ?? ""}"`);
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ export function Dashboard({
|
|||||||
data,
|
data,
|
||||||
stats,
|
stats,
|
||||||
onAuditItem,
|
onAuditItem,
|
||||||
|
onAuditQueue,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
}: {
|
}: {
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
onAuditItem: (i: Item) => void;
|
onAuditItem: (i: Item) => void;
|
||||||
|
onAuditQueue: (items: Item[]) => void;
|
||||||
onSelectItem: (i: Item) => void;
|
onSelectItem: (i: Item) => void;
|
||||||
}) {
|
}) {
|
||||||
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
|
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
|
||||||
@@ -174,8 +176,8 @@ export function Dashboard({
|
|||||||
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
|
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Btn variant="secondary" icon="check" onClick={() => onAuditItem(overdue[0]!)}>
|
<Btn variant="secondary" icon="search" onClick={() => onAuditQueue(overdue)}>
|
||||||
Run audit
|
Audit {overdue.length > 1 ? `all ${overdue.length}` : ""}
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
+54
-19
@@ -40,6 +40,7 @@ export function Inventory({
|
|||||||
const [filter, setFilter] = useState<FilterKey>("active");
|
const [filter, setFilter] = useState<FilterKey>("active");
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
const [sortBy, setSortBy] = useState<SortKey>("recent");
|
const [sortBy, setSortBy] = useState<SortKey>("recent");
|
||||||
|
const [sortAsc, setSortAsc] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [view, setView] = useState<ViewKey>(
|
const [view, setView] = useState<ViewKey>(
|
||||||
() => (localStorage.getItem("apothecary.inventoryView") as ViewKey | null) ?? "flat",
|
() => (localStorage.getItem("apothecary.inventoryView") as ViewKey | null) ?? "flat",
|
||||||
@@ -49,14 +50,15 @@ export function Inventory({
|
|||||||
}, [view]);
|
}, [view]);
|
||||||
|
|
||||||
const sortFn = (a: Item, b: Item) => {
|
const sortFn = (a: Item, b: Item) => {
|
||||||
if (sortBy === "recent") return +new Date(b.purchaseDate) - +new Date(a.purchaseDate);
|
const dir = sortAsc ? -1 : 1;
|
||||||
if (sortBy === "name") return a.name.localeCompare(b.name);
|
if (sortBy === "recent") return dir * (+new Date(b.purchaseDate) - +new Date(a.purchaseDate));
|
||||||
if (sortBy === "thc") return b.thc - a.thc;
|
if (sortBy === "name") return dir * a.name.localeCompare(b.name);
|
||||||
|
if (sortBy === "thc") return dir * (b.thc - a.thc);
|
||||||
if (sortBy === "remaining")
|
if (sortBy === "remaining")
|
||||||
return helpers.estimatedRemaining(b, getToday(getStoredTimezone())) - helpers.estimatedRemaining(a, getToday(getStoredTimezone()));
|
return dir * (helpers.estimatedRemaining(b, getToday(getStoredTimezone())) - helpers.estimatedRemaining(a, getToday(getStoredTimezone())));
|
||||||
if (sortBy === "price") return b.price - a.price;
|
if (sortBy === "price") return dir * (b.price - a.price);
|
||||||
if (sortBy === "audit")
|
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;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ export function Inventory({
|
|||||||
return out;
|
return out;
|
||||||
}, [items, data, filter, typeFilter, search]);
|
}, [items, data, filter, typeFilter, search]);
|
||||||
|
|
||||||
const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy]);
|
const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy, sortAsc]);
|
||||||
|
|
||||||
type Group = {
|
type Group = {
|
||||||
productId: string;
|
productId: string;
|
||||||
@@ -112,12 +114,11 @@ export function Inventory({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
out.sort((a, b) => {
|
out.sort((a, b) => {
|
||||||
const aMax = Math.max(...a.items.map((p) => +new Date(p.purchaseDate)));
|
if (a.items.length === 0 || b.items.length === 0) return 0;
|
||||||
const bMax = Math.max(...b.items.map((p) => +new Date(p.purchaseDate)));
|
return sortFn(a.items[0]!, b.items[0]!);
|
||||||
return bMax - aMax;
|
|
||||||
});
|
});
|
||||||
return out;
|
return out;
|
||||||
}, [filtered, sortBy]);
|
}, [filtered, sortBy, sortAsc]);
|
||||||
|
|
||||||
// All visible item IDs in display order (flat or grouped)
|
// All visible item IDs in display order (flat or grouped)
|
||||||
const visibleIds = useMemo(() => {
|
const visibleIds = useMemo(() => {
|
||||||
@@ -255,15 +256,23 @@ export function Inventory({
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={sortBy}
|
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" }}
|
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
|
||||||
>
|
>
|
||||||
<option value="recent">Recent first</option>
|
<option value="recent">Recent first</option>
|
||||||
<option value="name">Name (A–Z)</option>
|
<option value="name">Name (A–Z)</option>
|
||||||
<option value="thc">THC % (high)</option>
|
<option value="thc">THC %</option>
|
||||||
<option value="remaining">Remaining (high)</option>
|
<option value="remaining">Remaining</option>
|
||||||
<option value="price">Price (high)</option>
|
<option value="price">Price</option>
|
||||||
<option value="audit">Audit overdue first</option>
|
<option value="audit">Audit overdue</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -271,7 +280,15 @@ export function Inventory({
|
|||||||
<Card padded={false}>
|
<Card padded={false}>
|
||||||
<HeaderRow
|
<HeaderRow
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
onSort={setSortBy}
|
sortAsc={sortAsc}
|
||||||
|
onSort={(key) => {
|
||||||
|
if (key === sortBy) {
|
||||||
|
setSortAsc((prev) => !prev);
|
||||||
|
} else {
|
||||||
|
setSortBy(key);
|
||||||
|
setSortAsc(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
isAllSelected={isAllSelected}
|
isAllSelected={isAllSelected}
|
||||||
isIndeterminate={isIndeterminate}
|
isIndeterminate={isIndeterminate}
|
||||||
onToggleAll={toggleAll}
|
onToggleAll={toggleAll}
|
||||||
@@ -346,8 +363,20 @@ function Segmented<T extends string>({
|
|||||||
options: [T, string][];
|
options: [T, string][];
|
||||||
onChange: (v: T) => void;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="tablist"
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
background: "var(--bg-2)",
|
background: "var(--bg-2)",
|
||||||
@@ -356,10 +385,14 @@ function Segmented<T extends string>({
|
|||||||
padding: 3,
|
padding: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{options.map(([k, l]) => (
|
{options.map(([k, l], i) => (
|
||||||
<button
|
<button
|
||||||
key={k}
|
key={k}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={value === k}
|
||||||
|
tabIndex={value === k ? 0 : -1}
|
||||||
onClick={() => onChange(k)}
|
onClick={() => onChange(k)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, i)}
|
||||||
style={{
|
style={{
|
||||||
padding: "6px 14px",
|
padding: "6px 14px",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -384,12 +417,14 @@ const COL_LABELS = ["", "", "Item", "Brand", "Shop", "THC", "Price", "Remaining"
|
|||||||
|
|
||||||
function HeaderRow({
|
function HeaderRow({
|
||||||
sortBy,
|
sortBy,
|
||||||
|
sortAsc,
|
||||||
onSort,
|
onSort,
|
||||||
isAllSelected,
|
isAllSelected,
|
||||||
isIndeterminate,
|
isIndeterminate,
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
}: {
|
}: {
|
||||||
sortBy: SortKey;
|
sortBy: SortKey;
|
||||||
|
sortAsc: boolean;
|
||||||
onSort: (k: SortKey) => void;
|
onSort: (k: SortKey) => void;
|
||||||
isAllSelected: boolean;
|
isAllSelected: boolean;
|
||||||
isIndeterminate: boolean;
|
isIndeterminate: boolean;
|
||||||
@@ -447,7 +482,7 @@ function HeaderRow({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{active && <span style={{ fontSize: 9 }}>▼</span>}
|
{active && <span style={{ fontSize: 9 }}>{sortAsc ? "▲" : "▼"}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user