northlake-health/src/screens/ClaimsScreen.tsx
Yashas 91002b11c4 fix: deep links reproduce Ava's counts — slice identity, window ceiling, cohort dates
- slice now carries site / desk / provider (14_slice_dims.sql), so a front-desk
  or provider finding opens the right rows, not every CO-27 in the hospital
- every link is capped at the review's ceiling date (review_to / as-of); a
  custom-window run no longer opens claims Ava never looked at
- slow-payer link filters to claims SENT 31–60 days before the latest submitted
  date (from the stats view), matching her 1,352 instead of all 2,753 pending
- 'first deadline' ignores dates before the review (it was picking the spike week)
- header says 'Custom run' when dates were chosen; page changes scroll to top
  and dim the table while loading

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 11:39:52 +05:30

321 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowLeft, ChevronLeft, ChevronRight, Database, Search, 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, Input, Select, Spinner } from '../components/core';
import { CARC, money, num, shortDate, str } from '../format';
/**
* The claims table — the lakehouse rows themselves, read-only.
*
* Reached three ways: from the Lakehouse overview, from the sidebar, 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, search and the total
* come from /view/recordview against the Databricks-backed record view, and
* the dropdown options are the distinct values the platform returns for the
* current filter set.
*/
const PAGE = 50;
/** URL param → record-view column. */
const DIMS: Array<{ key: string; col: string; label: string }> = [
{ key: 'payer', col: 'payer', label: 'Insurer' },
{ key: 'department', col: 'department', label: 'Department' },
{ key: 'status', col: 'status', label: 'Status' },
{ key: 'code', col: 'denial_reason_code', label: 'Reason code' },
{ 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' },
{ key: 'category', col: 'denial_category', label: 'Denial category' },
];
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);
const search = params.get('q') ?? '';
const [draft, setDraft] = useState(search);
useEffect(() => setDraft(search), [search]);
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 filtered = filters.length > 0 || !!search;
const key = params.toString();
// ORDER BY A UNIQUE COLUMN. Each page is its own query against Spark, and
// `service_date` has thousands of ties — with a non-unique sort key the
// engine is free to order tied rows differently per execution, so page 2
// repeated rows from page 1. `claim_id` is unique and is assigned in date
// order, so descending is still newest-first, and every page is disjoint.
const q = useQuery(
() => client.recordView('dni-rv-claims', { page, limit: PAGE, filters, search, sortBy: 'claim_id', sortDir: 'desc' }),
[key],
);
// The platform caps its count at 10,000; the stats view has the exact
// table size, which is the number to show when nothing is filtered.
const stats = useQuery(() => client.rows('dni-rv-lakehouse-tables', { limit: 10 }), [], !filtered);
const exact = stats.data?.find((r) => str(r.table_name) === 'claims');
const rows: Row[] = q.data?.data ?? q.data?.records ?? [];
const pg = q.data?.pagination;
const total = !filtered && exact ? (num(exact.row_count) ?? null) : (pg?.total_count ?? q.data?.total ?? null);
const capped = filtered && !!pg?.total_is_capped;
const pages = total != null ? Math.max(1, Math.ceil(total / PAGE)) : (pg?.total_pages ?? null);
const options = q.data?.config?.filter_options ?? {};
const billedOnPage = rows.reduce((t, r) => t + (num(r.billed_amount) ?? 0), 0);
function go(next: URLSearchParams) {
next.delete('page');
navigate(`/claims${next.toString() ? `?${next}` : ''}`);
}
function set(k: string, v: string) {
const next = new URLSearchParams(params);
if (v) next.set(k, v);
else next.delete(k);
go(next);
}
function setPage(n: number) {
const next = new URLSearchParams(params);
if (n <= 1) next.delete('page');
else next.set('page', String(n));
navigate(`/claims?${next}`);
// The pager sits under the table; without this the new page loads with the
// viewport still at the bottom, which reads as "nothing changed".
window.scrollTo({ top: 0, behavior: 'smooth' });
}
const opts = (col: string, labels?: Record<string, string>) =>
((options[col] ?? []) as unknown[]).map(String).filter(Boolean).map((v) => ({ value: v, label: labels?.[v] ?? v }));
// Active filters as removable chips, in display order.
const chips: Array<{ label: string; value: string; remove: string[] }> = [];
for (const d of DIMS) {
const v = params.get(d.key);
if (v) chips.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) chips.push({ label: r.label, value: a && b ? `${shortDate(a)} – ${shortDate(b)}` : a ? `from ${shortDate(a)}` : `to ${shortDate(b)}`, remove: [r.from, r.to] });
}
if (search) chips.push({ label: 'Search', value: search, remove: ['q'] });
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">
<Link to="/lakehouse" className="inline-flex items-center gap-1 text-xs text-muted hover:text-ink">
<ArrowLeft className="h-3.5 w-3.5" /> Lakehouse
</Link>
<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>
<div className="text-right">
<p className="tnum text-[22px] leading-none font-bold text-ink">
{total == null ? '—' : `${total.toLocaleString('en-US')}${capped ? '+' : ''}`}
</p>
<p className="mt-1 text-xs text-muted">
{filtered ? 'claims match' : 'claims in the table'}
{rows.length > 0 ? ` · ${money(billedOnPage)} billed on this page` : ''}
</p>
</div>
</div>
{/* ---------- Search + filters. Options are the platform's own distinct values. ---------- */}
<div className="lift rounded-xl border border-line bg-panel px-4 py-3">
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
set('q', draft.trim());
}}
>
<div className="relative min-w-[16rem] flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-faint" />
<div className="[&_input]:pl-8">
<Input value={draft} onChange={setDraft} placeholder="Search claim id, provider or CPT…" />
</div>
</div>
<button type="submit" className="rounded-md border border-line px-3 py-2 text-sm text-ink hover:bg-panel2">
Search
</button>
</form>
<div className="mt-3 grid gap-2 sm:grid-cols-3 lg:grid-cols-5">
<Select value={params.get('payer') ?? ''} onChange={(v) => set('payer', v)} options={opts('payer')} placeholder="All insurers" />
<Select value={params.get('department') ?? ''} onChange={(v) => set('department', v)} options={opts('department')} placeholder="All departments" />
<Select value={params.get('status') ?? ''} onChange={(v) => set('status', v)} options={opts('status')} placeholder="Any status" />
<Select
value={params.get('code') ?? ''}
onChange={(v) => set('code', v)}
options={opts('denial_reason_code').map((o) => ({ ...o, label: CARC[o.value] ? `${o.value} · ${CARC[o.value]}` : o.value }))}
placeholder="Any reason code"
/>
<Select value={params.get('appeal') ?? ''} onChange={(v) => set('appeal', v)} options={opts('appeal_status', APPEAL_LABEL)} placeholder="Any appeal status" />
<Select value={params.get('site') ?? ''} onChange={(v) => set('site', v)} options={opts('site')} placeholder="All sites" />
<Select value={params.get('desk') ?? ''} onChange={(v) => set('desk', v)} options={opts('registration_desk')} placeholder="All desks" />
<Select value={params.get('provider') ?? ''} onChange={(v) => set('provider', v)} options={opts('provider')} placeholder="All providers" />
<div className="flex items-center gap-1.5">
<span className="shrink-0 text-[11px] text-faint">Service</span>
<Input type="date" value={params.get('from') ?? ''} onChange={(v) => set('from', v)} />
</div>
<div className="flex items-center gap-1.5">
<span className="shrink-0 text-[11px] text-faint">to</span>
<Input type="date" value={params.get('to') ?? ''} onChange={(v) => set('to', v)} />
</div>
</div>
{chips.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-line pt-3">
{chips.map((c) => (
<button
key={c.label}
type="button"
onClick={() => {
const next = new URLSearchParams(params);
for (const k of c.remove) next.delete(k);
go(next);
}}
title="Remove filter"
className="inline-flex items-center gap-1.5 rounded-full border border-line bg-panel2/60 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>
)}
</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 transition-opacity ${q.loading ? 'opacity-50' : ''}`} aria-busy={q.loading}>
<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.toLocaleString('en-US')} of {pages.toLocaleString('en-US')}
{capped ? ' (count capped by the platform at 10,000)' : ''}
</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>
);
}