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 { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import { useClaimDetail } from "@/hooks/useClaimDetail";
|
import { useClaimDetail } from "@/hooks/useClaimDetail";
|
||||||
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
|
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
|
||||||
|
import { useLineReconciliation } from "@/hooks/useLineReconciliation";
|
||||||
import { ClaimDrawerHeader } from "./ClaimDrawerHeader";
|
import { ClaimDrawerHeader } from "./ClaimDrawerHeader";
|
||||||
import { ValidationPanel } from "./ValidationPanel";
|
import { ValidationPanel } from "./ValidationPanel";
|
||||||
import { ServiceLinesTable } from "./ServiceLinesTable";
|
import { ServiceLinesTable } from "./ServiceLinesTable";
|
||||||
@@ -11,6 +12,7 @@ import { PartiesGrid } from "./PartiesGrid";
|
|||||||
import { RawSegmentsPanel } from "./RawSegmentsPanel";
|
import { RawSegmentsPanel } from "./RawSegmentsPanel";
|
||||||
import { MatchedRemitCard } from "./MatchedRemitCard";
|
import { MatchedRemitCard } from "./MatchedRemitCard";
|
||||||
import { StateHistoryTimeline } from "./StateHistoryTimeline";
|
import { StateHistoryTimeline } from "./StateHistoryTimeline";
|
||||||
|
import { LineReconciliationTab } from "./LineReconciliationTab";
|
||||||
import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
|
import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
|
||||||
import { ClaimDrawerError } from "./ClaimDrawerError";
|
import { ClaimDrawerError } from "./ClaimDrawerError";
|
||||||
|
|
||||||
@@ -71,6 +73,12 @@ export function ClaimDrawer({
|
|||||||
onToggleHelp,
|
onToggleHelp,
|
||||||
}: ClaimDrawerProps) {
|
}: ClaimDrawerProps) {
|
||||||
const { data, isLoading, isError, error, refetch } = useClaimDetail(claimId);
|
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
|
// 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
|
// index in the parent list. Wrap around at both ends. If the current
|
||||||
@@ -157,17 +165,84 @@ export function ClaimDrawer({
|
|||||||
data-testid="claim-drawer-content"
|
data-testid="claim-drawer-content"
|
||||||
>
|
>
|
||||||
<ClaimDrawerHeader claim={data} onClose={onClose} />
|
<ClaimDrawerHeader claim={data} onClose={onClose} />
|
||||||
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
|
<div
|
||||||
<ValidationPanel validation={data.validation} />
|
className="flex gap-2 px-6 pt-4 border-b"
|
||||||
<ServiceLinesTable serviceLines={data.serviceLines} />
|
style={{ borderColor: "var(--tt-bg)" }}
|
||||||
<DiagnosesList diagnoses={data.diagnoses} />
|
data-testid="claim-drawer-tabs"
|
||||||
<PartiesGrid parties={data.parties} />
|
>
|
||||||
{data.matchedRemittance ? (
|
<button
|
||||||
<MatchedRemitCard matchedRemittance={data.matchedRemittance} />
|
type="button"
|
||||||
) : null}
|
onClick={() => setActiveTab("details")}
|
||||||
<RawSegmentsPanel rawSegments={data.rawSegments} />
|
className="font-mono text-xs uppercase tracking-wider px-3 py-2"
|
||||||
<StateHistoryTimeline history={data.stateHistory} />
|
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>
|
</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}
|
||||||
|
lineReconciliation={data.lineReconciliation}
|
||||||
|
/>
|
||||||
|
<DiagnosesList diagnoses={data.diagnoses} />
|
||||||
|
<PartiesGrid parties={data.parties} />
|
||||||
|
{data.matchedRemittance ? (
|
||||||
|
<MatchedRemitCard matchedRemittance={data.matchedRemittance} />
|
||||||
|
) : null}
|
||||||
|
<RawSegmentsPanel rawSegments={data.rawSegments} />
|
||||||
|
<StateHistoryTimeline history={data.stateHistory} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export { MatchedRemitCard } from "./MatchedRemitCard";
|
|||||||
export { StateHistoryTimeline } from "./StateHistoryTimeline";
|
export { StateHistoryTimeline } from "./StateHistoryTimeline";
|
||||||
export { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
|
export { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
|
||||||
export { ClaimDrawerError } from "./ClaimDrawerError";
|
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