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>
317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
import { useState } from 'react';
|
||
import { Link, useParams } from 'react-router-dom';
|
||
import { ArrowLeft, Brain, Download, Radar } from 'lucide-react';
|
||
import { useQuery, useZino } from '../api/provider';
|
||
import {
|
||
ACT,
|
||
FINDING_KIND,
|
||
NEEDS_HUMAN,
|
||
RECORD_VIEW,
|
||
STAGES,
|
||
STAGE_TONE,
|
||
actionsFor,
|
||
stageStep,
|
||
} from '../api/config';
|
||
import { Badge, Button, ErrorNote, Facts, Spinner } from '../components/core';
|
||
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 type { Action } from '../api/config';
|
||
import { dateTime, num, prose, str } from '../format';
|
||
|
||
/**
|
||
* One investigation, for a person with ninety seconds.
|
||
*
|
||
* ORDER IS THE DESIGN. What happened (one sentence, large) → how much (three
|
||
* numbers) → what she wants approved → the buttons. That is the whole
|
||
* above-the-fold. The full analysis, her evidence and the live trace sit
|
||
* behind disclosures underneath, for the reviewer who wants to check and the
|
||
* sceptic who wants to see the SQL.
|
||
*
|
||
* The exception is while Ava is still working: then the live trace IS the
|
||
* page, because there is nothing else to show yet and watching her query is
|
||
* the demo.
|
||
*
|
||
* POLLING is on while she owns the case (Investigating / Monitoring); when it
|
||
* is with a person nothing moves, so the poll stops.
|
||
*/
|
||
export function CaseScreen() {
|
||
const { id } = useParams();
|
||
const { client, user } = useZino();
|
||
const [form, setForm] = useState<Action | null>(null);
|
||
const [showWork, setShowWork] = useState(false);
|
||
const [exporting, setExporting] = useState(false);
|
||
|
||
const cases = useQuery(
|
||
() =>
|
||
client.rows(RECORD_VIEW, {
|
||
limit: 1,
|
||
filters: [{ field_key: 'instance_id', value: String(id), data_type: 'number' }],
|
||
}),
|
||
[id],
|
||
true,
|
||
6000,
|
||
);
|
||
const row = cases.data?.[0];
|
||
const state = str(row?.current_state_name);
|
||
const aiOwned = state === 'Investigating' || state === 'Monitoring';
|
||
const scanned = str(row?.scanned_summary);
|
||
const flagged = num(row?.flagged_count);
|
||
const kind = str(row?.finding_kind);
|
||
const quiet = flagged === 0 && !str(row?.summary);
|
||
const hasFindings = !!str(row?.summary);
|
||
|
||
const slice = row ? sliceOf(row) : null;
|
||
// Chart rows come from six Databricks views through the record-view API. Loaded
|
||
// once there are findings to picture; the slice keys decide which weekly line
|
||
// and which recovery breakdown are drawn.
|
||
const asOf = str(row?.review_to || row?.review_as_of).slice(0, 10) || null;
|
||
// The anchor behind "expiring within 14 days". It is the review's own
|
||
// ceiling date when the reviewer set one, otherwise the lakehouse's latest
|
||
// service date — read from the stats view, NOT from the briefing: the charts
|
||
// take seconds to load and a link rendered without the anchor would silently
|
||
// drop the deadline window and open thousands of rows instead of hundreds.
|
||
const lake = useQuery(() => client.rows('dni-rv-lakehouse-tables', { limit: 10 }), [], !asOf && hasFindings);
|
||
const anchor = asOf ?? str(lake.data?.find((r) => str(r.table_name) === 'claims')?.to_date).slice(0, 10) ?? null;
|
||
const briefing = useQuery(
|
||
() => loadBriefing(client, slice, { kind, asOf }),
|
||
[id, slice?.payer, slice?.department, slice?.code, kind, asOf],
|
||
hasFindings,
|
||
);
|
||
|
||
async function exportDeck() {
|
||
if (!row || !briefing.data) return;
|
||
setExporting(true);
|
||
try {
|
||
// pptxgenjs is ~700 KB; nobody pays for it until they ask for slides.
|
||
const { buildDeck } = await import('../lib/slides');
|
||
await buildDeck(row, briefing.data, slice, kind);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}
|
||
|
||
const trace = useQuery(() => client.avaTrace(String(id)), [id], true, aiOwned ? 4000 : 0);
|
||
const decisions = useQuery(() => client.avaDecisions(String(id)), [id], true, aiOwned ? 8000 : 0);
|
||
|
||
const actions = actionsFor(state, user?.roles);
|
||
|
||
function refresh() {
|
||
cases.refetch();
|
||
trace.refetch();
|
||
decisions.refetch();
|
||
}
|
||
|
||
if (cases.loading && !cases.data) return <Spinner label="Loading case…" />;
|
||
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} />;
|
||
|
||
return (
|
||
<div className="mx-auto max-w-5xl space-y-4">
|
||
<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
|
||
</Link>
|
||
<span className="tnum text-sm text-faint">Case #{str(row.instance_id)}</span>
|
||
<Badge tone={quiet ? 'slate' : (STAGE_TONE[state] ?? 'slate')} dot={aiOwned}>
|
||
{quiet ? 'No findings' : state}
|
||
</Badge>
|
||
{kind && FINDING_KIND[kind] && <Badge tone="violet">{FINDING_KIND[kind]}</Badge>}
|
||
<span className="ml-auto text-[11px] text-faint">
|
||
Daily review · opened {dateTime(row.created_at)}
|
||
{str(row.review_as_of) ? ` · as of ${str(row.review_as_of).slice(0, 10)}` : ''}
|
||
</span>
|
||
{hasFindings && (
|
||
<Button kind="secondary" onClick={exportDeck} busy={exporting} disabled={!briefing.data} title="Export a six-slide briefing (.pptx)">
|
||
<Download className="h-4 w-4" /> Export slides
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<Rail state={state} />
|
||
|
||
{/* ---------- Ava still working: the trace is the page ---------- */}
|
||
{aiOwned && !hasFindings && (
|
||
<>
|
||
<section className="lift rounded-xl border border-ai-edge bg-ai-soft px-5 py-4">
|
||
<p className="inline-flex items-center gap-2 text-[11px] font-semibold tracking-wide text-ai uppercase">
|
||
<Brain className="h-4 w-4" /> Review in progress
|
||
</p>
|
||
<p className="mt-1.5 text-[19px] leading-snug font-semibold text-ink">
|
||
Checking all payers, departments and front desks against the 8-week baseline.
|
||
</p>
|
||
<p className="mt-1 text-sm text-muted">
|
||
Typical duration 4–5 minutes. If no change clears the materiality threshold, the case closes with no findings.
|
||
</p>
|
||
</section>
|
||
{work}
|
||
</>
|
||
)}
|
||
|
||
{/* ---------- A quiet morning ---------- */}
|
||
{quiet && (
|
||
<section className="lift rounded-xl border border-line bg-panel px-5 py-5">
|
||
<p className="inline-flex items-center gap-2 text-[11px] font-semibold tracking-wide text-faint uppercase">
|
||
<Radar className="h-4 w-4" /> Review complete
|
||
</p>
|
||
<p className="mt-1.5 text-[21px] leading-snug font-semibold text-ink">
|
||
No findings. No material change across payers, departments or front desks.
|
||
</p>
|
||
{scanned && <p className="mt-3 text-sm leading-relaxed whitespace-pre-wrap text-muted">{scanned}</p>}
|
||
</section>
|
||
)}
|
||
|
||
{/* ---------- Needs a human ---------- */}
|
||
{state === NEEDS_HUMAN && (
|
||
<section className="lift rounded-xl border border-bad-edge bg-bad-soft px-5 py-4">
|
||
<p className="text-[11px] font-semibold tracking-wide text-bad uppercase">Escalated — requires a decision</p>
|
||
<p className="mt-1.5 text-[17px] leading-snug font-semibold text-ink">
|
||
{prose(row.escalation_reason) || 'No reason recorded.'}
|
||
</p>
|
||
</section>
|
||
)}
|
||
|
||
{/* ---------- The findings: what happened, how much, what to approve ---------- */}
|
||
{hasFindings && (
|
||
<>
|
||
<Verdict row={row} slice={slice} kind={kind} anchor={anchor || null} />
|
||
{briefing.data ? (
|
||
<Briefing data={briefing.data} slice={slice} kind={kind} />
|
||
) : (
|
||
<BriefingSkeleton error={briefing.error} />
|
||
)}
|
||
<Recheck row={row} />
|
||
<Recommendations row={row} />
|
||
</>
|
||
)}
|
||
|
||
{/* ---------- The decision ---------- */}
|
||
{actions.length > 0 && (
|
||
<section className="lift rounded-xl border border-warn-edge bg-warn-soft px-5 py-4">
|
||
<p className="text-[11px] font-semibold tracking-wide text-warn uppercase">Review decision</p>
|
||
<div className="mt-3 flex flex-wrap items-start gap-3">
|
||
{actions.map((a) => (
|
||
<div key={a.activity}>
|
||
<Button kind={a.kind} onClick={() => setForm(a)} className="px-5 py-2.5 text-[15px]">
|
||
{a.label}
|
||
</Button>
|
||
{a.hint && <p className="mt-1 max-w-[17rem] text-[11px] text-muted">{a.hint}</p>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* ---------- For the reader who wants to check ---------- */}
|
||
{hasFindings && (
|
||
<>
|
||
<FullStory row={row} />
|
||
<SqlEvidence row={row} steps={trace.data ?? []} decisions={decisions.data ?? []} />
|
||
{!aiOwned && (
|
||
<Disclosure
|
||
open={showWork}
|
||
onToggle={() => setShowWork(!showWork)}
|
||
icon={<Brain className="h-4 w-4 text-ai" />}
|
||
title="Activity trace"
|
||
hint="Step-by-step execution log for this review"
|
||
tone="ai"
|
||
>
|
||
<div className="p-3">{work}</div>
|
||
</Disclosure>
|
||
)}
|
||
{aiOwned && work}
|
||
</>
|
||
)}
|
||
{quiet && (
|
||
<>
|
||
<SqlEvidence row={row} steps={trace.data ?? []} decisions={decisions.data ?? []} />
|
||
<Disclosure
|
||
open={showWork}
|
||
onToggle={() => setShowWork(!showWork)}
|
||
icon={<Brain className="h-4 w-4 text-ai" />}
|
||
title="Activity trace"
|
||
hint="Step-by-step execution log — the checks performed for this no-findings result"
|
||
tone="ai"
|
||
>
|
||
<div className="p-3">{work}</div>
|
||
</Disclosure>
|
||
</>
|
||
)}
|
||
|
||
{/* ---------- Provenance ---------- */}
|
||
<section className="rounded-xl border border-line bg-panel px-5 py-4">
|
||
<Facts
|
||
cols={3}
|
||
rows={[
|
||
{ label: 'Trigger', value: str(row.review_as_of) || str(row.review_from) || str(row.review_to) ? 'Custom run (chosen dates)' : 'Daily review' },
|
||
{ label: 'Data as of', value: str(row.review_to || row.review_as_of) ? str(row.review_to || row.review_as_of).slice(0, 10) : 'Latest available' },
|
||
{
|
||
label: 'Analysis window',
|
||
value: str(row.review_from)
|
||
? `${str(row.review_from).slice(0, 10)} – ${str(row.review_to || row.review_as_of).slice(0, 10) || 'latest'}`
|
||
: row.period_weeks
|
||
? `${str(row.period_weeks)} weeks`
|
||
: '6 weeks',
|
||
},
|
||
...(str(row.review_note) ? [{ label: 'Reviewer note', value: prose(row.review_note), wide: true }] : []),
|
||
...(str(row.approval_note) ? [{ label: 'Approval note', value: prose(row.approval_note), wide: true }] : []),
|
||
]}
|
||
/>
|
||
</section>
|
||
|
||
{form && (
|
||
<ActivityForm
|
||
open
|
||
onClose={() => setForm(null)}
|
||
activityUid={form.activity}
|
||
instanceId={str(row.instance_id)}
|
||
title={form.label}
|
||
subtitle={form.hint}
|
||
submitLabel={form.label}
|
||
danger={form.kind === 'danger'}
|
||
prefill={form.activity === ACT.APPROVE ? { approval_note: defaultApprovalNote(row) } : undefined}
|
||
onDone={refresh}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The five states, as a rail. Needs Human is off the rail on purpose: it is
|
||
* not a step forward, it is the case leaving the track. */
|
||
function Rail({ state }: { state: string }) {
|
||
if (state === NEEDS_HUMAN) return null;
|
||
const at = stageStep(state);
|
||
return (
|
||
<ol className="flex flex-wrap items-center gap-1.5">
|
||
{STAGES.map((s, i) => {
|
||
const done = at > i;
|
||
const here = at === i;
|
||
return (
|
||
<li key={s} className="flex items-center gap-1.5">
|
||
<span
|
||
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||
here ? 'bg-brand text-brand-fg' : done ? 'bg-good-soft text-good ring-1 ring-good-edge ring-inset' : 'bg-panel2 text-faint'
|
||
}`}
|
||
>
|
||
{s}
|
||
</span>
|
||
{i < STAGES.length - 1 && <span aria-hidden="true" className="h-px w-3 bg-line" />}
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
);
|
||
}
|
||
|
||
function defaultApprovalNote(row: Record<string, unknown>): string {
|
||
const recoverable = row.recoverable_amount;
|
||
return recoverable
|
||
? `Approved. Proceed with the recommended appeals and process changes. Expected recovery $${Number(recoverable).toLocaleString('en-US')}.`
|
||
: 'Approved. Proceed with the recommended actions.';
|
||
}
|