diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx
new file mode 100644
index 0000000..184739f
--- /dev/null
+++ b/src/components/Sidebar.test.tsx
@@ -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(
+
+
+
+
+ ,
+ );
+}
+
+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();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 7d4be7c..fbcc07d 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useReconciliation } from "@/hooks/useReconciliation";
+import { useAckStats } from "@/hooks/useAckStats";
import { useAuth } from "@/auth/useAuth";
const nav = [
@@ -43,6 +44,13 @@ export function Sidebar() {
(unmatched.data?.claims.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 (