From e8741f633343dffa8a1491995b955693e89b3fb8 Mon Sep 17 00:00:00 2001 From: Yashas Date: Wed, 16 Sep 2026 18:45:18 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20claims=20explorer=20=E2=80=94=20the=20l?= =?UTF-8?q?akehouse=20rows=20behind=20every=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- src/App.tsx | 2 + src/api/charts.ts | 19 +++ src/api/client.ts | 6 +- src/api/types.ts | 12 +- src/components/Findings.tsx | 54 +++++++- src/layout/Shell.tsx | 24 +++- src/lib/claimsLink.ts | 62 +++++++++ src/screens/CaseScreen.tsx | 4 +- src/screens/ClaimsScreen.tsx | 251 +++++++++++++++++++++++++++++++++++ 9 files changed, 421 insertions(+), 13 deletions(-) create mode 100644 src/lib/claimsLink.ts create mode 100644 src/screens/ClaimsScreen.tsx diff --git a/src/App.tsx b/src/App.tsx index f3f929b..0920d1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { Login } from './pages/Login'; import { QueueScreen } from './screens/QueueScreen'; import { CaseScreen } from './screens/CaseScreen'; import { RunReviewScreen } from './screens/RunReviewScreen'; +import { ClaimsScreen } from './screens/ClaimsScreen'; /** * Routes. @@ -25,6 +26,7 @@ export function App() { } /> } /> } /> + } /> {/* 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. */} } /> diff --git a/src/api/charts.ts b/src/api/charts.ts index 73fc236..8334590 100644 --- a/src/api/charts.ts +++ b/src/api/charts.ts @@ -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. */ export const RECOVERY_ORDER = ['recovered', 'appeal filed', 'still appealable', 'lost on appeal', 'window closed']; diff --git a/src/api/client.ts b/src/api/client.ts index 4c1f742..4009e37 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -141,9 +141,11 @@ export class ZinoClient { search: params.search ?? '', filters: (params.filters ?? []).map((f) => ({ field_key: f.field_key, - value: f.value, + value: f.value ?? '', 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'), })), }, }); diff --git a/src/api/types.ts b/src/api/types.ts index fa2e787..3c84ffb 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -45,13 +45,23 @@ export interface RecordViewResponse { /** A record-view row. Values are whatever the column's data_type implies. */ export type Row = Record; +/** 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 { page?: number; limit?: number; sortBy?: string; sortDir?: 'asc' | 'desc'; search?: string; - filters?: Array<{ field_key: string; value: string; data_type?: string }>; + filters?: RecordFilter[]; } export interface DetailViewResponse { diff --git a/src/components/Findings.tsx b/src/components/Findings.tsx index 97f7dd0..9b8d3ee 100644 --- a/src/components/Findings.tsx +++ b/src/components/Findings.tsx @@ -1,5 +1,8 @@ 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 type { AvaDecision, AvaTraceStep, Row } from '../api/types'; import { @@ -30,10 +33,23 @@ import { */ /** 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); if (!summary) return null; 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 recoverable = num(row.recoverable_amount); const affected = num(row.affected_claims); @@ -58,15 +74,26 @@ export function Verdict({ row }: { row: Row }) { )} AI analysis · Ava + {scanned && ( +

+ What Ava reviewed. + {scanned} +

+ )}
- + + const body = ( + <>

{label}

{value}

{note &&

{note}

} -
+ {href && ( +

+ See the claims +

+ )} + + ); + // A number you can click opens the rows behind it in the claims table. + return href ? ( + + {body} + + ) : ( +
{body}
); } diff --git a/src/layout/Shell.tsx b/src/layout/Shell.tsx index 8adfde0..eedc154 100644 --- a/src/layout/Shell.tsx +++ b/src/layout/Shell.tsx @@ -1,5 +1,5 @@ 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 { QUEUES, prettyRole } from '../api/config'; import { NorthlakeLockup } from '../components/NorthlakeLogo'; @@ -90,6 +90,28 @@ export function Shell() { ))} + {/* The data itself, after the pipeline: every claim in the lakehouse. */} +
  • + + `block rounded-md px-3 py-2 transition-colors ${ + isActive ? 'bg-brand text-brand-fg' : 'text-muted hover:bg-panel2 hover:text-ink' + }` + } + > + {({ isActive }) => ( + <> + + Claims + + + The lakehouse, row by row + + + )} + +
  • diff --git a/src/lib/claimsLink.ts b/src/lib/claimsLink.ts new file mode 100644 index 0000000..98816c1 --- /dev/null +++ b/src/lib/claimsLink.ts @@ -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>; + +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, + }); +} diff --git a/src/screens/CaseScreen.tsx b/src/screens/CaseScreen.tsx index 4a8bf3d..ce9d323 100644 --- a/src/screens/CaseScreen.tsx +++ b/src/screens/CaseScreen.tsx @@ -17,7 +17,7 @@ import { ActivityForm } from '../components/ActivityForm'; import { AvaLive } from '../components/AvaLive'; import { Disclosure, FullStory, Recheck, Recommendations, SqlEvidence, Verdict } from '../components/Findings'; 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 { dateTime, num, str } from '../format'; @@ -171,7 +171,7 @@ export function CaseScreen() { {/* ---------- The findings: what happened, how much, what to approve ---------- */} {hasFindings && ( <> - + {briefing.data ? ( ) : ( diff --git a/src/screens/ClaimsScreen.tsx b/src/screens/ClaimsScreen.tsx new file mode 100644 index 0000000..1149163 --- /dev/null +++ b/src/screens/ClaimsScreen.tsx @@ -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 = { + paid: 'emerald', + partial: 'amber', + denied: 'rose', + pending: 'slate', +}; +const APPEAL_LABEL: Record = { + 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(() => { + 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 ( +
    +
    +
    +

    Claims

    +

    + + Read-only · {LAKEHOUSE.platform} · {LAKEHOUSE.catalog}.{LAKEHOUSE.schema}.claims +

    +
    +

    + {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` : ''} +

    +
    + + {chips.length > 0 && ( +
    + {chips.map((c) => ( + + ))} + + Clear all + +
    + )} + + {q.error && {q.error}} + +
    + {q.loading && !q.data ? ( + + ) : rows.length === 0 ? ( +

    No claims match these filters.

    + ) : ( +
    + + + + + + + + + + + + + + + + + + {rows.map((r) => { + const status = str(r.status); + const code = str(r.denial_reason_code); + return ( + + + + + + + + + + + + + + ); + })} + +
    ClaimServiceInsurerDepartmentProviderProcedureBilledStatusReasonAppealDeadline
    {str(r.claim_id)}{shortDate(str(r.service_date).slice(0, 10) || null)}{str(r.payer)}{str(r.department)}{str(r.provider)} + {str(r.procedure_code)} {str(r.procedure_desc)} + {money(r.billed_amount)} + {status} + + {code ? ( + + {code}{' '} + {str(r.denial_reason_desc)} + + ) : ( + — + )} + {status === 'denied' ? (APPEAL_LABEL[str(r.appeal_status)] ?? str(r.appeal_status)) : —}{status === 'denied' ? shortDate(str(r.appeal_deadline).slice(0, 10) || null) : —}
    +
    + )} + {pages != null && pages > 1 && ( +
    + + Page {page} of {pages.toLocaleString('en-US')} + + + setPage(page - 1)} label="Previous page"> + + + = pages} onClick={() => setPage(page + 1)} label="Next page"> + + + +
    + )} +
    +
    + ); +} + +function Th({ children, right }: { children: React.ReactNode; right?: boolean }) { + return {children}; +} +function Td({ children, right, mono }: { children: React.ReactNode; right?: boolean; mono?: boolean }) { + return {children}; +} +function PageBtn({ children, disabled, onClick, label }: { children: React.ReactNode; disabled: boolean; onClick: () => void; label: string }) { + return ( + + ); +}