118 lines
3.4 KiB
TypeScript
118 lines
3.4 KiB
TypeScript
import { Link } from "react-router-dom";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { fmt } from "@/lib/format";
|
||
import { usePayerSummary } from "@/hooks/usePayerSummary";
|
||
import type { PayerSummary } from "@/lib/api";
|
||
|
||
interface Props {
|
||
payerId: string;
|
||
}
|
||
|
||
/**
|
||
* Peek body for a payer — aggregate stats card shown inside the
|
||
* centered PeekModal (SP21 universal drill-down).
|
||
*
|
||
* Owns its own fetch via `usePayerSummary`; the parent PeekModal only
|
||
* concerns itself with open/close + title. We deliberately do NOT show
|
||
* an error state here — the peek is a low-stakes summary, so a silent
|
||
* retry + skeleton on failure is acceptable (the parent modal still
|
||
* closes correctly).
|
||
*
|
||
* `fmt.pct` does not multiply by 100 (it's just `n.toFixed(d)%`), so the
|
||
* API's fraction `denial_rate` (0–1) needs `* 100` before formatting —
|
||
* otherwise the UI would render "0.0%" for everything.
|
||
*/
|
||
export function PayerPeekContent({ payerId }: Props) {
|
||
const { data, isLoading } = usePayerSummary(payerId);
|
||
if (isLoading || !data) {
|
||
return (
|
||
<div className="space-y-2" aria-busy="true">
|
||
<Skeleton variant="row" />
|
||
<Skeleton variant="row" />
|
||
<Skeleton variant="row" />
|
||
</div>
|
||
);
|
||
}
|
||
return <Loaded payer={data} />;
|
||
}
|
||
|
||
function Loaded({ payer }: { payer: PayerSummary }) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-baseline justify-between gap-3">
|
||
<div className="display text-[15px] text-foreground truncate">
|
||
{payer.name}
|
||
</div>
|
||
<div className="mono text-[11px] text-muted-foreground">
|
||
{payer.payer_id}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Stat label="Claims" value={fmt.num(payer.claim_count)} />
|
||
<Stat
|
||
label="Denial rate"
|
||
value={fmt.pct(payer.denial_rate * 100)}
|
||
/>
|
||
<Stat
|
||
label="Billed"
|
||
value={fmt.usd(payer.billed_total)}
|
||
accent="accent"
|
||
/>
|
||
<Stat
|
||
label="Received"
|
||
value={fmt.usd(payer.received_total)}
|
||
accent="success"
|
||
/>
|
||
</div>
|
||
{payer.top_providers.length > 0 ? (
|
||
<div>
|
||
<div className="eyebrow mb-1.5">Top providers</div>
|
||
<ul className="text-[12.5px] space-y-1">
|
||
{payer.top_providers.slice(0, 3).map((p) => (
|
||
<li
|
||
key={p.npi}
|
||
className="flex justify-between gap-3"
|
||
>
|
||
<span className="mono">{p.npi}</span>
|
||
<span className="mono text-muted-foreground">
|
||
{fmt.num(p.count)} claims
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
<Link
|
||
to={`/claims?payer=${encodeURIComponent(payer.payer_id)}`}
|
||
className="text-[12.5px] text-accent hover:underline"
|
||
>
|
||
View all claims →
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stat({
|
||
label,
|
||
value,
|
||
accent,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
accent?: "accent" | "success" | "warning";
|
||
}) {
|
||
const color =
|
||
accent === "success"
|
||
? "text-[hsl(var(--success))]"
|
||
: accent === "warning"
|
||
? "text-[hsl(var(--warning))]"
|
||
: accent === "accent"
|
||
? "text-accent"
|
||
: "text-foreground";
|
||
return (
|
||
<div>
|
||
<div className="eyebrow">{label}</div>
|
||
<div className={`display mono text-[16px] mt-1 ${color}`}>{value}</div>
|
||
</div>
|
||
);
|
||
} |