feat: the claims tile counts the lakehouse rows the link opens; live trace fills the screen
Claims denied showed Ava's affected_claims (225) while "See the claims" opened 237 rows — her quantify query and the link's filters are two different queries. The tile now asks the record view for the link's own filter set (limit 1, pagination.total_count) and shows that; her figure appears underneath only when it disagrees. The URL→filter mapping moves to lib/claimsLink so the table and the tile share one definition. While a review runs the trace is the page: the layout widens to the screen, the list takes the viewport height, follows the newest step unless the viewer scrolled up, and shows a working row with a spinner and an indeterminate strip under the header. Reduced motion honoured. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
fc1e9dc27c
commit
e775fb0e88
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Brain,
|
||||
@ -70,6 +70,7 @@ export function AvaLive({
|
||||
steps,
|
||||
decisions,
|
||||
working,
|
||||
fill = false,
|
||||
}: {
|
||||
steps: AvaTraceStep[];
|
||||
decisions: AvaDecision[];
|
||||
@ -77,8 +78,27 @@ export function AvaLive({
|
||||
* the "still working" footer, so an empty trace reads as "starting" rather
|
||||
* than "nothing happened". */
|
||||
working: boolean;
|
||||
/** While the review runs the trace is the page: let the list take the
|
||||
* viewport instead of a fixed 32rem box. */
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
// Follow the run: when a new step lands while she is working, keep the
|
||||
// newest step in view — unless the viewer has scrolled up to read.
|
||||
const listRef = useRef<HTMLOListElement | null>(null);
|
||||
const stuck = useRef(true);
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el || !working || !stuck.current) return;
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
}, [steps.length, working]);
|
||||
function onScroll() {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
stuck.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
||||
}
|
||||
const last = steps[steps.length - 1];
|
||||
const sinceLast = last ? Math.max(0, (Date.now() - new Date(last.ended_at ?? last.started_at).getTime()) / 1000) : 0;
|
||||
|
||||
const queries = steps.filter((s) => s.type === 'api');
|
||||
const failed = queries.filter((s) => s.query_state === 'FAILED').length;
|
||||
@ -112,6 +132,7 @@ export function AvaLive({
|
||||
{spend > 0 && <Badge tone="slate">{money(spend.toFixed(2))}</Badge>}
|
||||
</div>
|
||||
</header>
|
||||
{working && <div className="progress-strip" aria-hidden="true" />}
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<p className="flex items-center gap-2 px-4 py-8 text-sm text-faint">
|
||||
@ -121,7 +142,11 @@ export function AvaLive({
|
||||
: 'No steps recorded for this case.'}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="max-h-[32rem] overflow-y-auto px-4">
|
||||
<ol
|
||||
ref={listRef}
|
||||
onScroll={onScroll}
|
||||
className={`overflow-y-auto px-4 ${fill ? 'max-h-[calc(100vh-21rem)] min-h-[24rem]' : 'max-h-[32rem]'}`}
|
||||
>
|
||||
{steps.map((s, i) => {
|
||||
const m = meta(s.type);
|
||||
const Icon = m.icon;
|
||||
@ -196,6 +221,24 @@ export function AvaLive({
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{working && (
|
||||
<li className="flex items-center gap-3 py-3">
|
||||
<span className="flex shrink-0 flex-col items-center gap-1 pt-0.5">
|
||||
<span className="rounded-full bg-ai-soft p-1.5 text-ai ring-1 ring-ai-edge ring-inset">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</span>
|
||||
<span className="tnum text-[10px] text-faint">{steps.length + 1}</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-[13px] font-semibold text-ai">Working on the next step</span>
|
||||
<span className="mt-1 block text-xs text-muted">
|
||||
{last?.type === 'api' ? 'Reading the result and deciding what to query next' : 'Running'}
|
||||
{sinceLast >= 5 ? ` · ${Math.round(sinceLast)}s so far` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="working-dots" aria-hidden="true"><i /><i /><i /></span>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
|
||||
@ -3,7 +3,8 @@ import { Link } from 'react-router-dom';
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, ExternalLink, FileSearch } from 'lucide-react';
|
||||
import type { Slice } from '../api/charts';
|
||||
import type { ReviewedFacts } from '../format';
|
||||
import { affectedClaimsHref, recoverableClaimsHref } from '../lib/claimsLink';
|
||||
import { affectedClaimsHref, filtersFromHref, recoverableClaimsHref } from '../lib/claimsLink';
|
||||
import { useQuery, useZino } from '../api/provider';
|
||||
import type { LinkDates } from '../lib/claimsLink';
|
||||
import { Badge, Card, Facts } from './core';
|
||||
import type { AvaDecision, AvaTraceStep, Row } from '../api/types';
|
||||
@ -39,14 +40,27 @@ export function Verdict({
|
||||
dataSpan?: { from: string; to: string } | null;
|
||||
}) {
|
||||
const summary = str(row.summary);
|
||||
const affectedHref = affectedClaimsHref(kind, slice, dates);
|
||||
const recoverableHref = recoverableClaimsHref(kind, slice, dates);
|
||||
// THE COUNT COMES FROM THE DATA, NOT FROM AVA. She writes affected_claims
|
||||
// into her decision; the lakehouse is asked for the exact rows the "See the
|
||||
// claims" link opens, and that total is the number on the tile. Her figure
|
||||
// is shown underneath only when it disagrees, so a discrepancy is visible
|
||||
// rather than silently replaced.
|
||||
const live = useLiveCount(affectedHref);
|
||||
if (!summary) return null;
|
||||
const line = prose(headline(summary));
|
||||
const scanned = str(row.scanned_summary);
|
||||
const affectedHref = affectedClaimsHref(kind, slice, dates);
|
||||
const recoverableHref = recoverableClaimsHref(kind, slice, dates);
|
||||
const atRisk = num(row.amount_at_risk);
|
||||
const recoverable = num(row.recoverable_amount);
|
||||
const affected = num(row.affected_claims);
|
||||
const reported = num(row.affected_claims);
|
||||
const affected = live.count ?? reported;
|
||||
const countNote =
|
||||
live.count != null && reported != null && live.count !== reported
|
||||
? `AI analysis reported ${reported.toLocaleString('en-US')}; the lakehouse count is shown`
|
||||
: live.count != null
|
||||
? 'counted from the lakehouse'
|
||||
: undefined;
|
||||
const deadline = firstDeadline(row.recommendations, dates.anchor);
|
||||
const confidence = num(row.confidence);
|
||||
const reconciled = row.data_reconciled === true || str(row.data_reconciled) === 'true';
|
||||
@ -74,8 +88,9 @@ export function Verdict({
|
||||
<div className="grid gap-px bg-line sm:grid-cols-3">
|
||||
<Big
|
||||
label={kind === 'slow_payer' ? 'Claims unanswered' : kind === 'underpayment' ? 'Claims short-paid' : 'Claims denied'}
|
||||
value={affected == null ? '—' : affected.toLocaleString('en-US')}
|
||||
value={affected == null ? '—' : `${affected.toLocaleString('en-US')}${live.capped ? '+' : ''}`}
|
||||
href={affectedHref}
|
||||
note={countNote}
|
||||
/>
|
||||
<Big label="Money at stake" value={money(atRisk)} tone="bad" />
|
||||
<Big
|
||||
@ -399,6 +414,21 @@ export function Recheck({ row }: { row: Row }) {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The lakehouse's own count of the rows behind a claims link: one record-view
|
||||
* call with the link's filters and limit 1, reading pagination.total_count.
|
||||
* No link → nothing asked. */
|
||||
function useLiveCount(href: string | null): { count: number | null; capped: boolean } {
|
||||
const { client } = useZino();
|
||||
const q = useQuery(
|
||||
() => client.recordView('dni-rv-claims', { page: 1, limit: 1, filters: filtersFromHref(href ?? '') }),
|
||||
[href],
|
||||
!!href,
|
||||
);
|
||||
const pg = q.data?.pagination;
|
||||
const count = pg?.total_count ?? (q.data?.total as number | undefined) ?? null;
|
||||
return { count: typeof count === 'number' ? count : null, capped: !!pg?.total_is_capped };
|
||||
}
|
||||
|
||||
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 body = (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Slice } from '../api/charts';
|
||||
import type { RecordFilter } from '../api/types';
|
||||
import { addDays, isDenialKind } from '../api/charts';
|
||||
|
||||
/**
|
||||
@ -30,6 +31,47 @@ export type ClaimsQuery = Partial<Record<
|
||||
string
|
||||
>>;
|
||||
|
||||
/** URL param → record-view column. The claims table and the finding tiles
|
||||
* both read this, so "the rows the link opens" and "the number on the tile"
|
||||
* are the same query by construction. */
|
||||
export const CLAIM_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' },
|
||||
];
|
||||
export const CLAIM_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' },
|
||||
];
|
||||
|
||||
/** The record-view filters a /claims URL (or its query string) stands for. */
|
||||
export function filtersFromParams(params: URLSearchParams): RecordFilter[] {
|
||||
const f: RecordFilter[] = [];
|
||||
for (const d of CLAIM_DIMS) {
|
||||
const v = params.get(d.key);
|
||||
if (v) f.push({ field_key: d.col, value: v, data_type: 'string' });
|
||||
}
|
||||
for (const r of CLAIM_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;
|
||||
}
|
||||
|
||||
export function filtersFromHref(href: string): RecordFilter[] {
|
||||
const qs = href.includes('?') ? href.slice(href.indexOf('?') + 1) : '';
|
||||
return filtersFromParams(new URLSearchParams(qs));
|
||||
}
|
||||
|
||||
export function claimsHref(q: ClaimsQuery): string {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(q)) if (v) p.set(k, v);
|
||||
|
||||
@ -116,10 +116,14 @@ export function CaseScreen() {
|
||||
if (cases.error) return <ErrorNote>{cases.error}</ErrorNote>;
|
||||
if (!row) return <ErrorNote>Case {id} not found.</ErrorNote>;
|
||||
|
||||
const work = <AvaLive steps={trace.data ?? []} decisions={decisions.data ?? []} working={aiOwned} />;
|
||||
const running = aiOwned && !hasFindings;
|
||||
const work = <AvaLive steps={trace.data ?? []} decisions={decisions.data ?? []} working={aiOwned} fill={running} />;
|
||||
|
||||
// While the review runs there is nothing else on the page, so the trace is
|
||||
// given the width and height of the screen; once findings exist the page
|
||||
// narrows back to reading width.
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-4">
|
||||
<div className={`mx-auto space-y-4 ${running ? 'max-w-7xl' : 'max-w-5xl'}`}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link to="/q/review" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
|
||||
@ -7,6 +7,7 @@ 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';
|
||||
import { CLAIM_DIMS, CLAIM_RANGES, filtersFromParams } from '../lib/claimsLink';
|
||||
|
||||
/**
|
||||
* The claims table — the lakehouse rows themselves, read-only.
|
||||
@ -22,24 +23,8 @@ import { CARC, money, num, shortDate, str } from '../format';
|
||||
|
||||
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 DIMS = CLAIM_DIMS;
|
||||
const RANGES = CLAIM_RANGES;
|
||||
|
||||
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' };
|
||||
@ -53,19 +38,7 @@ export function ClaimsScreen() {
|
||||
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 filters = useMemo<RecordFilter[]>(() => filtersFromParams(params), [params]);
|
||||
const filtered = filters.length > 0 || !!search;
|
||||
|
||||
const key = params.toString();
|
||||
|
||||
@ -241,3 +241,35 @@ body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Review in progress: an indeterminate strip under the trace header, and
|
||||
three breathing dots on the "working" row. Both honour reduced motion. */
|
||||
@keyframes nl-slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
.progress-strip {
|
||||
position: relative;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--color-ai) 18%, transparent);
|
||||
}
|
||||
.progress-strip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 33%;
|
||||
background: var(--color-ai);
|
||||
animation: nl-slide 1.8s ease-in-out infinite;
|
||||
}
|
||||
.working-dots { display: inline-flex; gap: 4px; align-items: center; }
|
||||
.working-dots i {
|
||||
width: 6px; height: 6px; border-radius: 9999px;
|
||||
background: var(--color-ai);
|
||||
animation: nl-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.working-dots i:nth-child(2) { animation-delay: 0.2s; }
|
||||
.working-dots i:nth-child(3) { animation-delay: 0.4s; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.progress-strip::after, .working-dots i { animation: none; }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user