fix: briefing picks charts by finding kind, folds partial slices, honours replay dates
- expiring_appeals / slow_payer / underpayment no longer get the denial-spike charts sliced by payer alone (one bar per department x code) - weekly and recovery rows folded to their chart grain - replayed mornings show only the trend, cut at the replay date - new charts: payer speed (slow_payer) and appeal scorecard - y-axis ticks no longer clipped at two digits Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
03ff478e4a
commit
1d2dc4a1b9
@ -19,8 +19,21 @@ export const CHART_VIEWS = {
|
|||||||
reasonRecentVsBase: 'dni-rv-chart-reason-recent-vs-base',
|
reasonRecentVsBase: 'dni-rv-chart-reason-recent-vs-base',
|
||||||
expiringAppeals: 'dni-rv-chart-expiring-appeals',
|
expiringAppeals: 'dni-rv-chart-expiring-appeals',
|
||||||
sliceRecovery: 'dni-rv-chart-slice-recovery',
|
sliceRecovery: 'dni-rv-chart-slice-recovery',
|
||||||
|
payerSpeed: 'dni-rv-chart-payer-speed',
|
||||||
|
appealOutcomes: 'dni-rv-chart-appeal-outcomes',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Finding kinds that describe a DENIAL SPIKE in one payer/department/code
|
||||||
|
* slice. Only these get the slice-filtered weekly bars and recovery stack;
|
||||||
|
* the money-timing kinds (expiring appeals, slow payer, underpayment) name a
|
||||||
|
* payer at most, and slicing the views by payer alone returns one row per
|
||||||
|
* department x code — hundreds of bars, not a chart. */
|
||||||
|
export const DENIAL_KINDS = new Set([
|
||||||
|
'payer_policy', 'front_desk', 'coding', 'provider_coding', 'documentation',
|
||||||
|
'medical_necessity', 'timely_filing', 'other', '',
|
||||||
|
]);
|
||||||
|
export const isDenialKind = (kind: string) => DENIAL_KINDS.has(kind);
|
||||||
|
|
||||||
export interface Slice {
|
export interface Slice {
|
||||||
payer: string;
|
payer: string;
|
||||||
department: string;
|
department: string;
|
||||||
@ -68,6 +81,21 @@ export interface RecoveryBucket {
|
|||||||
claims: number;
|
claims: number;
|
||||||
billed: number;
|
billed: number;
|
||||||
}
|
}
|
||||||
|
export interface PayerSpeed {
|
||||||
|
payer: string;
|
||||||
|
claims: number;
|
||||||
|
pending: number;
|
||||||
|
pendingPct: number;
|
||||||
|
medianDays: number;
|
||||||
|
pendingBilled: number;
|
||||||
|
}
|
||||||
|
export interface AppealScore {
|
||||||
|
payer: string;
|
||||||
|
resolved: number;
|
||||||
|
overturned: number;
|
||||||
|
overturnPct: number;
|
||||||
|
recovered: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BriefingData {
|
export interface BriefingData {
|
||||||
weeklyRate: WeekPoint[];
|
weeklyRate: WeekPoint[];
|
||||||
@ -76,6 +104,23 @@ export interface BriefingData {
|
|||||||
reasons: ReasonCompare[];
|
reasons: ReasonCompare[];
|
||||||
expiring: Expiring[];
|
expiring: Expiring[];
|
||||||
recovery: RecoveryBucket[];
|
recovery: RecoveryBucket[];
|
||||||
|
speed: PayerSpeed[];
|
||||||
|
scorecard: AppealScore[];
|
||||||
|
/** Set when the case replayed a past morning. The views are anchored on the
|
||||||
|
* lakehouse's latest date, so only the time series can honour it. */
|
||||||
|
asOf: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Views filtered by a PARTIAL slice come back one row per finer key; fold them
|
||||||
|
* to the grain the chart draws. */
|
||||||
|
function sumBy<T>(rows: T[], key: (r: T) => string, add: (acc: T, r: T) => T): T[] {
|
||||||
|
const m = new Map<string, T>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = key(r);
|
||||||
|
const prev = m.get(k);
|
||||||
|
m.set(k, prev ? add(prev, r) : r);
|
||||||
|
}
|
||||||
|
return [...m.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
const byWeek = (a: { week: string }, b: { week: string }) => a.week.localeCompare(b.week);
|
const byWeek = (a: { week: string }, b: { week: string }) => a.week.localeCompare(b.week);
|
||||||
@ -91,9 +136,14 @@ function week(r: Row): WeekPoint {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadBriefing(client: ZinoClient, slice: Slice | null): Promise<BriefingData> {
|
export async function loadBriefing(
|
||||||
|
client: ZinoClient,
|
||||||
|
slice: Slice | null,
|
||||||
|
opts: { kind?: string; asOf?: string | null } = {},
|
||||||
|
): Promise<BriefingData> {
|
||||||
const f = (field_key: string, value: string) => ({ field_key, value, data_type: 'string' });
|
const f = (field_key: string, value: string) => ({ field_key, value, data_type: 'string' });
|
||||||
const sliceFilters = slice
|
const asOf = opts.asOf?.slice(0, 10) || null;
|
||||||
|
const sliceFilters = slice && isDenialKind(opts.kind ?? '')
|
||||||
? [
|
? [
|
||||||
...(slice.payer ? [f('payer', slice.payer)] : []),
|
...(slice.payer ? [f('payer', slice.payer)] : []),
|
||||||
...(slice.department ? [f('department', slice.department)] : []),
|
...(slice.department ? [f('department', slice.department)] : []),
|
||||||
@ -101,18 +151,26 @@ export async function loadBriefing(client: ZinoClient, slice: Slice | null): Pro
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const [rate, sl, payers, reasons, expiring, recovery] = await Promise.all([
|
const [rate, sl, payers, reasons, expiring, recovery, speed, scorecard] = await Promise.all([
|
||||||
client.rows(CHART_VIEWS.weeklyRate, { limit: 200 }),
|
client.rows(CHART_VIEWS.weeklyRate, { limit: 200 }),
|
||||||
sliceFilters.length ? client.rows(CHART_VIEWS.weeklyBySlice, { limit: 200, filters: sliceFilters }) : Promise.resolve([] as Row[]),
|
sliceFilters.length ? client.rows(CHART_VIEWS.weeklyBySlice, { limit: 200, filters: sliceFilters }) : Promise.resolve([] as Row[]),
|
||||||
client.rows(CHART_VIEWS.payerRecentVsBase, { limit: 50 }),
|
client.rows(CHART_VIEWS.payerRecentVsBase, { limit: 50 }),
|
||||||
client.rows(CHART_VIEWS.reasonRecentVsBase, { limit: 50 }),
|
client.rows(CHART_VIEWS.reasonRecentVsBase, { limit: 50 }),
|
||||||
client.rows(CHART_VIEWS.expiringAppeals, { limit: 50 }),
|
client.rows(CHART_VIEWS.expiringAppeals, { limit: 50 }),
|
||||||
sliceFilters.length ? client.rows(CHART_VIEWS.sliceRecovery, { limit: 50, filters: sliceFilters }) : Promise.resolve([] as Row[]),
|
sliceFilters.length ? client.rows(CHART_VIEWS.sliceRecovery, { limit: 200, filters: sliceFilters }) : Promise.resolve([] as Row[]),
|
||||||
|
client.rows(CHART_VIEWS.payerSpeed, { limit: 50 }).catch(() => [] as Row[]),
|
||||||
|
client.rows(CHART_VIEWS.appealOutcomes, { limit: 50 }).catch(() => [] as Row[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const upTo = (pts: WeekPoint[]) => (asOf ? pts.filter((p) => p.week <= asOf) : pts);
|
||||||
|
const foldWeeks = (pts: WeekPoint[]) =>
|
||||||
|
sumBy(pts, (p) => p.week, (a, b) => ({ ...a, claims: a.claims + b.claims, denied: a.denied + b.denied }))
|
||||||
|
.map((p) => ({ ...p, pct: p.claims ? Math.round((1000 * p.denied) / p.claims) / 10 : 0 }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
weeklyRate: rate.map(week).sort(byWeek),
|
asOf,
|
||||||
weeklySlice: sl.map(week).sort(byWeek),
|
weeklyRate: upTo(rate.map(week).sort(byWeek)),
|
||||||
|
weeklySlice: upTo(foldWeeks(sl.map(week)).sort(byWeek)),
|
||||||
payers: payers
|
payers: payers
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
payer: str(r.payer),
|
payer: str(r.payer),
|
||||||
@ -139,9 +197,30 @@ export async function loadBriefing(client: ZinoClient, slice: Slice | null): Pro
|
|||||||
days: num(r.days_to_first_deadline) ?? 0,
|
days: num(r.days_to_first_deadline) ?? 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.billed - a.billed),
|
.sort((a, b) => b.billed - a.billed),
|
||||||
recovery: recovery
|
recovery: sumBy(
|
||||||
.map((r) => ({ bucket: str(r.bucket), claims: num(r.claims) ?? 0, billed: num(r.billed) ?? 0 }))
|
recovery.map((r) => ({ bucket: str(r.bucket), claims: num(r.claims) ?? 0, billed: num(r.billed) ?? 0 })),
|
||||||
.sort((a, b) => RECOVERY_ORDER.indexOf(a.bucket) - RECOVERY_ORDER.indexOf(b.bucket)),
|
(b) => b.bucket,
|
||||||
|
(a, b) => ({ ...a, claims: a.claims + b.claims, billed: a.billed + b.billed }),
|
||||||
|
).sort((a, b) => RECOVERY_ORDER.indexOf(a.bucket) - RECOVERY_ORDER.indexOf(b.bucket)),
|
||||||
|
speed: speed
|
||||||
|
.map((r) => ({
|
||||||
|
payer: str(r.payer),
|
||||||
|
claims: num(r.claims) ?? 0,
|
||||||
|
pending: num(r.still_pending) ?? 0,
|
||||||
|
pendingPct: num(r.pending_pct) ?? 0,
|
||||||
|
medianDays: num(r.median_days_to_remit) ?? 0,
|
||||||
|
pendingBilled: num(r.pending_billed) ?? 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.pendingPct - a.pendingPct),
|
||||||
|
scorecard: scorecard
|
||||||
|
.map((r) => ({
|
||||||
|
payer: str(r.payer),
|
||||||
|
resolved: num(r.resolved) ?? 0,
|
||||||
|
overturned: num(r.overturned) ?? 0,
|
||||||
|
overturnPct: num(r.overturn_pct) ?? 0,
|
||||||
|
recovered: num(r.recovered_billed) ?? 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.overturnPct - a.overturnPct),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { Loader2, Table2 } from 'lucide-react';
|
import { Loader2, Table2 } from 'lucide-react';
|
||||||
import type { BriefingData, Slice } from '../api/charts';
|
import type { BriefingData, Slice } from '../api/charts';
|
||||||
import { RECOVERY_ORDER } from '../api/charts';
|
import { RECOVERY_ORDER, isDenialKind } from '../api/charts';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../theme';
|
||||||
import { money, moneyShort, shortDate } from '../format';
|
import { money, moneyShort, shortDate } from '../format';
|
||||||
|
|
||||||
@ -95,19 +95,56 @@ export function BriefingSkeleton({ error }: { error?: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Briefing({ data, slice }: { data: BriefingData; slice: Slice | null }) {
|
export function Briefing({ data, slice, kind = '' }: { data: BriefingData; slice: Slice | null; kind?: string }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const c = HUES[theme];
|
const c = HUES[theme];
|
||||||
const title = slice ? [slice.payer, slice.department, slice.code].filter(Boolean).join(' · ') : 'the hospital';
|
const title = slice ? [slice.payer, slice.department, slice.code].filter(Boolean).join(' · ') : 'the hospital';
|
||||||
|
const denial = isDenialKind(kind);
|
||||||
|
|
||||||
|
// A replayed morning: the lakehouse views are anchored on the data's LATEST
|
||||||
|
// date, so "recent vs baseline", "expiring within 14 days", appeal status and
|
||||||
|
// payer speed all describe today, not the morning Ava replayed. Only the
|
||||||
|
// weekly series can be cut at the replay date honestly — so that is all we
|
||||||
|
// draw, and we say why.
|
||||||
|
if (data.asOf) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<SpikeChart data={data} slice={denial ? slice : null} c={c} title={title} kind={kind} />
|
||||||
|
<p className="text-xs text-faint">
|
||||||
|
Replayed morning ({shortDate(data.asOf)}): the trend is cut at that date. Comparison charts are
|
||||||
|
anchored on the latest lakehouse data and are not shown for a replay.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which four pictures tell this kind of story.
|
||||||
|
const cards: React.ReactNode[] = [];
|
||||||
|
if (kind === 'expiring_appeals') {
|
||||||
|
if (data.expiring.length) cards.push(<ExpiringChart key="exp" data={data} c={c} />);
|
||||||
|
if (data.scorecard.length) cards.push(<ScorecardChart key="score" data={data} c={c} />);
|
||||||
|
cards.push(<PayerChart key="payer" data={data} slice={slice} c={c} />);
|
||||||
|
cards.push(<SpikeChart key="spike" data={data} slice={null} c={c} title={title} kind={kind} />);
|
||||||
|
} else if (kind === 'slow_payer') {
|
||||||
|
if (data.speed.length) cards.push(<SpeedChart key="speed" data={data} slice={slice} c={c} />);
|
||||||
|
cards.push(<PayerChart key="payer" data={data} slice={slice} c={c} />);
|
||||||
|
cards.push(<SpikeChart key="spike" data={data} slice={null} c={c} title={title} kind={kind} />);
|
||||||
|
if (data.expiring.length) cards.push(<ExpiringChart key="exp" data={data} c={c} />);
|
||||||
|
} else if (kind === 'underpayment') {
|
||||||
|
cards.push(<PayerChart key="payer" data={data} slice={slice} c={c} />);
|
||||||
|
cards.push(<SpikeChart key="spike" data={data} slice={null} c={c} title={title} kind={kind} />);
|
||||||
|
if (data.scorecard.length) cards.push(<ScorecardChart key="score" data={data} c={c} />);
|
||||||
|
if (data.expiring.length) cards.push(<ExpiringChart key="exp" data={data} c={c} />);
|
||||||
|
} else {
|
||||||
|
cards.push(<SpikeChart key="spike" data={data} slice={slice} c={c} title={title} kind={kind} />);
|
||||||
|
cards.push(<PayerChart key="payer" data={data} slice={slice} c={c} />);
|
||||||
|
if (data.recovery.length) cards.push(<RecoveryChart key="rec" data={data} c={c} title={title} />);
|
||||||
|
if (data.expiring.length) cards.push(<ExpiringChart key="exp" data={data} c={c} />);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
<div className="grid gap-3 lg:grid-cols-2">{cards}</div>
|
||||||
<SpikeChart data={data} slice={slice} c={c} title={title} />
|
|
||||||
<PayerChart data={data} slice={slice} c={c} />
|
|
||||||
{data.recovery.length > 0 && <RecoveryChart data={data} c={c} title={title} />}
|
|
||||||
{data.expiring.length > 0 && <ExpiringChart data={data} c={c} />}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -186,19 +223,23 @@ const tip = (c: typeof HUES.light) => ({
|
|||||||
const axis = (c: typeof HUES.light) => ({ tick: { fill: c.muted, fontSize: 11 }, axisLine: { stroke: c.grid }, tickLine: false as const });
|
const axis = (c: typeof HUES.light) => ({ tick: { fill: c.muted, fontSize: 11 }, axisLine: { stroke: c.grid }, tickLine: false as const });
|
||||||
|
|
||||||
// 1. The spike ----------------------------------------------------------------
|
// 1. The spike ----------------------------------------------------------------
|
||||||
function SpikeChart({ data, slice, c, title }: { data: BriefingData; slice: Slice | null; c: typeof HUES.light; title: string }) {
|
function SpikeChart({ data, slice, c, title, kind = '' }: { data: BriefingData; slice: Slice | null; c: typeof HUES.light; title: string; kind?: string }) {
|
||||||
const pts = data.weeklySlice.length ? data.weeklySlice : data.weeklyRate;
|
const isSlice = slice !== null && data.weeklySlice.length > 0;
|
||||||
const isSlice = data.weeklySlice.length > 0;
|
const pts = isSlice ? data.weeklySlice : data.weeklyRate;
|
||||||
const spike = slice?.spikeStart ?? null;
|
// The dashed marker means different things: a payer bulletin date for a
|
||||||
|
// policy finding, the week a pattern began otherwise. A money-timing finding
|
||||||
|
// has no start week on the denial trend, so no marker.
|
||||||
|
const spike = slice && isDenialKind(kind) ? slice.spikeStart : null;
|
||||||
|
const marker = kind === 'payer_policy' ? 'policy effective' : 'change begins';
|
||||||
const rows = pts.map((p) => ({ ...p, label: shortDate(p.week) }));
|
const rows = pts.map((p) => ({ ...p, label: shortDate(p.week) }));
|
||||||
return (
|
return (
|
||||||
<Frame
|
<Frame
|
||||||
title={isSlice ? `Weekly denied claims — ${title}` : 'Weekly denial rate — all claims'}
|
title={isSlice ? `Weekly denied claims — ${title}` : 'Weekly denial rate — all claims'}
|
||||||
caption={spike ? `Dashed line: payer policy effective ${shortDate(spike)}` : 'Service weeks, pending claims excluded'}
|
caption={spike ? `Dashed line: ${marker} ${shortDate(spike)}` : 'Service weeks, pending claims excluded'}
|
||||||
table={{ head: ['Week', isSlice ? 'Denied' : 'Denial %'], rows: rows.map((r) => [r.label, isSlice ? r.denied : `${r.pct}%`]) }}
|
table={{ head: ['Week', isSlice ? 'Denied' : 'Denial %'], rows: rows.map((r) => [r.label, isSlice ? r.denied : `${r.pct}%`]) }}
|
||||||
>
|
>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -18 }}>
|
<ComposedChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -4 }}>
|
||||||
<CartesianGrid vertical={false} stroke={c.grid} />
|
<CartesianGrid vertical={false} stroke={c.grid} />
|
||||||
<XAxis dataKey="label" {...axis(c)} interval="preserveStartEnd" minTickGap={28} />
|
<XAxis dataKey="label" {...axis(c)} interval="preserveStartEnd" minTickGap={28} />
|
||||||
<YAxis {...axis(c)} width={44} tickFormatter={(v) => (isSlice ? String(v) : `${v}%`)} />
|
<YAxis {...axis(c)} width={44} tickFormatter={(v) => (isSlice ? String(v) : `${v}%`)} />
|
||||||
@ -217,7 +258,7 @@ function SpikeChart({ data, slice, c, title }: { data: BriefingData; slice: Slic
|
|||||||
x={shortDate(spike)}
|
x={shortDate(spike)}
|
||||||
stroke={c.ai}
|
stroke={c.ai}
|
||||||
strokeDasharray="4 3"
|
strokeDasharray="4 3"
|
||||||
label={{ value: 'policy effective', position: 'insideTopLeft', fill: c.ai, fontSize: 11 }}
|
label={{ value: marker, position: 'insideTopLeft', fill: c.ai, fontSize: 11 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
@ -236,7 +277,7 @@ function PayerChart({ data, slice, c }: { data: BriefingData; slice: Slice | nul
|
|||||||
table={{ head: ['Insurer', 'Before', 'Now'], rows: rows.map((r) => [r.payer, `${r.rateBase}%`, `${r.rateRecent}%`]) }}
|
table={{ head: ['Insurer', 'Before', 'Now'], rows: rows.map((r) => [r.payer, `${r.rateBase}%`, `${r.rateRecent}%`]) }}
|
||||||
>
|
>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -18 }} barGap={2} barCategoryGap="28%">
|
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -4 }} barGap={2} barCategoryGap="28%">
|
||||||
<CartesianGrid vertical={false} stroke={c.grid} />
|
<CartesianGrid vertical={false} stroke={c.grid} />
|
||||||
<XAxis dataKey="short" {...axis(c)} interval={0} />
|
<XAxis dataKey="short" {...axis(c)} interval={0} />
|
||||||
<YAxis {...axis(c)} width={44} tickFormatter={(v) => `${v}%`} />
|
<YAxis {...axis(c)} width={44} tickFormatter={(v) => `${v}%`} />
|
||||||
@ -296,6 +337,60 @@ function RecoveryChart({ data, c, title }: { data: BriefingData; c: typeof HUES.
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Speed: who is sitting on our claims ---------------------------------------
|
||||||
|
function SpeedChart({ data, slice, c }: { data: BriefingData; slice: Slice | null; c: typeof HUES.light }) {
|
||||||
|
const rows = data.speed.map((p) => ({ ...p, short: p.payer.replace(' Health Plan', '').replace(' Health', '').replace(' Mutual', '') }));
|
||||||
|
const slow = rows.find((r) => r.payer === slice?.payer) ?? rows[0];
|
||||||
|
return (
|
||||||
|
<Frame
|
||||||
|
title="Claims sent 31–60 days ago, still unanswered"
|
||||||
|
caption={slow ? `${slow.short}: ${slow.pendingPct}% unanswered, ${moneyShort(slow.pendingBilled)} waiting · others answer in ~${Math.round(rows.filter((r) => r !== slow).reduce((t, r) => t + r.medianDays, 0) / Math.max(rows.length - 1, 1))} days` : 'Share of month-old claims with no payer response'}
|
||||||
|
table={{ head: ['Insurer', 'Claims', 'Unanswered', 'Median days', 'Waiting'], rows: rows.map((r) => [r.payer, r.claims, `${r.pendingPct}%`, r.medianDays, money(r.pendingBilled)]) }}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={rows} layout="vertical" margin={{ top: 4, right: 56, bottom: 0, left: 8 }} barCategoryGap="30%">
|
||||||
|
<CartesianGrid horizontal={false} stroke={c.grid} />
|
||||||
|
<XAxis type="number" hide domain={[0, 100]} />
|
||||||
|
<YAxis type="category" dataKey="short" {...axis(c)} width={70} />
|
||||||
|
<Tooltip {...tip(c)} formatter={(v: unknown) => [`${Number(v)}% unanswered`, '']} />
|
||||||
|
<Bar dataKey="pendingPct" radius={[0, 4, 4, 0]} maxBarSize={18} label={{ position: 'right', fill: c.ink, fontSize: 11, formatter: (v: unknown) => `${Number(v)}%` }}>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<Cell key={r.payer} fill={r === slow ? c.warn : c.gray} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Scorecard: which appeals are worth the staff time -------------------------
|
||||||
|
function ScorecardChart({ data, c }: { data: BriefingData; c: typeof HUES.light }) {
|
||||||
|
const rows = data.scorecard.map((p) => ({ ...p, short: p.payer.replace(' Health Plan', '').replace(' Health', '').replace(' Mutual', '') }));
|
||||||
|
const worst = rows[rows.length - 1];
|
||||||
|
return (
|
||||||
|
<Frame
|
||||||
|
title="Appeals overturned, by insurer"
|
||||||
|
caption={worst ? `All history · ${worst.short} reverses only ${worst.overturnPct}% — correct and resubmit instead of appealing` : 'Share of resolved appeals the insurer reversed'}
|
||||||
|
table={{ head: ['Insurer', 'Resolved', 'Overturned', 'Recovered'], rows: rows.map((r) => [r.payer, r.resolved, `${r.overturnPct}%`, money(r.recovered)]) }}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={rows} layout="vertical" margin={{ top: 4, right: 56, bottom: 0, left: 8 }} barCategoryGap="30%">
|
||||||
|
<CartesianGrid horizontal={false} stroke={c.grid} />
|
||||||
|
<XAxis type="number" hide domain={[0, 100]} />
|
||||||
|
<YAxis type="category" dataKey="short" {...axis(c)} width={70} />
|
||||||
|
<Tooltip {...tip(c)} formatter={(v: unknown, _n: unknown, item: { payload?: { resolved?: number } }) => [`${Number(v)}% of ${item?.payload?.resolved ?? ''} resolved`, 'overturned']} />
|
||||||
|
<Bar dataKey="overturnPct" radius={[0, 4, 4, 0]} maxBarSize={18} label={{ position: 'right', fill: c.ink, fontSize: 11, formatter: (v: unknown) => `${Number(v)}%` }}>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<Cell key={r.payer} fill={r.overturnPct < 20 ? c.gray : c.good} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Expiring -------------------------------------------------------------------
|
// 4. Expiring -------------------------------------------------------------------
|
||||||
function ExpiringChart({ data, c }: { data: BriefingData; c: typeof HUES.light }) {
|
function ExpiringChart({ data, c }: { data: BriefingData; c: typeof HUES.light }) {
|
||||||
const rows = data.expiring.map((e) => ({ ...e, short: e.payer.replace(' Health Plan', '').replace(' Health', '').replace(' Mutual', '') }));
|
const rows = data.expiring.map((e) => ({ ...e, short: e.payer.replace(' Health Plan', '').replace(' Health', '').replace(' Mutual', '') }));
|
||||||
|
|||||||
@ -43,7 +43,7 @@ const RECOVERY_COLOR: Record<string, string> = {
|
|||||||
'window closed': GRAY,
|
'window closed': GRAY,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function buildDeck(row: Row, data: BriefingData, slice: Slice | null): Promise<void> {
|
export async function buildDeck(row: Row, data: BriefingData, slice: Slice | null, kind = ''): Promise<void> {
|
||||||
const pres = new pptxgen();
|
const pres = new pptxgen();
|
||||||
pres.layout = 'LAYOUT_WIDE'; // 13.33 x 7.5 in
|
pres.layout = 'LAYOUT_WIDE'; // 13.33 x 7.5 in
|
||||||
pres.author = 'Northlake Health — Denials Desk (AI analyst: Ava)';
|
pres.author = 'Northlake Health — Denials Desk (AI analyst: Ava)';
|
||||||
@ -223,7 +223,9 @@ export async function buildDeck(row: Row, data: BriefingData, slice: Slice | nul
|
|||||||
const bullets = [
|
const bullets = [
|
||||||
str(row.scanned_summary) || 'Reviewed every payer, department and front desk against an 8-week baseline.',
|
str(row.scanned_summary) || 'Reviewed every payer, department and front desk against an 8-week baseline.',
|
||||||
reconciled ? 'Claims data reconciled against the finance report before any analysis.' : 'Claims data could not be fully reconciled against finance — treat numbers as provisional.',
|
reconciled ? 'Claims data reconciled against the finance report before any analysis.' : 'Claims data could not be fully reconciled against finance — treat numbers as provisional.',
|
||||||
`Cause matched to the payer's own policy bulletin${slice?.spikeStart ? ` effective ${shortDate(slice.spikeStart)}` : ''}.`,
|
kind === 'payer_policy'
|
||||||
|
? `Cause matched to the payer's own policy bulletin${slice?.spikeStart ? ` effective ${shortDate(slice.spikeStart)}` : ''}.`
|
||||||
|
: '',
|
||||||
conf != null ? `Stated confidence: ${Math.round(conf * 100)}%.` : '',
|
conf != null ? `Stated confidence: ${Math.round(conf * 100)}%.` : '',
|
||||||
'Read-only access to Databricks (workspace.rcm). Every query is logged and reproducible from the case.',
|
'Read-only access to Databricks (workspace.rcm). Every query is logged and reproducible from the case.',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|||||||
@ -67,9 +67,10 @@ export function CaseScreen() {
|
|||||||
// Chart rows come from six Databricks views through the record-view API. Loaded
|
// 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
|
// once there are findings to picture; the slice keys decide which weekly line
|
||||||
// and which recovery breakdown are drawn.
|
// and which recovery breakdown are drawn.
|
||||||
|
const asOf = str(row?.review_as_of).slice(0, 10) || null;
|
||||||
const briefing = useQuery(
|
const briefing = useQuery(
|
||||||
() => loadBriefing(client, slice),
|
() => loadBriefing(client, slice, { kind, asOf }),
|
||||||
[id, slice?.payer, slice?.department, slice?.code],
|
[id, slice?.payer, slice?.department, slice?.code, kind, asOf],
|
||||||
hasFindings,
|
hasFindings,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -79,7 +80,7 @@ export function CaseScreen() {
|
|||||||
try {
|
try {
|
||||||
// pptxgenjs is ~700 KB; nobody pays for it until they ask for slides.
|
// pptxgenjs is ~700 KB; nobody pays for it until they ask for slides.
|
||||||
const { buildDeck } = await import('../lib/slides');
|
const { buildDeck } = await import('../lib/slides');
|
||||||
await buildDeck(row, briefing.data, slice);
|
await buildDeck(row, briefing.data, slice, kind);
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@ -172,7 +173,7 @@ export function CaseScreen() {
|
|||||||
<>
|
<>
|
||||||
<Verdict row={row} />
|
<Verdict row={row} />
|
||||||
{briefing.data ? (
|
{briefing.data ? (
|
||||||
<Briefing data={briefing.data} slice={slice} />
|
<Briefing data={briefing.data} slice={slice} kind={kind} />
|
||||||
) : (
|
) : (
|
||||||
<BriefingSkeleton error={briefing.error} />
|
<BriefingSkeleton error={briefing.error} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user