From 1c81912b1748fccdae41f6cd0e97011720ac5435 Mon Sep 17 00:00:00 2001 From: Yashas Date: Wed, 16 Sep 2026 19:00:10 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Lakehouse=20=E2=80=94=20every=20table?= =?UTF-8?q?=20Ava=20reads,=20with=20counts;=20claims=20gets=20total,=20sea?= =?UTF-8?q?rch=20and=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /lakehouse: one card per Databricks table from the lakehouse_tables stats view (rows, date span, dollars); reference tables open a plain row view - /claims: exact total (stats view when unfiltered; platform count, capped at 10,000, when filtered), search on claim id / provider / CPT, dropdown filters fed by the API's distinct values, service-date range - sidebar: 'Lakehouse' replaces 'Claims'; claims pages light it too - pairs with 13_lakehouse.sql (rdbms templates + record views) Co-Authored-By: Claude Fable 5.1 --- src/App.tsx | 3 + src/api/types.ts | 8 +- src/layout/Shell.tsx | 29 +++-- src/screens/ClaimsScreen.tsx | 201 +++++++++++++++++++++----------- src/screens/LakehouseScreen.tsx | 161 +++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 86 deletions(-) create mode 100644 src/screens/LakehouseScreen.tsx diff --git a/src/App.tsx b/src/App.tsx index 0920d1e..5597629 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { QueueScreen } from './screens/QueueScreen'; import { CaseScreen } from './screens/CaseScreen'; import { RunReviewScreen } from './screens/RunReviewScreen'; import { ClaimsScreen } from './screens/ClaimsScreen'; +import { LakehouseScreen, LakehouseTableScreen } from './screens/LakehouseScreen'; /** * Routes. @@ -27,6 +28,8 @@ 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/types.ts b/src/api/types.ts index 3c84ffb..901e678 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -34,12 +34,18 @@ export interface RecordViewField { } export interface RecordViewResponse { - config: { fields: RecordViewField[] }; + config: { + fields: RecordViewField[]; + /** Distinct values per `is_filter` field, narrowed by the active filters. + * Date fields come back as timestamps; the console ignores those. */ + filter_options?: Record; + }; data?: Row[]; records?: Row[]; total?: number; page?: number; limit?: number; + pagination?: { page: number; limit: number; total_count: number; total_pages: number; total_is_capped?: boolean }; } /** A record-view row. Values are whatever the column's data_type implies. */ diff --git a/src/layout/Shell.tsx b/src/layout/Shell.tsx index eedc154..b1fb4c5 100644 --- a/src/layout/Shell.tsx +++ b/src/layout/Shell.tsx @@ -1,4 +1,4 @@ -import { NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Database, LogOut, Radar } from 'lucide-react'; import { useZino } from '../api/provider'; import { QUEUES, prettyRole } from '../api/config'; @@ -19,6 +19,8 @@ import { Button } from '../components/core'; * it is a worse failure than showing a button that comes back "not permitted". */ export function Shell() { + const { pathname } = useLocation(); + const onLakehouse = pathname.startsWith('/lakehouse') || pathname.startsWith('/claims'); const { user, logout } = useZino(); const navigate = useNavigate(); @@ -90,26 +92,23 @@ export function Shell() { ))} - {/* The data itself, after the pipeline: every claim in the lakehouse. */} + {/* The data itself, after the pipeline: every table Ava reads. Claims + pages live under it, so they light this entry too. */}
  • + to="/lakehouse" + className={() => `block rounded-md px-3 py-2 transition-colors ${ - isActive ? 'bg-brand text-brand-fg' : 'text-muted hover:bg-panel2 hover:text-ink' + onLakehouse ? 'bg-brand text-brand-fg' : 'text-muted hover:bg-panel2 hover:text-ink' }` } > - {({ isActive }) => ( - <> - - Claims - - - The lakehouse, row by row - - - )} + + Lakehouse + + + Every table Ava reads +
  • diff --git a/src/screens/ClaimsScreen.tsx b/src/screens/ClaimsScreen.tsx index 1149163..8e7cccb 100644 --- a/src/screens/ClaimsScreen.tsx +++ b/src/screens/ClaimsScreen.tsx @@ -1,37 +1,39 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { ChevronLeft, ChevronRight, Database, X } from 'lucide-react'; +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, Spinner } from '../components/core'; +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 two ways: from the sidebar (unfiltered, newest first) and from a - * finding's numbers ("216 claims" → the exact rows Ava counted). The URL IS + * 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 and the total come from - * /view/recordview against the Databricks-backed record view. + * 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, with the control shape it compiles to. */ +/** 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: 'code', col: 'denial_reason_code', label: 'Reason code' }, - { key: 'category', col: 'denial_category', label: 'Denial category' }, { 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' }, @@ -39,39 +41,17 @@ const RANGES: Array<{ from: string; to: string; col: string; label: string }> = { 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', -}; +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 search = params.get('q') ?? ''; + const [draft, setDraft] = useState(search); + useEffect(() => setDraft(search), [search]); const filters = useMemo(() => { const f: RecordFilter[] = []; @@ -86,68 +66,149 @@ export function ClaimsScreen() { } return f; }, [params]); + const filtered = filters.length > 0 || !!search; const key = params.toString(); const q = useQuery( - () => client.recordView('dni-rv-claims', { page, limit: PAGE, filters, sortBy: 'service_date', sortDir: 'desc' }), + () => client.recordView('dni-rv-claims', { page, limit: PAGE, filters, search, 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; + // 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 drop(keys: string[]) { + function set(k: string, v: string) { const next = new URLSearchParams(params); - for (const k of keys) next.delete(k); - next.delete('page'); + 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)); - go(next); + navigate(`/claims?${next}`); } + const opts = (col: string, labels?: Record) => + ((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 (
    + + Lakehouse +

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

    +
    +

    + {total == null ? '—' : `${total.toLocaleString('en-US')}${capped ? '+' : ''}`} +

    +

    + {filtered ? 'claims match' : 'claims in the table'} + {rows.length > 0 ? ` · ${money(billedOnPage)} billed on this page` : ''} +

    +
    - {chips.length > 0 && ( -
    - {chips.map((c) => ( - - ))} - - Clear all - + {/* ---------- Search + filters. Options are the platform's own distinct values. ---------- */} +
    +
    { + e.preventDefault(); + set('q', draft.trim()); + }} + > +
    + +
    + +
    +
    + +
    +
    + set('department', v)} options={opts('department')} placeholder="All departments" /> + 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" + /> + set('site', v)} options={opts('site')} placeholder="All sites" /> + set('provider', v)} options={opts('provider')} placeholder="All providers" /> +
    + Service + set('from', v)} /> +
    +
    + to + set('to', v)} /> +
    - )} + {chips.length > 0 && ( +
    + {chips.map((c) => ( + + ))} + + Clear all + +
    + )} +
    {q.error && {q.error}} @@ -195,8 +256,7 @@ export function ClaimsScreen() { {code ? ( - {code}{' '} - {str(r.denial_reason_desc)} + {code} {str(r.denial_reason_desc)} ) : ( — @@ -214,7 +274,8 @@ export function ClaimsScreen() { {pages != null && pages > 1 && (
    - Page {page} of {pages.toLocaleString('en-US')} + Page {page.toLocaleString('en-US')} of {pages.toLocaleString('en-US')} + {capped ? ' (count capped by the platform at 10,000)' : ''} setPage(page - 1)} label="Previous page"> diff --git a/src/screens/LakehouseScreen.tsx b/src/screens/LakehouseScreen.tsx new file mode 100644 index 0000000..4b5eb4a --- /dev/null +++ b/src/screens/LakehouseScreen.tsx @@ -0,0 +1,161 @@ +import { Link, useParams } from 'react-router-dom'; +import { ArrowLeft, ArrowRight, Database } from 'lucide-react'; +import { useQuery, useZino } from '../api/provider'; +import { LAKEHOUSE } from '../api/config'; +import type { Row } from '../api/types'; +import { ErrorNote, Spinner } from '../components/core'; +import { money, num, pct, shortDate, str } from '../format'; + +/** + * Everything Ava reads, in one place. + * + * The overview is the `lakehouse_tables` stats view: one card per table with + * its row count and date span, straight from Databricks. `claims` opens the + * claims explorer; the four reference tables open a plain table view of every + * row (they are small — 4 to 24 rows). + */ + +const TABLES: Record = { + claims: { rv: 'dni-rv-claims', title: 'Claims', blurb: 'One row per submitted insurance claim. The table Ava sweeps every morning.' }, + payers: { rv: 'dni-rv-payers', title: 'Insurers', blurb: 'Plan type, appeal window and the Northlake contract manager for each insurer.' }, + departments: { rv: 'dni-rv-departments', title: 'Departments', blurb: 'Department × site directory with each director.' }, + payer_policy_updates: { rv: 'dni-rv-policy-updates', title: 'Insurer bulletins', blurb: 'Policy changes insurers announced, with effective dates — where Ava looks for the cause of a spike.' }, + finance_monthly_summary: { rv: 'dni-rv-finance-monthly', title: 'Finance monthly summary', blurb: 'What finance reports each month. Ava reconciles the claims table against it before analysing anything.' }, +}; +const ORDER = ['claims', 'payers', 'departments', 'payer_policy_updates', 'finance_monthly_summary']; + +export function LakehouseScreen() { + const { client } = useZino(); + const stats = useQuery(() => client.rows('dni-rv-lakehouse-tables', { limit: 20 }), []); + const byName = new Map((stats.data ?? []).map((r) => [str(r.table_name), r])); + + return ( +
    +
    +

    Lakehouse

    +

    + + {LAKEHOUSE.platform} · {LAKEHOUSE.catalog}.{LAKEHOUSE.schema} · read-only. The tables Ava reads, exactly as she reads them. +

    +
    + {stats.error && {stats.error}} + {stats.loading && !stats.data ? ( + + ) : ( +
    + {ORDER.map((name) => { + const t = TABLES[name]; + const s = byName.get(name); + const rows = num(s?.row_count); + const from = str(s?.from_date).slice(0, 10); + const to = str(s?.to_date).slice(0, 10); + const billed = num(s?.billed); + const to_ = name === 'claims' ? '/claims' : `/lakehouse/${name}`; + return ( + +
    +
    +

    {name}

    +

    {t.title}

    +

    {t.blurb}

    +
    +
    +

    {rows == null ? '—' : rows.toLocaleString('en-US')}

    +

    rows

    +
    +
    +
    + {from && to && ( + + {shortDate(from)} – {shortDate(to)} + + )} + {billed != null && billed > 0 && {money(billed)} billed} + {name === 'claims' && num(s?.denied) != null && ( + + {num(s?.denied)!.toLocaleString('en-US')} denied · {num(s?.pending)?.toLocaleString('en-US')} pending + + )} + + {name === 'claims' ? 'Explore' : 'Open'} + +
    + + ); + })} +
    + )} +
    + ); +} + +/** Every row of one small reference table. */ +export function LakehouseTableScreen() { + const { table = '' } = useParams(); + const { client } = useZino(); + const t = TABLES[table]; + const q = useQuery(() => client.recordView(t.rv, { limit: 200 }), [table], !!t); + if (!t) return No such table.; + const rows: Row[] = q.data?.data ?? q.data?.records ?? []; + const fields = (q.data?.config?.fields ?? []).filter((f) => f.field_key !== '_pk_value'); + + return ( +
    +
    + + Lakehouse + +

    {t.title}

    +

    + + {LAKEHOUSE.catalog}.{LAKEHOUSE.schema}.{table} · {rows.length.toLocaleString('en-US')} rows +

    +
    + {q.error && {q.error}} +
    + {q.loading && !q.data ? ( + + ) : ( +
    + + + + {fields.map((f) => ( + + ))} + + + + {rows.map((r, i) => ( + + {fields.map((f) => ( + + ))} + + ))} + +
    + {f.output_label} +
    + {cell(r[f.field_key], f.field_key, f.data_type)} +
    +
    + )} +
    +
    + ); +} + +const isNumeric = (t: string) => /int|decimal|number|bigint/i.test(t); +function cell(v: unknown, key: string, type: string): string { + if (v == null || v === '') return '—'; + if (type === 'date') return shortDate(str(v).slice(0, 10)); + if (/rate/.test(key)) return pct((num(v) ?? 0) * 100, 1); + if (/amount|billed|paid/.test(key)) return money(v); + if (isNumeric(type)) return (num(v) ?? 0).toLocaleString('en-US'); + return str(v); +}