feat(acks): surface 999 rejections via sidebar badge + table sort
The 1,156 accepted acks were burying the 5 rejections — the operator had to scroll the whole table to find them. Two changes: 1. Sidebar '999 ACKs' nav item gets a warning-toned badge showing the total rejected segment count from aggregates.rejected_count (mirrors the Reconciliation unmatched badge pattern). One number, visible from any page. 2. Acks page table now sorts rejected rows to the top, then newest-id-first. All 5 rejections fit on page 1 since they're a tiny fraction of the total — no pagination needed. New files: - src/hooks/useAckStats.ts (lightweight aggregates-only fetch) - src/hooks/useAckStats.test.tsx (2 tests) - src/components/Sidebar.test.tsx (3 tests) Pre-existing baseline: 10 frontend failures (api.test.ts exportBatch837, tail-stream.test.ts acks/ta1_acks targeting, Inbox/InboxHeader copy). Unchanged.
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { render, waitFor, cleanup } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Stub the auth hook so Sidebar doesn't try to read a token.
|
||||||
|
vi.mock("@/auth/useAuth", () => ({
|
||||||
|
useAuth: () => ({ user: { id: 1, username: "admin", role: "admin" }, logout: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mutable state so each test can flip the rejected-acks count without
|
||||||
|
// re-importing the module. `vi.hoisted` ensures the mock factory closes
|
||||||
|
// over the same `state` object across calls.
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
unmatchedClaims: 0,
|
||||||
|
unmatchedRemits: 0,
|
||||||
|
rejectedAcks: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useReconciliation", () => ({
|
||||||
|
useReconciliation: () => ({
|
||||||
|
unmatched: {
|
||||||
|
data: {
|
||||||
|
claims: Array.from({ length: state.unmatchedClaims }),
|
||||||
|
remittances: Array.from({ length: state.unmatchedRemits }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useAckStats", () => ({
|
||||||
|
useAckStats: () => ({
|
||||||
|
data: state.rejectedAcks > 0
|
||||||
|
? { aggregates: { accepted: 1000, rejected: state.rejectedAcks, received: 1005 } }
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
|
function renderSidebar() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<Sidebar />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Sidebar — 999 ACKs rejected badge", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
state.unmatchedClaims = 0;
|
||||||
|
state.unmatchedRemits = 0;
|
||||||
|
state.rejectedAcks = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a '5' badge when 5 segments are rejected", async () => {
|
||||||
|
state.rejectedAcks = 5;
|
||||||
|
const { container } = renderSidebar();
|
||||||
|
await waitFor(() => {
|
||||||
|
const badge = container.querySelector(
|
||||||
|
'[data-testid="sidebar-acks-rejected-badge"]',
|
||||||
|
);
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge?.textContent).toBe("5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows '99+' when rejected segments exceed 99", async () => {
|
||||||
|
state.rejectedAcks = 250;
|
||||||
|
const { container } = renderSidebar();
|
||||||
|
await waitFor(() => {
|
||||||
|
const badge = container.querySelector(
|
||||||
|
'[data-testid="sidebar-acks-rejected-badge"]',
|
||||||
|
);
|
||||||
|
expect(badge?.textContent).toBe("99+");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the badge when there are no rejected acks", async () => {
|
||||||
|
state.rejectedAcks = 0;
|
||||||
|
const { container } = renderSidebar();
|
||||||
|
// Allow the synchronous mock to settle.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="sidebar-acks-rejected-badge"]'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useReconciliation } from "@/hooks/useReconciliation";
|
import { useReconciliation } from "@/hooks/useReconciliation";
|
||||||
|
import { useAckStats } from "@/hooks/useAckStats";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
@@ -43,6 +44,13 @@ export function Sidebar() {
|
|||||||
(unmatched.data?.claims.length ?? 0) +
|
(unmatched.data?.claims.length ?? 0) +
|
||||||
(unmatched.data?.remittances.length ?? 0);
|
(unmatched.data?.remittances.length ?? 0);
|
||||||
|
|
||||||
|
// 999 ACKs — total rejected segments across all ack files. Drives
|
||||||
|
// the badge on the "999 ACKs" nav item so the operator can see
|
||||||
|
// there are rejections to review from any page (mirrors the
|
||||||
|
// Reconciliation unmatched badge above).
|
||||||
|
const { data: ackStats } = useAckStats();
|
||||||
|
const rejectedAckCount = ackStats?.aggregates.rejected ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 z-30 border-r border-border/60 bg-sidebar/80 backdrop-blur-xl"
|
className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 z-30 border-r border-border/60 bg-sidebar/80 backdrop-blur-xl"
|
||||||
@@ -152,6 +160,20 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||||
<span>999 ACKs</span>
|
<span>999 ACKs</span>
|
||||||
|
{rejectedAckCount > 0 ? (
|
||||||
|
// Rejection-count badge — same right-aligned mono shape
|
||||||
|
// as the Reconciliation unmatched badge above so the
|
||||||
|
// operator's eye finds both at the same scan position.
|
||||||
|
// Warning tone (not signal/destructive) so it doesn't
|
||||||
|
// compete with the more urgent Payer-Rejected lane.
|
||||||
|
<span
|
||||||
|
className="ml-auto mono text-[10px] text-[hsl(var(--warning))]"
|
||||||
|
data-testid="sidebar-acks-rejected-badge"
|
||||||
|
title={`${rejectedAckCount} rejected 999 segment${rejectedAckCount === 1 ? "" : "s"} across all ack files`}
|
||||||
|
>
|
||||||
|
{rejectedAckCount > 99 ? "99+" : rejectedAckCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Mock the API module so the hook doesn't touch the real /api/acks endpoint.
|
||||||
|
// The mock mirrors the wire shape that listAcks returns.
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
isConfigured: true,
|
||||||
|
listAcks: vi.fn().mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 1161,
|
||||||
|
returned: 0,
|
||||||
|
has_more: false,
|
||||||
|
aggregates: { accepted: 1156, rejected: 5, received: 1161 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useAckStats } from "./useAckStats";
|
||||||
|
|
||||||
|
function withClient(): (props: { children: ReactNode }) => ReactNode {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return ({ children }) => (
|
||||||
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useAckStats", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns server-computed aggregates for sidebar badge use", async () => {
|
||||||
|
const { result } = renderHook(() => useAckStats(), { wrapper: withClient() });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(result.current.data?.aggregates.rejected).toBe(5);
|
||||||
|
expect(result.current.data?.aggregates.accepted).toBe(1156);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls listAcks with limit=1 so the lightweight payload is smallest", async () => {
|
||||||
|
const { api } = await import("@/lib/api");
|
||||||
|
renderHook(() => useAckStats(), { wrapper: withClient() });
|
||||||
|
await waitFor(() => expect(api.listAcks).toHaveBeenCalled());
|
||||||
|
expect(api.listAcks).toHaveBeenCalledWith({ limit: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api, type AckAggregates } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight 999 ACK aggregates for sidebar / nav badges.
|
||||||
|
*
|
||||||
|
* Hits `/api/acks?limit=1` so the server-side aggregates (which are
|
||||||
|
* computed by iterating every persisted row) come back without
|
||||||
|
* pulling 100 rows for a sidebar badge. The 1-row payload is the
|
||||||
|
* smallest the endpoint accepts (limit ge=1).
|
||||||
|
*
|
||||||
|
* Used by the Sidebar's "999 ACKs" nav badge to surface the
|
||||||
|
* total rejected segment count without forcing a full page fetch.
|
||||||
|
* 60s stale time matches the page-level fetch cadence — the badge
|
||||||
|
* refreshes when the user navigates to the page anyway.
|
||||||
|
*
|
||||||
|
* Distinct from useAcks (which fetches paginated rows + aggregates
|
||||||
|
* for the page body). The query key intentionally differs so React
|
||||||
|
* Query doesn't dedupe the two into a single entry.
|
||||||
|
*/
|
||||||
|
export function useAckStats() {
|
||||||
|
return useQuery<{ aggregates: AckAggregates }>({
|
||||||
|
queryKey: ["acks", "stats"],
|
||||||
|
queryFn: () => api.listAcks({ limit: 1 }) as Promise<{ aggregates: AckAggregates }>,
|
||||||
|
enabled: api.isConfigured,
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -166,6 +166,69 @@ describe("Acks", () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 2026-07-02: rejections were getting buried inside the accepted acks
|
||||||
|
// (1,156 accepted + 5 rejected) — the operator had to scroll the
|
||||||
|
// entire table to find the 5 they cared about. Fix: client-side sort
|
||||||
|
// bubbles rejected rows to the top of page 1. Within each group
|
||||||
|
// (rejected vs accepted) newest-id-first is preserved.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it("test_rejected_acks_float_to_top_of_table", async () => {
|
||||||
|
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 100,
|
||||||
|
sourceBatchId: "b-newest-accepted",
|
||||||
|
acceptedCount: 1,
|
||||||
|
rejectedCount: 0,
|
||||||
|
receivedCount: 1,
|
||||||
|
ackCode: "A",
|
||||||
|
parsedAt: "2026-07-01T12:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 99,
|
||||||
|
sourceBatchId: "b-rejected",
|
||||||
|
acceptedCount: 0,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 1,
|
||||||
|
ackCode: "R",
|
||||||
|
parsedAt: "2026-07-01T11:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 98,
|
||||||
|
sourceBatchId: "b-old-accepted",
|
||||||
|
acceptedCount: 1,
|
||||||
|
rejectedCount: 0,
|
||||||
|
receivedCount: 1,
|
||||||
|
ackCode: "A",
|
||||||
|
parsedAt: "2026-06-25T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
returned: 3,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||||
|
await waitForText("100");
|
||||||
|
|
||||||
|
// The first table row must be the rejected one (id=99), even though
|
||||||
|
// id=100 has a higher id. After that, accepted rows sort newest-first
|
||||||
|
// (id=100 before id=98).
|
||||||
|
const tableRows = Array.from(
|
||||||
|
document.querySelectorAll("table tbody tr"),
|
||||||
|
).filter((row) => {
|
||||||
|
// Exclude any TA1-section rows by requiring the sourceBatchId
|
||||||
|
// pattern (TA1 rows have a different layout).
|
||||||
|
return row.textContent?.includes("b-");
|
||||||
|
});
|
||||||
|
expect(tableRows.length).toBe(3);
|
||||||
|
expect(tableRows[0]?.textContent).toContain("b-rejected");
|
||||||
|
expect(tableRows[1]?.textContent).toContain("b-newest-accepted");
|
||||||
|
expect(tableRows[2]?.textContent).toContain("b-old-accepted");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it("test_initial_render_shows_no_selection_highlight", async () => {
|
it("test_initial_render_shows_no_selection_highlight", async () => {
|
||||||
// Three rows in the fixture so there's plenty to select; the test
|
// Three rows in the fixture so there's plenty to select; the test
|
||||||
// asserts the *initial* state has no row marked selected.
|
// asserts the *initial* state has no row marked selected.
|
||||||
|
|||||||
+16
-2
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { CheckCircle2, Download, Mail, ShieldCheck } from "lucide-react";
|
import { CheckCircle2, Download, Mail, ShieldCheck } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -63,7 +63,21 @@ export function Acks() {
|
|||||||
// dedupes by id (first write wins), so a snapshot replay on
|
// dedupes by id (first write wins), so a snapshot replay on
|
||||||
// reconnect can't double-count an existing row.
|
// reconnect can't double-count an existing row.
|
||||||
const mergedItems = useMergedTail("acks", data?.items ?? []);
|
const mergedItems = useMergedTail("acks", data?.items ?? []);
|
||||||
const items = mergedItems;
|
// Display order: rejected rows float to the top, then newest-first
|
||||||
|
// by id. The server returns id-DESC so accepted rows already
|
||||||
|
// follow that order — we only need to bubble the rejections up
|
||||||
|
// (the operator flagged on 2026-07-02 that 5 rejections were
|
||||||
|
// buried inside 1,156 accepted acks and hard to find). All 5 fit
|
||||||
|
// on page 1 since they're a tiny fraction of the total — the
|
||||||
|
// operator never needs to paginate to find them.
|
||||||
|
const items = useMemo(() => {
|
||||||
|
return [...mergedItems].sort((a, b) => {
|
||||||
|
const ra = a.rejectedCount > 0 ? 1 : 0;
|
||||||
|
const rb = b.rejectedCount > 0 ? 1 : 0;
|
||||||
|
if (ra !== rb) return rb - ra; // rejected first
|
||||||
|
return b.id - a.id; // newest id first within each group
|
||||||
|
});
|
||||||
|
}, [mergedItems]);
|
||||||
const totalCount = data?.total ?? 0;
|
const totalCount = data?.total ?? 0;
|
||||||
// Server-side aggregates — sum over the *full* ACK set, not just the
|
// Server-side aggregates — sum over the *full* ACK set, not just the
|
||||||
// visible page. Without this, the KPI strip silently under-reports
|
// visible page. Without this, the KPI strip silently under-reports
|
||||||
|
|||||||
Reference in New Issue
Block a user