feat: claims explorer — the lakehouse rows behind every number
- /claims: Databricks claims table through the record-view API, server-side paging and filters (exact on dimensions, ranges on dates); URL is the filter set - finding tiles link to the exact rows: affected set, and the unfiled-inside-window subset behind 'still recoverable'; kind-aware (expiring, slow payer, underpayment) - 'What Ava reviewed' work statement shown under the headline - client: range / multi-value filters Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
1d2dc4a1b9
commit
e8741f6333
@ -5,6 +5,7 @@ import { Login } from './pages/Login';
|
|||||||
import { QueueScreen } from './screens/QueueScreen';
|
import { QueueScreen } from './screens/QueueScreen';
|
||||||
import { CaseScreen } from './screens/CaseScreen';
|
import { CaseScreen } from './screens/CaseScreen';
|
||||||
import { RunReviewScreen } from './screens/RunReviewScreen';
|
import { RunReviewScreen } from './screens/RunReviewScreen';
|
||||||
|
import { ClaimsScreen } from './screens/ClaimsScreen';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes.
|
* Routes.
|
||||||
@ -25,6 +26,7 @@ export function App() {
|
|||||||
<Route path="/q/:q" element={<QueueScreen />} />
|
<Route path="/q/:q" element={<QueueScreen />} />
|
||||||
<Route path="/c/:id" element={<CaseScreen />} />
|
<Route path="/c/:id" element={<CaseScreen />} />
|
||||||
<Route path="/review" element={<RunReviewScreen />} />
|
<Route path="/review" element={<RunReviewScreen />} />
|
||||||
|
<Route path="/claims" element={<ClaimsScreen />} />
|
||||||
{/* Awaiting Review is the landing page, not "all cases": it is the
|
{/* Awaiting Review is the landing page, not "all cases": it is the
|
||||||
queue with work in it, and the one the console exists to serve. */}
|
queue with work in it, and the one the console exists to serve. */}
|
||||||
<Route path="/" element={<Navigate to="/q/review" replace />} />
|
<Route path="/" element={<Navigate to="/q/review" replace />} />
|
||||||
|
|||||||
@ -224,5 +224,24 @@ export async function loadBriefing(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The date the lakehouse views are anchored on: the last service week's
|
||||||
|
* Sunday, or the replay date. Deep links into the claims table use it for
|
||||||
|
* "still inside the appeal window" and "expiring within 14 days". */
|
||||||
|
export function anchorDate(data: BriefingData | null | undefined): string | null {
|
||||||
|
if (!data) return null;
|
||||||
|
if (data.asOf) return data.asOf;
|
||||||
|
const last = data.weeklyRate[data.weeklyRate.length - 1]?.week;
|
||||||
|
if (!last) return null;
|
||||||
|
const d = new Date(last + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() + 6);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(iso: string, days: number): string {
|
||||||
|
const d = new Date(iso + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
/** Ordered by how alive the money is. Fixed order → fixed colour per bucket. */
|
/** Ordered by how alive the money is. Fixed order → fixed colour per bucket. */
|
||||||
export const RECOVERY_ORDER = ['recovered', 'appeal filed', 'still appealable', 'lost on appeal', 'window closed'];
|
export const RECOVERY_ORDER = ['recovered', 'appeal filed', 'still appealable', 'lost on appeal', 'window closed'];
|
||||||
|
|||||||
@ -141,9 +141,11 @@ export class ZinoClient {
|
|||||||
search: params.search ?? '',
|
search: params.search ?? '',
|
||||||
filters: (params.filters ?? []).map((f) => ({
|
filters: (params.filters ?? []).map((f) => ({
|
||||||
field_key: f.field_key,
|
field_key: f.field_key,
|
||||||
value: f.value,
|
value: f.value ?? '',
|
||||||
value2: '',
|
value2: '',
|
||||||
data_type: f.data_type ?? 'string',
|
...(f.values ? { values: f.values } : {}),
|
||||||
|
...(f.range ? { range: f.range } : {}),
|
||||||
|
data_type: f.data_type ?? (f.range ? 'date' : 'string'),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -45,13 +45,23 @@ export interface RecordViewResponse {
|
|||||||
/** A record-view row. Values are whatever the column's data_type implies. */
|
/** A record-view row. Values are whatever the column's data_type implies. */
|
||||||
export type Row = Record<string, unknown>;
|
export type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** One record-view filter. `value` is exact match; `values` matches any;
|
||||||
|
* `range` (with data_type 'date') is an inclusive, open-endable bound. */
|
||||||
|
export interface RecordFilter {
|
||||||
|
field_key: string;
|
||||||
|
value?: string;
|
||||||
|
values?: string[];
|
||||||
|
range?: { from?: string; to?: string };
|
||||||
|
data_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecordViewParams {
|
export interface RecordViewParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortDir?: 'asc' | 'desc';
|
sortDir?: 'asc' | 'desc';
|
||||||
search?: string;
|
search?: string;
|
||||||
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
|
filters?: RecordFilter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetailViewResponse {
|
export interface DetailViewResponse {
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AlertTriangle, CheckCircle2, ChevronDown, FileSearch } from 'lucide-react';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { AlertTriangle, CheckCircle2, ChevronDown, ExternalLink, FileSearch } from 'lucide-react';
|
||||||
|
import type { Slice } from '../api/charts';
|
||||||
|
import { affectedClaimsHref, recoverableClaimsHref } from '../lib/claimsLink';
|
||||||
import { Badge, Card, Facts } from './core';
|
import { Badge, Card, Facts } from './core';
|
||||||
import type { AvaDecision, AvaTraceStep, Row } from '../api/types';
|
import type { AvaDecision, AvaTraceStep, Row } from '../api/types';
|
||||||
import {
|
import {
|
||||||
@ -30,10 +33,23 @@ import {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** The headline card: one sentence, and the three numbers. */
|
/** The headline card: one sentence, and the three numbers. */
|
||||||
export function Verdict({ row }: { row: Row }) {
|
export function Verdict({
|
||||||
|
row,
|
||||||
|
slice = null,
|
||||||
|
kind = '',
|
||||||
|
anchor = null,
|
||||||
|
}: {
|
||||||
|
row: Row;
|
||||||
|
slice?: Slice | null;
|
||||||
|
kind?: string;
|
||||||
|
anchor?: string | null;
|
||||||
|
}) {
|
||||||
const summary = str(row.summary);
|
const summary = str(row.summary);
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
const line = headline(summary);
|
const line = headline(summary);
|
||||||
|
const scanned = str(row.scanned_summary);
|
||||||
|
const affectedHref = affectedClaimsHref(kind, slice, anchor);
|
||||||
|
const recoverableHref = recoverableClaimsHref(kind, slice, anchor);
|
||||||
const atRisk = num(row.amount_at_risk);
|
const atRisk = num(row.amount_at_risk);
|
||||||
const recoverable = num(row.recoverable_amount);
|
const recoverable = num(row.recoverable_amount);
|
||||||
const affected = num(row.affected_claims);
|
const affected = num(row.affected_claims);
|
||||||
@ -58,15 +74,26 @@ export function Verdict({ row }: { row: Row }) {
|
|||||||
)}
|
)}
|
||||||
<span className="ml-auto">AI analysis · Ava</span>
|
<span className="ml-auto">AI analysis · Ava</span>
|
||||||
</div>
|
</div>
|
||||||
|
{scanned && (
|
||||||
|
<p className="mt-3 border-t border-ai-edge/60 pt-3 text-[13px] leading-relaxed text-muted">
|
||||||
|
<span className="font-semibold text-ink">What Ava reviewed. </span>
|
||||||
|
{scanned}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-px bg-line sm:grid-cols-3">
|
<div className="grid gap-px bg-line sm:grid-cols-3">
|
||||||
<Big label="Claims denied" value={affected == null ? '—' : affected.toLocaleString('en-US')} />
|
<Big
|
||||||
|
label={kind === 'slow_payer' ? 'Claims unanswered' : kind === 'underpayment' ? 'Claims short-paid' : 'Claims denied'}
|
||||||
|
value={affected == null ? '—' : affected.toLocaleString('en-US')}
|
||||||
|
href={affectedHref}
|
||||||
|
/>
|
||||||
<Big label="Money at stake" value={money(atRisk)} tone="bad" />
|
<Big label="Money at stake" value={money(atRisk)} tone="bad" />
|
||||||
<Big
|
<Big
|
||||||
label="Still recoverable"
|
label="Still recoverable"
|
||||||
value={money(recoverable)}
|
value={money(recoverable)}
|
||||||
tone="good"
|
tone="good"
|
||||||
|
href={recoverableHref}
|
||||||
note={
|
note={
|
||||||
deadline
|
deadline
|
||||||
? `if appeals are filed — first deadline ${shortDate(deadline)}`
|
? `if appeals are filed — first deadline ${shortDate(deadline)}`
|
||||||
@ -247,14 +274,27 @@ export function Recheck({ row }: { row: Row }) {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function Big({ label, value, tone, note }: { label: string; value: string; tone?: 'good' | 'bad'; note?: string }) {
|
function Big({ label, value, tone, note, href }: { label: string; value: string; tone?: 'good' | 'bad'; note?: string; href?: string | null }) {
|
||||||
const text = tone === 'good' ? 'text-good' : tone === 'bad' ? 'text-bad' : 'text-ink';
|
const text = tone === 'good' ? 'text-good' : tone === 'bad' ? 'text-bad' : 'text-ink';
|
||||||
return (
|
const body = (
|
||||||
<div className="bg-panel px-5 py-4">
|
<>
|
||||||
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">{label}</p>
|
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">{label}</p>
|
||||||
<p className={`tnum mt-1 text-[28px] leading-none font-bold ${text}`}>{value}</p>
|
<p className={`tnum mt-1 text-[28px] leading-none font-bold ${text}`}>{value}</p>
|
||||||
{note && <p className="mt-1.5 text-xs text-muted">{note}</p>}
|
{note && <p className="mt-1.5 text-xs text-muted">{note}</p>}
|
||||||
</div>
|
{href && (
|
||||||
|
<p className="mt-1.5 inline-flex items-center gap-1 text-xs text-brand">
|
||||||
|
See the claims <ExternalLink className="h-3 w-3" />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
// A number you can click opens the rows behind it in the claims table.
|
||||||
|
return href ? (
|
||||||
|
<Link to={href} className="block bg-panel px-5 py-4 transition-colors hover:bg-panel2/60" title="Open these claims">
|
||||||
|
{body}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="bg-panel px-5 py-4">{body}</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { LogOut, Radar } from 'lucide-react';
|
import { Database, LogOut, Radar } from 'lucide-react';
|
||||||
import { useZino } from '../api/provider';
|
import { useZino } from '../api/provider';
|
||||||
import { QUEUES, prettyRole } from '../api/config';
|
import { QUEUES, prettyRole } from '../api/config';
|
||||||
import { NorthlakeLockup } from '../components/NorthlakeLogo';
|
import { NorthlakeLockup } from '../components/NorthlakeLogo';
|
||||||
@ -90,6 +90,28 @@ export function Shell() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{/* The data itself, after the pipeline: every claim in the lakehouse. */}
|
||||||
|
<li className="shrink-0 lg:mt-3 lg:shrink lg:border-t lg:border-line lg:pt-3">
|
||||||
|
<NavLink
|
||||||
|
to="/claims"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block rounded-md px-3 py-2 transition-colors ${
|
||||||
|
isActive ? 'bg-brand text-brand-fg' : 'text-muted hover:bg-panel2 hover:text-ink'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[13px] font-semibold whitespace-nowrap">
|
||||||
|
<Database className="h-3.5 w-3.5" /> Claims
|
||||||
|
</span>
|
||||||
|
<span className={`hidden text-[11px] lg:block ${isActive ? 'text-brand-fg/70' : 'text-faint'}`}>
|
||||||
|
The lakehouse, row by row
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
62
src/lib/claimsLink.ts
Normal file
62
src/lib/claimsLink.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import type { Slice } from '../api/charts';
|
||||||
|
import { addDays, isDenialKind } from '../api/charts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep links from a finding into the claims table.
|
||||||
|
*
|
||||||
|
* Ava writes "216 claims" and "172 still appealable"; these build the
|
||||||
|
* /claims URL whose filters reproduce those rows. Every filter here is a
|
||||||
|
* column the record view declares (11_claims_explorer.sql), and the date
|
||||||
|
* bounds compile to inclusive ranges on the lakehouse. The anchor is the
|
||||||
|
* data's latest date (or the replay date) — without it we still link, just
|
||||||
|
* without the "inside the window" bound.
|
||||||
|
*/
|
||||||
|
export type ClaimsQuery = Partial<Record<
|
||||||
|
'payer' | 'department' | 'code' | 'category' | 'status' | 'appeal' | 'site' | 'desk' | 'provider' | 'cpt'
|
||||||
|
| 'from' | 'to' | 'sub_from' | 'sub_to' | 'dl_from' | 'dl_to' | 'page',
|
||||||
|
string
|
||||||
|
>>;
|
||||||
|
|
||||||
|
export function claimsHref(q: ClaimsQuery): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(q)) if (v) p.set(k, v);
|
||||||
|
const s = p.toString();
|
||||||
|
return s ? `/claims?${s}` : '/claims';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rows behind "claims denied" / "affected". */
|
||||||
|
export function affectedClaimsHref(kind: string, slice: Slice | null, anchor: string | null): string | null {
|
||||||
|
if (kind === 'expiring_appeals') {
|
||||||
|
return claimsHref({ status: 'denied', appeal: 'not_filed', dl_from: anchor ?? undefined, dl_to: anchor ? addDays(anchor, 14) : undefined });
|
||||||
|
}
|
||||||
|
if (kind === 'slow_payer') {
|
||||||
|
return slice?.payer ? claimsHref({ payer: slice.payer, status: 'pending' }) : null;
|
||||||
|
}
|
||||||
|
if (kind === 'underpayment') {
|
||||||
|
return slice?.payer ? claimsHref({ payer: slice.payer, status: 'partial', from: slice.spikeStart ?? undefined }) : null;
|
||||||
|
}
|
||||||
|
if (!slice || !isDenialKind(kind)) return null;
|
||||||
|
return claimsHref({
|
||||||
|
payer: slice.payer || undefined,
|
||||||
|
department: slice.department || undefined,
|
||||||
|
code: slice.code || undefined,
|
||||||
|
status: 'denied',
|
||||||
|
from: slice.spikeStart ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rows behind "still recoverable": the affected set, unfiled and inside its window. */
|
||||||
|
export function recoverableClaimsHref(kind: string, slice: Slice | null, anchor: string | null): string | null {
|
||||||
|
if (kind === 'expiring_appeals') return affectedClaimsHref(kind, slice, anchor);
|
||||||
|
if (kind === 'slow_payer' || kind === 'underpayment') return null;
|
||||||
|
if (!slice || !isDenialKind(kind)) return null;
|
||||||
|
return claimsHref({
|
||||||
|
payer: slice.payer || undefined,
|
||||||
|
department: slice.department || undefined,
|
||||||
|
code: slice.code || undefined,
|
||||||
|
status: 'denied',
|
||||||
|
appeal: 'not_filed',
|
||||||
|
from: slice.spikeStart ?? undefined,
|
||||||
|
dl_from: anchor ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@ import { ActivityForm } from '../components/ActivityForm';
|
|||||||
import { AvaLive } from '../components/AvaLive';
|
import { AvaLive } from '../components/AvaLive';
|
||||||
import { Disclosure, FullStory, Recheck, Recommendations, SqlEvidence, Verdict } from '../components/Findings';
|
import { Disclosure, FullStory, Recheck, Recommendations, SqlEvidence, Verdict } from '../components/Findings';
|
||||||
import { Briefing, BriefingSkeleton } from '../components/Briefing';
|
import { Briefing, BriefingSkeleton } from '../components/Briefing';
|
||||||
import { loadBriefing, sliceOf } from '../api/charts';
|
import { anchorDate, loadBriefing, sliceOf } from '../api/charts';
|
||||||
import type { Action } from '../api/config';
|
import type { Action } from '../api/config';
|
||||||
import { dateTime, num, str } from '../format';
|
import { dateTime, num, str } from '../format';
|
||||||
|
|
||||||
@ -171,7 +171,7 @@ export function CaseScreen() {
|
|||||||
{/* ---------- The findings: what happened, how much, what to approve ---------- */}
|
{/* ---------- The findings: what happened, how much, what to approve ---------- */}
|
||||||
{hasFindings && (
|
{hasFindings && (
|
||||||
<>
|
<>
|
||||||
<Verdict row={row} />
|
<Verdict row={row} slice={slice} kind={kind} anchor={anchorDate(briefing.data)} />
|
||||||
{briefing.data ? (
|
{briefing.data ? (
|
||||||
<Briefing data={briefing.data} slice={slice} kind={kind} />
|
<Briefing data={briefing.data} slice={slice} kind={kind} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
251
src/screens/ClaimsScreen.tsx
Normal file
251
src/screens/ClaimsScreen.tsx
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { ChevronLeft, ChevronRight, Database, X } from 'lucide-react';
|
||||||
|
import { useQuery, useZino } from '../api/provider';
|
||||||
|
import { LAKEHOUSE } from '../api/config';
|
||||||
|
import type { Tone } from '../api/config';
|
||||||
|
import type { RecordFilter, Row } from '../api/types';
|
||||||
|
import { Badge, ErrorNote, Spinner } from '../components/core';
|
||||||
|
import { CARC, money, num, shortDate, str } from '../format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The claims table — the lakehouse rows themselves, read-only.
|
||||||
|
*
|
||||||
|
* Reached two ways: from the sidebar (unfiltered, newest first) and from a
|
||||||
|
* finding's numbers ("216 claims" → the exact rows Ava counted). The URL IS
|
||||||
|
* the filter set, so a link can be copied into an email and lands on the same
|
||||||
|
* rows. Everything is server-side: paging, filtering and the total come from
|
||||||
|
* /view/recordview against the Databricks-backed record view.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PAGE = 50;
|
||||||
|
|
||||||
|
/** URL param → record-view column, with the control shape it compiles to. */
|
||||||
|
const DIMS: Array<{ key: string; col: string; label: string }> = [
|
||||||
|
{ key: 'payer', col: 'payer', label: 'Insurer' },
|
||||||
|
{ key: 'department', col: 'department', label: 'Department' },
|
||||||
|
{ key: 'code', col: 'denial_reason_code', label: 'Reason code' },
|
||||||
|
{ key: 'category', col: 'denial_category', label: 'Denial category' },
|
||||||
|
{ key: 'status', col: 'status', label: 'Status' },
|
||||||
|
{ key: 'appeal', col: 'appeal_status', label: 'Appeal' },
|
||||||
|
{ key: 'site', col: 'site', label: 'Site' },
|
||||||
|
{ key: 'desk', col: 'registration_desk', label: 'Desk' },
|
||||||
|
{ key: 'provider', col: 'provider', label: 'Provider' },
|
||||||
|
{ key: 'cpt', col: 'procedure_code', label: 'CPT' },
|
||||||
|
];
|
||||||
|
const RANGES: Array<{ from: string; to: string; col: string; label: string }> = [
|
||||||
|
{ from: 'from', to: 'to', col: 'service_date', label: 'Service date' },
|
||||||
|
{ from: 'sub_from', to: 'sub_to', col: 'submitted_date', label: 'Submitted' },
|
||||||
|
{ from: 'dl_from', to: 'dl_to', col: 'appeal_deadline', label: 'Appeal deadline' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<string, Tone> = {
|
||||||
|
paid: 'emerald',
|
||||||
|
partial: 'amber',
|
||||||
|
denied: 'rose',
|
||||||
|
pending: 'slate',
|
||||||
|
};
|
||||||
|
const APPEAL_LABEL: Record<string, string> = {
|
||||||
|
not_filed: 'not filed',
|
||||||
|
filed: 'filed',
|
||||||
|
overturned: 'overturned',
|
||||||
|
upheld: 'upheld',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClaimsScreen() {
|
||||||
|
const { client } = useZino();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const page = Math.max(1, Number(params.get('page') ?? '1') || 1);
|
||||||
|
|
||||||
|
// The filter set, in the order the chips show it.
|
||||||
|
const chips = useMemo(() => {
|
||||||
|
const out: Array<{ label: string; value: string; remove: string[] }> = [];
|
||||||
|
for (const d of DIMS) {
|
||||||
|
const v = params.get(d.key);
|
||||||
|
if (v) out.push({ label: d.label, value: d.col === 'appeal_status' ? (APPEAL_LABEL[v] ?? v) : v, remove: [d.key] });
|
||||||
|
}
|
||||||
|
for (const r of RANGES) {
|
||||||
|
const a = params.get(r.from);
|
||||||
|
const b = params.get(r.to);
|
||||||
|
if (a || b) out.push({ label: r.label, value: a && b ? `${shortDate(a)} – ${shortDate(b)}` : a ? `from ${shortDate(a)}` : `to ${shortDate(b)}`, remove: [r.from, r.to] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
const filters = useMemo<RecordFilter[]>(() => {
|
||||||
|
const f: RecordFilter[] = [];
|
||||||
|
for (const d of DIMS) {
|
||||||
|
const v = params.get(d.key);
|
||||||
|
if (v) f.push({ field_key: d.col, value: v, data_type: 'string' });
|
||||||
|
}
|
||||||
|
for (const r of RANGES) {
|
||||||
|
const a = params.get(r.from) ?? undefined;
|
||||||
|
const b = params.get(r.to) ?? undefined;
|
||||||
|
if (a || b) f.push({ field_key: r.col, data_type: 'date', range: { from: a, to: b } });
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
const key = params.toString();
|
||||||
|
const q = useQuery(
|
||||||
|
() => client.recordView('dni-rv-claims', { page, limit: PAGE, filters, sortBy: 'service_date', sortDir: 'desc' }),
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
const rows: Row[] = q.data?.records ?? q.data?.data ?? [];
|
||||||
|
const total = q.data?.total ?? null;
|
||||||
|
const pages = total != null ? Math.max(1, Math.ceil(total / PAGE)) : null;
|
||||||
|
const billedOnPage = rows.reduce((t, r) => t + (num(r.billed_amount) ?? 0), 0);
|
||||||
|
|
||||||
|
function go(next: URLSearchParams) {
|
||||||
|
navigate(`/claims${next.toString() ? `?${next}` : ''}`);
|
||||||
|
}
|
||||||
|
function drop(keys: string[]) {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
for (const k of keys) next.delete(k);
|
||||||
|
next.delete('page');
|
||||||
|
go(next);
|
||||||
|
}
|
||||||
|
function setPage(n: number) {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
if (n <= 1) next.delete('page');
|
||||||
|
else next.set('page', String(n));
|
||||||
|
go(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-xl font-semibold text-ink">Claims</h1>
|
||||||
|
<p className="mt-0.5 inline-flex items-center gap-1.5 text-xs text-faint">
|
||||||
|
<Database className="h-3.5 w-3.5" />
|
||||||
|
Read-only · {LAKEHOUSE.platform} · {LAKEHOUSE.catalog}.{LAKEHOUSE.schema}.claims
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="tnum text-sm text-muted">
|
||||||
|
{total == null ? '' : `${total.toLocaleString('en-US')} claim${total === 1 ? '' : 's'}`}
|
||||||
|
{rows.length > 0 && total != null && total > rows.length ? ` · ${money(billedOnPage)} billed on this page` : rows.length > 0 ? ` · ${money(billedOnPage)} billed` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chips.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{chips.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.label}
|
||||||
|
onClick={() => drop(c.remove)}
|
||||||
|
title="Remove filter"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1 text-xs text-ink hover:border-brand/60"
|
||||||
|
>
|
||||||
|
<span className="text-faint">{c.label}</span>
|
||||||
|
<span className="font-semibold">{c.value}</span>
|
||||||
|
<X className="h-3 w-3 text-faint" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<Link to="/claims" className="text-xs text-muted hover:text-ink">
|
||||||
|
Clear all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.error && <ErrorNote>{q.error}</ErrorNote>}
|
||||||
|
|
||||||
|
<div className="lift overflow-hidden rounded-xl border border-line bg-panel">
|
||||||
|
{q.loading && !q.data ? (
|
||||||
|
<Spinner label="Reading the lakehouse…" />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="px-5 py-10 text-center text-sm text-faint">No claims match these filters.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-[11px] font-semibold tracking-wide text-faint uppercase">
|
||||||
|
<Th>Claim</Th>
|
||||||
|
<Th>Service</Th>
|
||||||
|
<Th>Insurer</Th>
|
||||||
|
<Th>Department</Th>
|
||||||
|
<Th>Provider</Th>
|
||||||
|
<Th>Procedure</Th>
|
||||||
|
<Th right>Billed</Th>
|
||||||
|
<Th>Status</Th>
|
||||||
|
<Th>Reason</Th>
|
||||||
|
<Th>Appeal</Th>
|
||||||
|
<Th>Deadline</Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const status = str(r.status);
|
||||||
|
const code = str(r.denial_reason_code);
|
||||||
|
return (
|
||||||
|
<tr key={str(r.claim_id)} className="hover:bg-panel2/60">
|
||||||
|
<Td mono>{str(r.claim_id)}</Td>
|
||||||
|
<Td>{shortDate(str(r.service_date).slice(0, 10) || null)}</Td>
|
||||||
|
<Td>{str(r.payer)}</Td>
|
||||||
|
<Td>{str(r.department)}</Td>
|
||||||
|
<Td>{str(r.provider)}</Td>
|
||||||
|
<Td>
|
||||||
|
<span className="tnum text-faint">{str(r.procedure_code)}</span> {str(r.procedure_desc)}
|
||||||
|
</Td>
|
||||||
|
<Td right mono>{money(r.billed_amount)}</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge tone={STATUS_TONE[status] ?? 'slate'}>{status}</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{code ? (
|
||||||
|
<span title={CARC[code] ?? str(r.denial_reason_desc)}>
|
||||||
|
<span className="tnum font-semibold">{code}</span>{' '}
|
||||||
|
<span className="text-muted">{str(r.denial_reason_desc)}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-faint">—</span>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td>{status === 'denied' ? (APPEAL_LABEL[str(r.appeal_status)] ?? str(r.appeal_status)) : <span className="text-faint">—</span>}</Td>
|
||||||
|
<Td>{status === 'denied' ? shortDate(str(r.appeal_deadline).slice(0, 10) || null) : <span className="text-faint">—</span>}</Td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pages != null && pages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted">
|
||||||
|
<span className="tnum">
|
||||||
|
Page {page} of {pages.toLocaleString('en-US')}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex gap-1">
|
||||||
|
<PageBtn disabled={page <= 1} onClick={() => setPage(page - 1)} label="Previous page">
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</PageBtn>
|
||||||
|
<PageBtn disabled={page >= pages} onClick={() => setPage(page + 1)} label="Next page">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</PageBtn>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Th({ children, right }: { children: React.ReactNode; right?: boolean }) {
|
||||||
|
return <th className={`border-b border-line px-3 py-2.5 ${right ? 'text-right' : ''}`}>{children}</th>;
|
||||||
|
}
|
||||||
|
function Td({ children, right, mono }: { children: React.ReactNode; right?: boolean; mono?: boolean }) {
|
||||||
|
return <td className={`px-3 py-2 align-top text-ink ${right ? 'text-right' : ''} ${mono ? 'tnum' : ''}`}>{children}</td>;
|
||||||
|
}
|
||||||
|
function PageBtn({ children, disabled, onClick, label }: { children: React.ReactNode; disabled: boolean; onClick: () => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
className="rounded-md border border-line p-1 text-muted hover:bg-panel2 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user