feat(sp7): wire LineReconciliationTab into ClaimDrawer

This commit is contained in:
Tyler
2026-06-20 19:51:04 -06:00
parent 2569ff99a0
commit 0b8f253186
4 changed files with 192 additions and 11 deletions
+77 -2
View File
@@ -1,8 +1,9 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { ApiError } from "@/lib/api";
import { useClaimDetail } from "@/hooks/useClaimDetail";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
import { useLineReconciliation } from "@/hooks/useLineReconciliation";
import { ClaimDrawerHeader } from "./ClaimDrawerHeader";
import { ValidationPanel } from "./ValidationPanel";
import { ServiceLinesTable } from "./ServiceLinesTable";
@@ -11,6 +12,7 @@ import { PartiesGrid } from "./PartiesGrid";
import { RawSegmentsPanel } from "./RawSegmentsPanel";
import { MatchedRemitCard } from "./MatchedRemitCard";
import { StateHistoryTimeline } from "./StateHistoryTimeline";
import { LineReconciliationTab } from "./LineReconciliationTab";
import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
import { ClaimDrawerError } from "./ClaimDrawerError";
@@ -71,6 +73,12 @@ export function ClaimDrawer({
onToggleHelp,
}: ClaimDrawerProps) {
const { data, isLoading, isError, error, refetch } = useClaimDetail(claimId);
// SP7: tab state. The Line Reconciliation tab lazy-fetches the
// per-line projection when first activated.
const [activeTab, setActiveTab] = useState<"details" | "line-reconciliation">("details");
const lr = useLineReconciliation(
activeTab === "line-reconciliation" ? claimId : null
);
// j/k navigation: derive next/prev IDs based on the current claim's
// index in the parent list. Wrap around at both ends. If the current
@@ -157,9 +165,75 @@ export function ClaimDrawer({
data-testid="claim-drawer-content"
>
<ClaimDrawerHeader claim={data} onClose={onClose} />
<div
className="flex gap-2 px-6 pt-4 border-b"
style={{ borderColor: "var(--tt-bg)" }}
data-testid="claim-drawer-tabs"
>
<button
type="button"
onClick={() => setActiveTab("details")}
className="font-mono text-xs uppercase tracking-wider px-3 py-2"
style={{
color:
activeTab === "details"
? "var(--tt-ink, currentColor)"
: "var(--tt-ink-dim, rgba(255,255,255,0.5))",
borderBottom:
activeTab === "details"
? "2px solid var(--tt-amber)"
: "2px solid transparent",
}}
data-testid="tab-button-details"
data-active={activeTab === "details" ? "true" : "false"}
>
Details
</button>
<button
type="button"
onClick={() => setActiveTab("line-reconciliation")}
className="font-mono text-xs uppercase tracking-wider px-3 py-2"
style={{
color:
activeTab === "line-reconciliation"
? "var(--tt-ink, currentColor)"
: "var(--tt-ink-dim, rgba(255,255,255,0.5))",
borderBottom:
activeTab === "line-reconciliation"
? "2px solid var(--tt-amber)"
: "2px solid transparent",
}}
data-testid="tab-button-line-reconciliation"
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
>
Line Reconciliation
</button>
</div>
{activeTab === "line-reconciliation" ? (
lr.loading ? (
<p
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
data-testid="line-reconciliation-loading"
>
Loading line reconciliation
</p>
) : lr.error ? (
<p
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
data-testid="line-reconciliation-error"
>
Failed to load line reconciliation.
</p>
) : lr.data ? (
<LineReconciliationTab data={lr.data} />
) : null
) : (
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
<ValidationPanel validation={data.validation} />
<ServiceLinesTable serviceLines={data.serviceLines} />
<ServiceLinesTable
serviceLines={data.serviceLines}
lineReconciliation={data.lineReconciliation}
/>
<DiagnosesList diagnoses={data.diagnoses} />
<PartiesGrid parties={data.parties} />
{data.matchedRemittance ? (
@@ -168,6 +242,7 @@ export function ClaimDrawer({
<RawSegmentsPanel rawSegments={data.rawSegments} />
<StateHistoryTimeline history={data.stateHistory} />
</div>
)}
</div>
) : null}
</DialogContent>
+1
View File
@@ -11,3 +11,4 @@ export { MatchedRemitCard } from "./MatchedRemitCard";
export { StateHistoryTimeline } from "./StateHistoryTimeline";
export { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
export { ClaimDrawerError } from "./ClaimDrawerError";
export { LineReconciliationTab } from "./LineReconciliationTab";
+56
View File
@@ -0,0 +1,56 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { useLineReconciliation } from "./useLineReconciliation";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("useLineReconciliation", () => {
it("fetches the line reconciliation data on mount", async () => {
const fixture = {
claimId: "CLM-1",
summary: {
billedTotal: "100",
paidTotal: "80",
adjustmentTotal: "20",
matchedLines: 1,
totalLines: 1,
},
lines: [],
};
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => fixture,
})
);
const { result } = renderHook(() => useLineReconciliation("CLM-1"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual(fixture);
});
it("returns null when claimId is null", () => {
const { result } = renderHook(() => useLineReconciliation(null));
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(false);
});
it("exposes an error when fetch fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({}),
})
);
const { result } = renderHook(() => useLineReconciliation("CLM-1"));
await waitFor(() => expect(result.current.error).not.toBeNull());
});
});
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import type { LineReconciliationResponse } from "@/types";
/**
* Data hook for the ClaimDrawer's Line Reconciliation tab.
*
* Calls GET /api/claims/{claimId}/line-reconciliation on mount and
* when claimId changes. When claimId is null, returns a no-data state.
*/
export function useLineReconciliation(claimId: string | null) {
const [data, setData] = useState<LineReconciliationResponse | null>(null);
const [loading, setLoading] = useState<boolean>(claimId !== null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (claimId === null) {
setData(null);
setLoading(false);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
fetch(`${baseUrl}/api/claims/${claimId}/line-reconciliation`)
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()) as LineReconciliationResponse;
})
.then((json) => {
if (cancelled) return;
setData(json);
setError(null);
})
.catch((e) => {
if (cancelled) return;
setError(e as Error);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [claimId]);
return { data, loading, error };
}