- 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>
321 lines
16 KiB
TypeScript
321 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|