feat(frontend): j/k row nav + ? cheatsheet on Acks and Remittances

This commit is contained in:
Tyler
2026-06-20 16:57:15 -06:00
parent b980e77cf2
commit 8f419cdeba
6 changed files with 1347 additions and 290 deletions
+362 -1
View File
@@ -51,6 +51,57 @@ async function waitForText(text: string, timeoutMs = 2000): Promise<void> {
}
}
/**
* Poll a predicate until it holds or we time out. react-query's fetch
* resolves asynchronously, and Radix portals content into
* `document.body`, so the DOM doesn't reflect new state synchronously
* after a keypress — we have to await a tick before asserting.
*/
async function settle(
predicate: () => boolean,
timeoutMs = 2000
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("settle: predicate did not hold within timeout");
}
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
}
/**
* Dispatch a synthetic keydown on `window`. Matches the dispatching
* pattern from `useDrawerKeyboard.test.ts` and `Claims.test.tsx` —
* `bubbles: true` so the event propagates like a real keypress, and
* `cancelable: true` so the hook's `preventDefault` has something to
* act on.
*/
function pressKey(key: string): void {
act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
);
});
}
/** The row at `idx` (per `data-row-index={idx}` on the row). */
function rowAt(idx: number): HTMLTableRowElement | null {
return document.querySelector(
`tbody tr[data-row-index="${idx}"]`
) as HTMLTableRowElement | null;
}
/** True iff exactly one row carries `data-state="selected"`. */
function hasExactlyOneSelectedRow(): boolean {
const selected = document.querySelectorAll(
'tbody tr[data-state="selected"]'
);
return selected.length === 1;
}
describe("Acks", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -87,4 +138,314 @@ describe("Acks", () => {
expect(document.body.textContent).toContain("b-uuid-1");
unmount();
});
});
it("test_initial_render_shows_no_selection_highlight", async () => {
// Three rows in the fixture so there's plenty to select; the test
// asserts the *initial* state has no row marked selected.
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 1,
sourceBatchId: "b-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
},
{
id: 2,
sourceBatchId: "b-2",
acceptedCount: 2,
rejectedCount: 0,
receivedCount: 2,
ackCode: "A",
parsedAt: "2026-06-20T12:01:00Z",
},
{
id: 3,
sourceBatchId: "b-3",
acceptedCount: 3,
rejectedCount: 0,
receivedCount: 3,
ackCode: "A",
parsedAt: "2026-06-20T12:02:00Z",
},
],
total: 3,
returned: 3,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-3");
// None of the three rows should be marked selected — the spec
// requires the initial render shows no selection highlight.
expect(
document.querySelectorAll('tbody tr[data-state="selected"]').length
).toBe(0);
unmount();
});
it("test_pressing_j_increments_selection", async () => {
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 1,
sourceBatchId: "b-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
},
{
id: 2,
sourceBatchId: "b-2",
acceptedCount: 2,
rejectedCount: 0,
receivedCount: 2,
ackCode: "A",
parsedAt: "2026-06-20T12:01:00Z",
},
{
id: 3,
sourceBatchId: "b-3",
acceptedCount: 3,
rejectedCount: 0,
receivedCount: 3,
ackCode: "A",
parsedAt: "2026-06-20T12:02:00Z",
},
],
total: 3,
returned: 3,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-3");
// From null, j lands on row 0.
pressKey("j");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
expect(rowAt(1)?.getAttribute("data-state")).not.toBe("selected");
expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected");
// j again → row 1.
pressKey("j");
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
expect(rowAt(0)?.getAttribute("data-state")).not.toBe("selected");
// j again → row 2.
pressKey("j");
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
// j from last row wraps to row 0 (per the wrap-or-clamp policy
// documented in Acks.tsx).
pressKey("j");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
unmount();
});
it("test_pressing_k_decrements_selection_with_wrap", async () => {
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 1,
sourceBatchId: "b-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
},
{
id: 2,
sourceBatchId: "b-2",
acceptedCount: 2,
rejectedCount: 0,
receivedCount: 2,
ackCode: "A",
parsedAt: "2026-06-20T12:01:00Z",
},
{
id: 3,
sourceBatchId: "b-3",
acceptedCount: 3,
rejectedCount: 0,
receivedCount: 3,
ackCode: "A",
parsedAt: "2026-06-20T12:02:00Z",
},
],
total: 3,
returned: 3,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-3");
// From null, k jumps to the last row — a one-keystroke "bottom
// of list" gesture.
pressKey("k");
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
// k again → row 1.
pressKey("k");
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected");
// k twice more lands on row 0.
pressKey("k");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
// k from row 0 wraps to row 2 (last).
pressKey("k");
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
unmount();
});
it("test_question_mark_toggles_cheatsheet_and_escape_closes", async () => {
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 1,
sourceBatchId: "b-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
},
],
total: 1,
returned: 1,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-1");
// No cheatsheet initially.
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).toBeNull();
// `?` opens it (Radix portals into document.body — query there).
pressKey("?");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).not.toBeNull();
// Escape closes it. The page wires onClose via useRowKeyboard
// (Escape → onClose → setHelpOpen(false)).
pressKey("Escape");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).toBeNull();
// `?` again re-opens (proving the toggle works in both directions).
pressKey("?");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).not.toBeNull();
unmount();
});
it("test_j_does_not_navigate_while_cheatsheet_is_open", async () => {
// `enabled: !helpOpen` is the recommended pairing with
// useRowKeyboard: j/k nav yields to the cheatsheet while it's
// open. The cheatsheet's own dismiss listener still closes it on
// any non-`?` keypress (per KeyboardCheatsheet.tsx), so pressing
// j will close the cheatsheet but the row should NOT have moved.
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 1,
sourceBatchId: "b-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
},
{
id: 2,
sourceBatchId: "b-2",
acceptedCount: 2,
rejectedCount: 0,
receivedCount: 2,
ackCode: "A",
parsedAt: "2026-06-20T12:01:00Z",
},
],
total: 2,
returned: 2,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-2");
// Move selection to row 0 first (so we can later verify it didn't
// jump to row 1 while the cheatsheet is open).
pressKey("j");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
// Open cheatsheet.
pressKey("?");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
null
);
// Press j while cheatsheet is open. Cheatsheet closes (its own
// listener handles any non-? key), but selection should NOT have
// moved — row 0 is still selected.
pressKey("j");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).toBeNull();
// Row 0 is still the selected row.
expect(hasExactlyOneSelectedRow()).toBe(true);
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
unmount();
});
});
+158 -89
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { CheckCircle2, Download } from "lucide-react";
import {
Table,
@@ -11,7 +11,9 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
import { useAcks } from "@/hooks/useAcks";
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { api } from "@/lib/api";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
@@ -107,98 +109,165 @@ export function Acks() {
const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
const items = data?.items ?? [];
// Row navigation state (SP4-style j/k). `selectedIndex === null`
// means "no row focused yet" — the initial render shows no
// highlight, and the first j press lands on row 0 (and the first k
// press lands on the last row, so the user can quickly jump to
// either end of the list).
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const moveNext = useCallback(() => {
setSelectedIndex((i) => {
if (items.length === 0) return null;
if (i === null) return 0;
return (i + 1) % items.length;
});
}, [items.length]);
const movePrev = useCallback(() => {
setSelectedIndex((i) => {
if (items.length === 0) return null;
if (i === null) return items.length - 1;
// Add items.length before the modulo so the negative index
// (first row → wrap to last) wraps correctly.
return (i - 1 + items.length) % items.length;
});
}, [items.length]);
// `enabled: !helpOpen` so j/k navigation yields to the cheatsheet
// while it's open. The cheatsheet's own dismiss listener still fires
// for any non-`?` keypress (including j/k), so pressing j will close
// the cheatsheet but won't move the row — exactly the behavior the
// user expects: "the cheatsheet is in the way, get it out of my
// way; I'll start navigating on the next keypress".
useRowKeyboard({
enabled: !helpOpen && items.length > 0,
onNext: moveNext,
onPrev: movePrev,
onClose: () => setHelpOpen(false),
onToggleHelp: () => setHelpOpen((v) => !v),
});
return (
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
999 ACKs
</div>
<h1 className="text-[22px] font-semibold tracking-tight">
999 Implementation Acknowledgments
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
999 ACK transaction sets generated automatically in response to
837P ingests, or parsed from inbound 999 uploads.
</p>
</header>
{isError ? (
<ErrorState
message="Couldn't load ACKs from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : null}
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
<>
<KeyboardCheatsheet
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
999 ACKs
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="999 ACKs · awaiting first 837 with ack=true"
message="Upload an 837P with ?ack=true, or parse a 999 file directly to populate this list."
<h1 className="text-[22px] font-semibold tracking-tight">
999 Implementation Acknowledgments
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
999 ACK transaction sets generated automatically in response to
837P ingests, or parsed from inbound 999 uploads.
</p>
</header>
{isError ? (
<ErrorState
message="Couldn't load ACKs from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" aria-label="Status" />
<TableHead>ID</TableHead>
<TableHead>Source Batch</TableHead>
<TableHead className="text-right">Accepted</TableHead>
<TableHead className="text-right">Rejected</TableHead>
<TableHead className="text-right">Received</TableHead>
<TableHead>ACK Code</TableHead>
<TableHead>Parsed</TableHead>
<TableHead className="w-20" aria-label="Download" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((a) => (
<TableRow key={a.id}>
<TableCell className="text-muted-foreground">
<CheckCircle2
className={cn(
"h-3.5 w-3.5",
a.ackCode === "A" ? "text-emerald-400" : a.ackCode === "R" ? "text-red-400" : "text-amber-400",
)}
strokeWidth={1.75}
aria-hidden
/>
</TableCell>
<TableCell className="display num text-[13px]">{a.id}</TableCell>
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[180px]">
{a.sourceBatchId}
</TableCell>
<TableCell className="text-right display num text-emerald-400">
{a.acceptedCount}
</TableCell>
<TableCell className="text-right display num text-red-400">
{a.rejectedCount}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{a.receivedCount}
</TableCell>
<TableCell>
<AckCodeBadge code={a.ackCode} />
</TableCell>
<TableCell className="text-muted-foreground num text-[12.5px]">
{a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"}
</TableCell>
<TableCell>
<DownloadButton id={a.id} sourceBatchId={a.sourceBatchId} />
</TableCell>
</TableRow>
) : null}
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</TableBody>
</Table>
)}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="999 ACKs · awaiting first 837 with ack=true"
message="Upload an 837P with ?ack=true, or parse a 999 file directly to populate this list."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" aria-label="Status" />
<TableHead>ID</TableHead>
<TableHead>Source Batch</TableHead>
<TableHead className="text-right">Accepted</TableHead>
<TableHead className="text-right">Rejected</TableHead>
<TableHead className="text-right">Received</TableHead>
<TableHead>ACK Code</TableHead>
<TableHead>Parsed</TableHead>
<TableHead className="w-20" aria-label="Download" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((a, idx) => {
const isSelected = selectedIndex === idx;
return (
<TableRow
key={a.id}
data-row-index={idx}
data-state={isSelected ? "selected" : undefined}
aria-selected={isSelected}
className={cn(
isSelected && [
// Accent-tinted bg + ring + left strip make the
// selected row clearly stand out from the
// default `hover:bg-muted/30` and the
// `data-[state=selected]:bg-muted` that the
// TableRow primitive applies. Tailwind's
// `accent` token is the same accent used
// elsewhere in the app (nav-active indicator,
// row-flash keyframe, focus ring).
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
],
)}
>
<TableCell className="text-muted-foreground">
<CheckCircle2
className={cn(
"h-3.5 w-3.5",
a.ackCode === "A" ? "text-emerald-400" : a.ackCode === "R" ? "text-red-400" : "text-amber-400",
)}
strokeWidth={1.75}
aria-hidden
/>
</TableCell>
<TableCell className="display num text-[13px]">{a.id}</TableCell>
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[180px]">
{a.sourceBatchId}
</TableCell>
<TableCell className="text-right display num text-emerald-400">
{a.acceptedCount}
</TableCell>
<TableCell className="text-right display num text-red-400">
{a.rejectedCount}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{a.receivedCount}
</TableCell>
<TableCell>
<AckCodeBadge code={a.ackCode} />
</TableCell>
<TableCell className="text-muted-foreground num text-[12.5px]">
{a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"}
</TableCell>
<TableCell>
<DownloadButton id={a.id} sourceBatchId={a.sourceBatchId} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
</div>
</>
);
}
+276 -33
View File
@@ -75,48 +75,120 @@ async function waitForText(
}
}
/**
* Poll a predicate until it holds or we time out. react-query's fetch
* resolves asynchronously, and Radix portals content into
* `document.body`, so the DOM doesn't reflect new state synchronously
* after a keypress — we have to await a tick before asserting.
*/
async function settle(
predicate: () => boolean,
timeoutMs = 2000
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("settle: predicate did not hold within timeout");
}
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
}
/**
* Dispatch a synthetic keydown on `window`. Same pattern used in
* `Claims.test.tsx` and `useDrawerKeyboard.test.ts`.
*/
function pressKey(key: string): void {
act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
);
});
}
/** The data row at `idx` (per `data-row-index={idx}`). */
function rowAt(idx: number): HTMLTableRowElement | null {
return document.querySelector(
`tbody tr[data-row-index="${idx}"]`
) as HTMLTableRowElement | null;
}
/** True iff exactly one row carries `data-state="selected"`. */
function hasExactlyOneSelectedRow(): boolean {
const selected = document.querySelectorAll(
'tbody tr[data-state="selected"]'
);
return selected.length === 1;
}
/** Fixture used by every test below so the page has rows to navigate. */
const SAMPLE_REMITS = [
{
id: "PCN-1",
claimId: "CLM-1",
payerName: "Colorado Medicaid",
paidAmount: 100,
adjustmentAmount: 60,
receivedDate: "2026-06-19T12:00:00Z",
checkNumber: "",
status: "received",
adjustments: [
{
group: "CO",
reason: "45",
label: "Charge exceeds fee schedule/maximum allowable",
amount: 50,
quantity: null,
},
{
group: "PR",
reason: "1",
label: "Deductible amount",
amount: 10,
quantity: 1,
},
],
},
{
id: "PCN-2",
claimId: "CLM-2",
payerName: "Aetna",
paidAmount: 200,
adjustmentAmount: 0,
receivedDate: "2026-06-18T12:00:00Z",
checkNumber: "",
status: "received",
adjustments: [],
},
{
id: "PCN-3",
claimId: "CLM-3",
payerName: "United",
paidAmount: 300,
adjustmentAmount: 0,
receivedDate: "2026-06-17T12:00:00Z",
checkNumber: "",
status: "received",
adjustments: [],
},
] as const;
describe("Remittances", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders a CAS adjustment label inside the expanded detail row", async () => {
(
api.listRemittances as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
items: [
{
id: "PCN-1",
claimId: "CLM-1",
payerName: "Colorado Medicaid",
paidAmount: 100,
adjustmentAmount: 60,
receivedDate: "2026-06-19T12:00:00Z",
checkNumber: "",
status: "received",
adjustments: [
{
group: "CO",
reason: "45",
label: "Charge exceeds fee schedule/maximum allowable",
amount: 50,
quantity: null,
},
{
group: "PR",
reason: "1",
label: "Deductible amount",
amount: 10,
quantity: 1,
},
],
},
],
total: 1,
returned: 1,
items: SAMPLE_REMITS,
total: SAMPLE_REMITS.length,
returned: SAMPLE_REMITS.length,
has_more: false,
});
});
it("renders a CAS adjustment label inside the expanded detail row", async () => {
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await waitForText("PCN-1");
@@ -146,4 +218,175 @@ describe("Remittances", () => {
expect(document.body.textContent).toContain("PR-1");
unmount();
});
it("test_initial_render_shows_no_selection_highlight", async () => {
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await waitForText("PCN-3");
// The spec requires the initial render shows no selection
// highlight — none of the three data rows should be marked
// selected.
expect(
document.querySelectorAll('tbody tr[data-state="selected"]').length
).toBe(0);
unmount();
});
it("test_pressing_j_increments_selection_with_wrap", async () => {
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await waitForText("PCN-3");
// From null, j lands on row 0.
pressKey("j");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
// j again → row 1, then row 2.
pressKey("j");
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
pressKey("j");
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
// j from the last row wraps to row 0 (wrap policy documented in
// Remittances.tsx — matches Claims' drawer behavior).
pressKey("j");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
expect(hasExactlyOneSelectedRow()).toBe(true);
unmount();
});
it("test_pressing_k_decrements_selection_with_wrap", async () => {
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await waitForText("PCN-3");
// From null, k jumps to the last row (one-keystroke "bottom of
// list" gesture).
pressKey("k");
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
// k → row 1, then row 0.
pressKey("k");
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
pressKey("k");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
// k from row 0 wraps to row 2 (last).
pressKey("k");
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
unmount();
});
it("test_j_does_not_trigger_expand_collapse", async () => {
// j/k selection is a focus indicator, NOT a substitute for the
// click-to-expand gesture. Pressing j through a row that has
// adjustments must NOT open the detail row.
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await waitForText("PCN-3");
// Row 0 has adjustments — moving selection onto it should leave
// the row collapsed (the user can still click to expand).
pressKey("j");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
expect(document.body.textContent).not.toContain("Adjustments (2)");
pressKey("j");
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
expect(document.body.textContent).not.toContain("Adjustments (2)");
unmount();
});
it("test_question_mark_toggles_cheatsheet_and_escape_closes", async () => {
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await waitForText("PCN-3");
// No cheatsheet initially.
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).toBeNull();
// `?` opens it.
pressKey("?");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).not.toBeNull();
// Escape closes it.
pressKey("Escape");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).toBeNull();
// `?` again re-opens (toggle works in both directions).
pressKey("?");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).not.toBeNull();
unmount();
});
it("test_j_does_not_navigate_while_cheatsheet_is_open", async () => {
// `enabled: !helpOpen` is the recommended pairing with
// useRowKeyboard: j/k nav yields to the cheatsheet while it's
// open. Pressing j while the cheatsheet is open closes the
// cheatsheet (its own dismiss listener handles any non-`?`
// keypress) but the row should NOT have moved.
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await waitForText("PCN-3");
// Move selection to row 0 first.
pressKey("j");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
// Open cheatsheet.
pressKey("?");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
null
);
// j while cheatsheet open: closes cheatsheet, row stays at 0.
pressKey("j");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).toBeNull();
expect(hasExactlyOneSelectedRow()).toBe(true);
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
unmount();
});
});
+224 -167
View File
@@ -1,4 +1,4 @@
import { Fragment, useState } from "react";
import { Fragment, useCallback, useState } from "react";
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
import {
Table,
@@ -14,7 +14,9 @@ import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips";
import { Pagination } from "@/components/ui/pagination";
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
import { useRemittances } from "@/hooks/useRemittances";
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { CasAdjustment, RemittanceStatus } from "@/types";
@@ -57,6 +59,11 @@ export function Remittances() {
const [page, setPage] = useState(1);
const [status, setStatus] = useState<RemittanceStatus | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
// Row navigation (j/k) lives in parallel with `expanded`; selection
// is purely a focus-state indicator and does NOT auto-expand the
// row (so j/k doesn't fight the existing click-to-expand gesture).
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
sort: "receivedDate",
@@ -83,181 +90,231 @@ export function Remittances() {
});
};
const moveNext = useCallback(() => {
setSelectedIndex((i) => {
if (items.length === 0) return null;
if (i === null) return 0;
return (i + 1) % items.length;
});
}, [items.length]);
const movePrev = useCallback(() => {
setSelectedIndex((i) => {
if (items.length === 0) return null;
if (i === null) return items.length - 1;
// Add items.length before the modulo so the negative index
// (first row → wrap to last) wraps correctly.
return (i - 1 + items.length) % items.length;
});
}, [items.length]);
// `enabled: !helpOpen` so j/k yields to the cheatsheet while it's
// open (the cheatsheet's own dismiss listener still closes it on
// any non-`?` keypress). See Acks.tsx for the full rationale.
useRowKeyboard({
enabled: !helpOpen && items.length > 0,
onNext: moveNext,
onPrev: movePrev,
onClose: () => setHelpOpen(false),
onToggleHelp: () => setHelpOpen((v) => !v),
});
return (
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Remittances
</div>
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Electronic remittance advice from payers, matched to submitted claims.
</p>
</header>
{isError ? (
<ErrorState
message="Couldn't load remittances from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : null}
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Remits
<>
<KeyboardCheatsheet
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Remittances
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.num(data?.total ?? 0)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Total paid
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Adjustments
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
</div>
</div>
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Electronic remittance advice from payers, matched to submitted claims.
</p>
</header>
<div className="surface rounded-xl p-4">
<FilterChips
options={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus((v as RemittanceStatus | null) ?? null);
setPage(1);
}}
eyebrow="Status"
/>
</div>
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Remittances · awaiting first 835"
message="Drop an 835 file on the Upload page to populate this list."
{isError ? (
<ErrorState
message="Couldn't load remittances from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" aria-label="Expand" />
<TableHead>Remit</TableHead>
<TableHead>Claim</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Adjustment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Received</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((r) => {
const isOpen = expanded.has(r.id);
const hasAdjustments =
!!r.adjustments && r.adjustments.length > 0;
return (
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
<TableRow
className={cn("animate-row-flash")}
onClick={() =>
hasAdjustments ? toggleExpand(r.id) : undefined
}
aria-expanded={hasAdjustments ? isOpen : undefined}
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
>
<TableCell className="text-muted-foreground">
{hasAdjustments ? (
isOpen ? (
<ChevronDown
className="h-3.5 w-3.5"
strokeWidth={1.75}
aria-hidden
/>
) : (
<ChevronRight
className="h-3.5 w-3.5"
strokeWidth={1.75}
aria-hidden
/>
)
) : null}
</TableCell>
<TableCell className="display num text-[13px]">{r.id}</TableCell>
<TableCell className="display num text-[13px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display num">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground num text-[13px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
{isOpen && hasAdjustments ? (
) : null}
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Remits
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.num(data?.total ?? 0)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Total paid
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Adjustments
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
</div>
</div>
<div className="surface rounded-xl p-4">
<FilterChips
options={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus((v as RemittanceStatus | null) ?? null);
setPage(1);
}}
eyebrow="Status"
/>
</div>
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Remittances · awaiting first 835"
message="Drop an 835 file on the Upload page to populate this list."
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" aria-label="Expand" />
<TableHead>Remit</TableHead>
<TableHead>Claim</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Adjustment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Received</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((r, idx) => {
const isOpen = expanded.has(r.id);
const hasAdjustments =
!!r.adjustments && r.adjustments.length > 0;
const isSelected = selectedIndex === idx;
return (
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
<TableRow
key={`${r.id}-${dataUpdatedAt}-detail`}
className="bg-muted/30 hover:bg-muted/30"
data-row-index={idx}
data-state={isSelected ? "selected" : undefined}
aria-selected={isSelected}
className={cn(
"animate-row-flash",
isSelected && [
// Same accent-tinted highlight as Acks:
// background tint + inset ring + left
// accent strip, layered on top of the
// TableRow primitive's
// `data-[state=selected]:bg-muted` to
// win visually.
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
],
)}
onClick={() =>
hasAdjustments ? toggleExpand(r.id) : undefined
}
aria-expanded={hasAdjustments ? isOpen : undefined}
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
>
<TableCell />
<TableCell colSpan={7} className="py-3">
<div className="flex items-center gap-2 mb-2">
<Receipt
className="h-3.5 w-3.5 text-muted-foreground"
strokeWidth={1.5}
aria-hidden
/>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Adjustments ({r.adjustments!.length})
</div>
</div>
<div className="pl-5">
{r.adjustments!.map((adj, i) => (
<AdjustmentRow
key={`${adj.group}-${adj.reason}-${i}`}
adj={adj}
<TableCell className="text-muted-foreground">
{hasAdjustments ? (
isOpen ? (
<ChevronDown
className="h-3.5 w-3.5"
strokeWidth={1.75}
aria-hidden
/>
))}
</div>
) : (
<ChevronRight
className="h-3.5 w-3.5"
strokeWidth={1.75}
aria-hidden
/>
)
) : null}
</TableCell>
<TableCell className="display num text-[13px]">{r.id}</TableCell>
<TableCell className="display num text-[13px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display num">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground num text-[13px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
) : null}
</Fragment>
);
})}
</TableBody>
</Table>
<div className="px-4 pb-4">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onPageChange={setPage}
/>
</div>
</>
)}
{isOpen && hasAdjustments ? (
<TableRow
key={`${r.id}-${dataUpdatedAt}-detail`}
className="bg-muted/30 hover:bg-muted/30"
>
<TableCell />
<TableCell colSpan={7} className="py-3">
<div className="flex items-center gap-2 mb-2">
<Receipt
className="h-3.5 w-3.5 text-muted-foreground"
strokeWidth={1.5}
aria-hidden
/>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Adjustments ({r.adjustments!.length})
</div>
</div>
<div className="pl-5">
{r.adjustments!.map((adj, i) => (
<AdjustmentRow
key={`${adj.group}-${adj.reason}-${i}`}
adj={adj}
/>
))}
</div>
</TableCell>
</TableRow>
) : null}
</Fragment>
);
})}
</TableBody>
</Table>
<div className="px-4 pb-4">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onPageChange={setPage}
/>
</div>
</>
)}
</div>
</div>
</div>
</>
);
}