northlake-health/src/components/Findings.tsx
Yashas 4087d2c2e0 fix: dates in Ava's prose read as 'Jul 26, 2026', not 2026-07-26
Display-only rewrite (format.prose) on every surface that shows her text —
headline, work statement, full story, recommendations, re-check, queue cards,
slides, notes. Stored text keeps ISO dates so deadline and spike parsers still
read them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:17:08 +05:30

336 lines
14 KiB
TypeScript

import { useState } from '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 { firstDeadline, headline, money, num, pct, prose, recommendations, shortDate, splitRecommendation, sqlSteps, str } from '../format';
/**
* Ava's findings, laid out for a person who has ninety seconds.
*
* THREE QUESTIONS, THREE SIZES. What happened — one sentence, large. How much
* — three numbers you can read from across a table. What to do — the ranked
* list, with the decision buttons rendered right under it by the case screen.
* Everything a reviewer needs to act is above the fold; everything an analyst
* would want to check is behind "the full story" and "her evidence".
*
* EVERY VALUE IS HERS. Nothing here is computed by the frontend — each field is
* read off the instance as she submitted it. Violet marks what an agent
* produced; nothing else is violet.
*/
/** The headline card: one sentence, and the three numbers. */
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 = prose(headline(summary));
const scanned = prose(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);
const deadline = firstDeadline(row.recommendations);
const confidence = num(row.confidence);
const reconciled = row.data_reconciled === true || str(row.data_reconciled) === 'true';
return (
<section className="lift rounded-xl border border-ai-edge bg-panel">
<div className="border-b border-ai-edge bg-ai-soft px-5 py-4">
<p className="text-[11px] font-semibold tracking-wide text-ai uppercase">Summary</p>
<p className="mt-1.5 text-[21px] leading-snug font-semibold text-ink">{line}</p>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
<span className="inline-flex items-center gap-1">
<CheckCircle2 className={`h-3.5 w-3.5 ${reconciled ? 'text-good' : 'text-warn'}`} />
{reconciled ? 'Reconciled with finance summary' : 'Not reconciled with finance summary'}
</span>
{confidence != null && (
<span>
Confidence <span className="font-semibold text-ink">{pct(confidence * 100, 0)}</span>
</span>
)}
<span className="ml-auto">AI analysis · Ava</span>
</div>
{scanned && (
<p className="mt-3 border-t border-ai-edge/60 pt-3 text-[13px] leading-relaxed text-muted">
<span className="font-semibold text-ink">What Ava reviewed. </span>
{scanned}
</p>
)}
</div>
<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')}
href={affectedHref}
/>
<Big label="Money at stake" value={money(atRisk)} tone="bad" />
<Big
label="Still recoverable"
value={money(recoverable)}
tone="good"
href={recoverableHref}
note={
deadline
? `if appeals are filed — first deadline ${shortDate(deadline)}`
: atRisk && recoverable
? `${Math.round((recoverable / atRisk) * 100)}% of what is at stake`
: undefined
}
/>
</div>
</section>
);
}
/** The decision list. Rendered directly above the Approve / Send back / Reject buttons. */
export function Recommendations({ row }: { row: Row }) {
const recs = recommendations(row.recommendations).map(prose);
if (recs.length === 0) return null;
return (
<section className="lift rounded-xl border border-line bg-panel">
<header className="border-b border-line px-5 py-3.5">
<h2 className="text-base font-semibold text-ink">Recommended actions</h2>
<p className="mt-0.5 text-xs text-muted">Ranked by financial impact. Each names an owner.</p>
</header>
<ol className="divide-y divide-line">
{recs.map((block, i) => {
const { title, body } = splitRecommendation(block);
return (
<li key={i} className="flex items-start gap-4 px-5 py-4">
<span className="tnum mt-0.5 shrink-0 rounded-full bg-brand px-2.5 py-0.5 text-xs font-bold text-brand-fg">
{i + 1}
</span>
<div className="min-w-0">
{title && <p className="text-[15px] font-semibold text-ink">{sentenceCase(title)}</p>}
<p className="mt-1 text-sm leading-relaxed whitespace-pre-wrap text-muted">{body}</p>
</div>
</li>
);
})}
</ol>
</section>
);
}
/** The paragraph version — for the reader who wants the whole account. Collapsed. */
export function FullStory({ row }: { row: Row }) {
const [open, setOpen] = useState(false);
const summary = prose(row.summary);
const cause = prose(row.root_cause);
const secondary = prose(row.secondary_findings);
if (!summary) return null;
const hasSecondary = secondary && !/^none found/i.test(secondary.trim());
return (
<Disclosure
open={open}
onToggle={() => setOpen(!open)}
icon={<FileSearch className="h-4 w-4 text-ai" />}
title="Analysis"
hint={hasSecondary ? 'Full analysis, root cause, and a secondary finding' : 'Full analysis and root cause'}
tone="ai"
>
<div className="space-y-4 px-5 py-4">
<p className="text-sm leading-relaxed whitespace-pre-wrap text-ink">{summary}</p>
{cause && (
<div className="rounded-md border border-line bg-panel2 px-4 py-3">
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">Root cause</p>
<p className="mt-1 text-sm leading-relaxed whitespace-pre-wrap text-ink">{cause}</p>
</div>
)}
{hasSecondary && (
<div className="rounded-md border border-warn-edge bg-warn-soft px-4 py-3">
<p className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide text-warn uppercase">
<AlertTriangle className="h-3.5 w-3.5" /> Secondary finding
</p>
<p className="mt-1 text-sm leading-relaxed whitespace-pre-wrap text-ink">{secondary}</p>
</div>
)}
</div>
</Disclosure>
);
}
/**
* Every SQL statement she ran. For the sceptic in the room. Collapsed.
*
* SOURCE OF TRUTH IS THE TRACE, not her sql_log field. The platform records
* every statement the agentic tool sent, verbatim, with Databricks' own
* SUCCEEDED/FAILED and the row count — so that is what is shown. Her sql_log
* is now a one-line-per-step index ("3c: department cut -> Cardiology +13
* pts") and is used only as the note beside each statement. Asking her to
* paste 16 full queries into a form field was what blew the tool call past
* the output cap and lost a whole investigation.
*/
export function SqlEvidence({ row, steps, decisions }: { row: Row; steps: AvaTraceStep[]; decisions: AvaDecision[] }) {
const [open, setOpen] = useState(false);
// Only the attempt that produced the findings. A case can carry an earlier
// attempt that failed to submit (its queries are still in the trace); showing
// both would double the count and make her look twice as busy as she was.
const submitted = decisions.filter((d) => d.status === 'submitted').slice(-1)[0];
const scoped = submitted ? steps.filter((s) => s.decision_id == null || String(s.decision_id) === String(submitted.id)) : steps;
const queries = scoped.filter((s) => s.type === 'api' && s.detail);
const notes = sqlSteps(row.sql_log);
if (queries.length === 0 && notes.length === 0) return null;
return (
<Disclosure
open={open}
onToggle={() => setOpen(!open)}
icon={<CheckCircle2 className="h-4 w-4 text-good" />}
title="Query log"
hint={
queries.length
? `${queries.length} queries executed against Databricks — reproducible`
: `${notes.length} analysis steps`
}
>
{queries.length > 0 ? (
<ol className="divide-y divide-line">
{queries.map((q, i) => (
<li key={`${q.seq}-${i}`} className="px-5 py-3">
<p className="flex items-center gap-2 text-[11px] font-semibold tracking-wide text-faint uppercase">
Query {i + 1}
{q.query_state === 'FAILED' ? (
<Badge tone="amber">error — corrected in next query</Badge>
) : q.rows_returned != null ? (
<span className="tnum font-normal normal-case">{q.rows_returned} row{q.rows_returned === 1 ? '' : 's'}</span>
) : null}
</p>
<pre className="sql mt-1 text-muted">{q.detail}</pre>
{notes[i]?.note && <p className="mt-1.5 text-xs text-ink">{notes[i].note}</p>}
</li>
))}
</ol>
) : (
<ol className="divide-y divide-line">
{notes.map((s, i) => (
<li key={i} className="px-5 py-3">
{s.label && <p className="text-[11px] font-semibold tracking-wide text-faint uppercase">{s.label}</p>}
<p className="mt-1 text-sm text-ink">{s.sql}</p>
{s.note && <p className="mt-1 text-xs text-muted">{s.note}</p>}
</li>
))}
</ol>
)}
</Disclosure>
);
}
/** The re-check. Same shape either way, so "fixed" and "not fixed" read alike. */
export function Recheck({ row }: { row: Row }) {
const summary = prose(row.recheck_summary);
if (!summary) return null;
const current = num(row.current_rate);
const baseline = num(row.baseline_rate);
const recovered = num(row.recovered_amount);
const better = current != null && baseline != null && current - baseline <= 2;
return (
<Card
title="Re-check result"
subtitle="Scheduled re-check after approval"
actions={<Badge tone={better ? 'emerald' : 'amber'}>{better ? 'Within baseline' : 'Still elevated'}</Badge>}
>
<div className="space-y-3 px-5 py-4">
<Facts
cols={3}
rows={[
{ label: 'Current denial rate', value: pct(current), tone: better ? 'emerald' : 'rose' },
{ label: 'Baseline', value: pct(baseline) },
{ label: 'Recovered to date', value: recovered == null ? '—' : money(recovered), tone: 'emerald' },
]}
/>
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted">{summary}</p>
</div>
</Card>
);
}
// ---------------------------------------------------------------------------
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 = (
<>
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">{label}</p>
<p className={`tnum mt-1 text-[28px] leading-none font-bold ${text}`}>{value}</p>
{note && <p className="mt-1.5 text-xs text-muted">{note}</p>}
{href && (
<p className="mt-1.5 inline-flex items-center gap-1 text-xs text-brand">
See the claims <ExternalLink className="h-3 w-3" />
</p>
)}
</>
);
// A number you can click opens the rows behind it in the claims table.
return href ? (
<Link to={href} className="block bg-panel px-5 py-4 transition-colors hover:bg-panel2/60" title="Open these claims">
{body}
</Link>
) : (
<div className="bg-panel px-5 py-4">{body}</div>
);
}
export function Disclosure({
open,
onToggle,
icon,
title,
hint,
tone,
children,
}: {
open: boolean;
onToggle: () => void;
icon: React.ReactNode;
title: string;
hint?: string;
tone?: 'ai';
children: React.ReactNode;
}) {
return (
<section className={`lift overflow-hidden rounded-xl border bg-panel ${tone === 'ai' ? 'border-ai-edge' : 'border-line'}`}>
<button
onClick={onToggle}
className="flex w-full items-center gap-3 px-5 py-3.5 text-left focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand"
>
{icon}
<span className="min-w-0">
<span className="block text-sm font-semibold text-ink">{title}</span>
{hint && <span className="block text-xs text-faint">{hint}</span>}
</span>
<ChevronDown className={`ml-auto h-4 w-4 shrink-0 text-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && <div className="border-t border-line">{children}</div>}
</section>
);
}
/** "APPEALS — FILE RETRO-AUTH IMMEDIATELY ($1,091,000 …)" → "Appeals — file retro-auth immediately ($1,091,000 …)".
* Ava shouts her headings; a manager's screen shouldn't. Acronyms and codes survive. */
function sentenceCase(s: string): string {
if (s !== s.toUpperCase()) return s;
return s
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase())
.replace(/\b(co-\d+|cpt|blueshield|bluecross|cardiology|northlake|d\. whitfield|dr\.)\b/gi, (m) =>
m.replace(/\b\w/g, (c) => c.toUpperCase()),
);
}