feat(drill): PayerPeekContent + usePayerSummary + api.getPayerSummary
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user