diff --git a/src/components/ClaimDrawer/StateHistoryTimeline.test.tsx b/src/components/ClaimDrawer/StateHistoryTimeline.test.tsx new file mode 100644 index 0000000..4d24712 --- /dev/null +++ b/src/components/ClaimDrawer/StateHistoryTimeline.test.tsx @@ -0,0 +1,276 @@ +// @vitest-environment happy-dom +// Pure prop-driven component; match the act() convention used by the +// other ClaimDrawer test files (ClaimDrawerHeader / ClaimDrawerSkeleton / +// ClaimDrawerError / ValidationPanel / ServiceLinesTable / DiagnosesList +// / PartiesGrid / RawSegmentsPanel / MatchedRemitCard). +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it } from "vitest"; +import { StateHistoryTimeline } from "./StateHistoryTimeline"; +import type { ClaimDetailStateHistoryEvent } from "@/types"; + +function renderIntoContainer(element: React.ReactElement): { + container: HTMLDivElement; + unmount: () => void; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render(element); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function makeEvent( + overrides: Partial = {} +): ClaimDetailStateHistoryEvent { + return { + kind: "claim_submitted", + ts: "2026-06-12T10:30:00Z", + batchId: null, + remittanceId: null, + ...overrides, + }; +} + +describe("StateHistoryTimeline", () => { + it("test_section_label_includes_count", () => { + const events = [ + makeEvent({ kind: "claim_submitted" }), + makeEvent({ kind: "claim_accepted" }), + makeEvent({ kind: "claim_paid" }), + ]; + + const { container, unmount } = renderIntoContainer( + + ); + + const label = container.querySelector('[data-testid="section-label"]'); + expect(label).not.toBeNull(); + + // `uppercase` is applied via Tailwind CSS, so textContent gives the + // source-case string "State History"; assert case-insensitively and + // confirm the visual transform via the className sniff below. + const text = (label?.textContent ?? "").toLowerCase(); + expect(text).toContain("state history"); + expect(text).toContain("(3)"); + + // Eyebrow style (uppercase + tracking) — sniff the className. + const cls = label?.className ?? ""; + expect(cls).toContain("uppercase"); + expect(cls).toContain("tracking-[0.18em]"); + + unmount(); + }); + + it("test_empty_array_renders_empty_state", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const empty = container.querySelector('[data-testid="state-history-empty"]'); + expect(empty).not.toBeNull(); + expect(empty?.textContent ?? "").toContain("No history events"); + + // No timeline rendered in the empty state. + expect( + container.querySelector('[data-testid="state-history-timeline"]') + ).toBeNull(); + expect( + container.querySelectorAll('[data-testid="state-history-item"]').length + ).toBe(0); + + // Section label still present, but with count "(0)". + const label = container.querySelector('[data-testid="section-label"]'); + expect(label?.textContent ?? "").toContain("(0)"); + + unmount(); + }); + + it("test_single_event_renders_one_row_with_kind_and_ts", () => { + const events = [ + makeEvent({ kind: "claim_paid", ts: "2026-06-12T10:30:00Z" }), + ]; + + const { container, unmount } = renderIntoContainer( + + ); + + const items = container.querySelectorAll('[data-testid="state-history-item"]'); + expect(items.length).toBe(1); + + const text = items[0].textContent ?? ""; + // Kind rendered (underscore replaced with space, case insensitive). + expect(text.toLowerCase()).toContain("claim paid"); + // ts formatted via fmt.date — at minimum the year is rendered. + expect(text).toContain("2026"); + + unmount(); + }); + + it("test_multiple_events_render_in_input_order", () => { + const events = [ + makeEvent({ kind: "claim_submitted", ts: "2026-06-10T10:00:00Z" }), + makeEvent({ kind: "claim_accepted", ts: "2026-06-11T11:00:00Z" }), + makeEvent({ kind: "remit_received", ts: "2026-06-12T12:00:00Z" }), + makeEvent({ kind: "claim_paid", ts: "2026-06-13T13:00:00Z" }), + ]; + + const { container, unmount } = renderIntoContainer( + + ); + + const items = Array.from( + container.querySelectorAll('[data-testid="state-history-item"]') + ); + expect(items.length).toBe(4); + + const kinds = items.map( + (li) => li.getAttribute("data-kind") ?? "" + ); + // The backend returns events most-recent-first; the component must + // render them in input order without resorting, so a re-sorting + // reordering would be visible here. + expect(kinds).toEqual([ + "claim_submitted", + "claim_accepted", + "remit_received", + "claim_paid", + ]); + + unmount(); + }); + + it.each([ + [ + "claim_submitted", + "bg-[color:var(--m-ink-secondary)]", + ], + [ + "claim_accepted", + "bg-[color:var(--m-success)]", + ], + [ + "claim_paid", + "bg-[color:var(--m-success)]", + ], + [ + "claim_denied", + "bg-[color:var(--m-error)]", + ], + [ + "manual_match", + "bg-[color:var(--m-warning)]", + ], + [ + "manual_unmatch", + "bg-[color:var(--m-warning)]", + ], + [ + "remit_received", + "bg-[color:var(--m-accent)]", + ], + [ + "something_unknown", + "bg-[color:var(--m-ink-tertiary)]", + ], + ])( + "test_dot_color_for_kind_%s", + (kind, expectedClass) => { + const { container, unmount } = renderIntoContainer( + + ); + + const dot = container.querySelector('[data-testid="state-history-dot"]'); + expect(dot).not.toBeNull(); + const cls = dot?.className ?? ""; + expect(cls).toContain(expectedClass); + + unmount(); + } + ); + + it("test_event_with_remittanceId_renders_id", () => { + const events = [ + makeEvent({ + kind: "remit_received", + remittanceId: "REM-2026-0007", + }), + ]; + + const { container, unmount } = renderIntoContainer( + + ); + + const id = container.querySelector('[data-testid="history-remit-id"]'); + expect(id).not.toBeNull(); + expect(id?.textContent ?? "").toContain("REM-2026-0007"); + + unmount(); + }); + + it("test_event_without_remittanceId_does_not_render_id", () => { + const events = [ + makeEvent({ kind: "claim_submitted", remittanceId: null }), + ]; + + const { container, unmount } = renderIntoContainer( + + ); + + expect( + container.querySelector('[data-testid="history-remit-id"]') + ).toBeNull(); + + unmount(); + }); + + it("test_mixed_events_with_and_without_remit_ids", () => { + const events = [ + makeEvent({ kind: "claim_submitted", remittanceId: null }), + makeEvent({ + kind: "remit_received", + remittanceId: "REM-A", + }), + makeEvent({ kind: "claim_paid", remittanceId: null }), + makeEvent({ + kind: "manual_match", + remittanceId: "REM-B", + }), + ]; + + const { container, unmount } = renderIntoContainer( + + ); + + const items = container.querySelectorAll('[data-testid="state-history-item"]'); + expect(items.length).toBe(4); + + // Per-row presence of the remittance id element. + expect( + items[0].querySelector('[data-testid="history-remit-id"]') + ).toBeNull(); + expect( + items[1].querySelector('[data-testid="history-remit-id"]')?.textContent ?? "" + ).toContain("REM-A"); + expect( + items[2].querySelector('[data-testid="history-remit-id"]') + ).toBeNull(); + expect( + items[3].querySelector('[data-testid="history-remit-id"]')?.textContent ?? "" + ).toContain("REM-B"); + + unmount(); + }); +}); diff --git a/src/components/ClaimDrawer/StateHistoryTimeline.tsx b/src/components/ClaimDrawer/StateHistoryTimeline.tsx new file mode 100644 index 0000000..b355f94 --- /dev/null +++ b/src/components/ClaimDrawer/StateHistoryTimeline.tsx @@ -0,0 +1,126 @@ +import { fmt } from "@/lib/format"; +import type { ClaimDetail } from "@/types"; + +type StateHistoryTimelineProps = { + history: ClaimDetail["stateHistory"]; +}; + +/** + * Event-kind → dot background color. The dot is the only colored element + * per row; the rest of the row uses the standard ink tokens so the + * timeline reads as a calm list of facts, with the dot color doing the + * signal work. + * + * claim_submitted → ink-secondary (neutral, in-flight) + * claim_accepted → success (payer accepted) + * claim_paid → success (funds received) + * claim_denied → error (needs attention) + * manual_match → warning (operator action) + * manual_unmatch → warning (operator action) + * remit_received → accent (ERA arrived) + * anything else → muted (forward-compat with new kinds) + */ +const DOT_BG: Record = { + claim_submitted: "bg-[color:var(--m-ink-secondary)]", + claim_accepted: "bg-[color:var(--m-success)]", + claim_paid: "bg-[color:var(--m-success)]", + claim_denied: "bg-[color:var(--m-error)]", + manual_match: "bg-[color:var(--m-warning)]", + manual_unmatch: "bg-[color:var(--m-warning)]", + remit_received: "bg-[color:var(--m-accent)]", +}; + +function dotColorFor(kind: string): string { + return DOT_BG[kind] ?? "bg-[color:var(--m-ink-tertiary)]"; +} + +/** + * Display label for the event kind. The raw kind is a snake-case machine + * value (e.g. ``claim_paid``); replacing underscores with spaces gives a + * friendlier visual label without losing the original token (it's still + * on the ``
  • `` attribute for tests/inspection). The + * CSS ``uppercase`` transform handles visual casing. + */ +function kindLabel(kind: string): string { + return kind.replace(/_/g, " "); +} + +/** + * State-history timeline for the claim detail drawer (SP4). + * + * Vertical timeline: a 2px left rule on the outer
      with a colored + * dot on each
    1. centered on the rule (negative offset). Events + * render in input order — the backend already returns them + * most-recent-first per the type comment, so the visual order matches + * the audit order without resorting. Each row shows the kind eyebrow, + * a mono ts (date + time), and an optional remittance id underneath + * when the event was triggered by an ERA. + */ +export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) { + return ( +
      +

      + State History ({history.length}) +

      + + {history.length === 0 ? ( +

      + No history events +

      + ) : ( +
        + {history.map((event, i) => ( +
      1. + +
        + + {kindLabel(event.kind)} + + + {fmt.date(event.ts)} · {fmt.time(event.ts)} + +
        + {event.remittanceId ? ( + + Remit {event.remittanceId} + + ) : null} +
      2. + ))} +
      + )} +
      + ); +}