feat(sp7): wire LineReconciliationTab into ClaimDrawer
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -11,3 +11,4 @@ export { MatchedRemitCard } from "./MatchedRemitCard";
|
||||
export { StateHistoryTimeline } from "./StateHistoryTimeline";
|
||||
export { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
|
||||
export { ClaimDrawerError } from "./ClaimDrawerError";
|
||||
export { LineReconciliationTab } from "./LineReconciliationTab";
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user