feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
Backend:
- New POST /api/batches/{id}/export-837: regenerate X12 837 files
for a list of claim_ids into a ZIP using HCPF file naming standards,
with a unique interchange/group control number per export. Wire
the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
(NM1*40) blocks so the serializer no longer falls back to
CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
batch_id in both JSON and NDJSON response shapes so the frontend
can hit batch-scoped endpoints without an extra listBatches
round-trip.
- Filename helpers and the 837 serializer updated to match the new
HCPF envelope; tests cover batch export, parse batch_id, and the
serializer's control-number uniqueness guarantee.
Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
EditorialNote, ExportBar, TickerTape, and a charts/ set
(BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
colors to Tailwind theme tokens (bg-card, text-foreground,
border/60, etc.) for consistency with the rest of the instrument
chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
reworked to compose the new shared components and consume the new
batch-scoped API surface (notably ExportBar wired into Batches).
Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
components and the useBatchExport hook.
Rolls up into the v0.2.0 release tag.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgingBars — AR aging stacked horizontal bar.
|
||||
//
|
||||
// One bar split into up to 4 segments by aging bucket (0–30 / 31–60 /
|
||||
// 61–90 / 90+ days). Each segment is coloured along a cool-to-warm ramp
|
||||
// so the eye reads left-to-right as "freshest to most stale". Below
|
||||
// the bar, four legend rows show the bucket label, dollar amount, and
|
||||
// share of total. The component is data-driven — pass any four-bucket
|
||||
// distribution.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { fmt } from "@/lib/format";
|
||||
|
||||
export interface AgingBucket {
|
||||
/** Bucket id for keying. */
|
||||
id: string;
|
||||
/** Display label (e.g. "0–30", "31–60", "61–90", "90+"). */
|
||||
label: string;
|
||||
/** Dollar amount outstanding in this bucket. */
|
||||
amount: number;
|
||||
/** Tailwind/HSL colour for the segment. */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface AgingBarsProps {
|
||||
buckets: AgingBucket[];
|
||||
/** Total AR amount for share calc. Defaults to sum of buckets. */
|
||||
total?: number;
|
||||
/** Optional caption shown right-aligned above the bar. */
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export function AgingBars({ buckets, total, caption }: AgingBarsProps) {
|
||||
const sum = total ?? buckets.reduce((s, b) => s + b.amount, 0);
|
||||
if (sum <= 0) {
|
||||
return (
|
||||
<div
|
||||
className="mono text-[11px] py-6 text-center"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Nothing outstanding.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{caption ? (
|
||||
<div
|
||||
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2 flex items-center justify-between"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
<span>Aging buckets</span>
|
||||
<span>{caption}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The stacked bar */}
|
||||
<div
|
||||
className="flex w-full h-3 overflow-hidden rounded-[3px]"
|
||||
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
|
||||
>
|
||||
{buckets.map((b, i) => {
|
||||
const pct = (b.amount / sum) * 100;
|
||||
if (pct <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: b.color,
|
||||
animation: `bar-grow-h 800ms cubic-bezier(0.2,0.8,0.2,1) ${
|
||||
i * 70
|
||||
}ms both`,
|
||||
transformOrigin: "left center",
|
||||
}}
|
||||
title={`${b.label}: ${fmt.usd(b.amount)}`}
|
||||
aria-label={`${b.label} days: ${fmt.usd(b.amount)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend rows */}
|
||||
<ul className="mt-4 space-y-2">
|
||||
{buckets.map((b) => {
|
||||
const share = sum > 0 ? (b.amount / sum) * 100 : 0;
|
||||
return (
|
||||
<li
|
||||
key={b.id}
|
||||
className="flex items-center justify-between gap-3 text-[12px]"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: b.color }}
|
||||
/>
|
||||
<span
|
||||
className="mono tabular-nums"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{b.label}d
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div
|
||||
className="relative h-1 w-16 rounded-full overflow-hidden"
|
||||
style={{ backgroundColor: "hsl(30 14% 14% / 0.06)" }}
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{
|
||||
width: `${share}%`,
|
||||
backgroundColor: b.color,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="mono tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
minWidth: 70,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{fmt.usd(b.amount)}
|
||||
</span>
|
||||
<span
|
||||
className="mono tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
minWidth: 40,
|
||||
textAlign: "right",
|
||||
fontSize: 10.5,
|
||||
}}
|
||||
>
|
||||
{share.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// BarChart — grouped vertical bar chart on cream paper.
|
||||
//
|
||||
// Renders N series (e.g. billed, received, AR) over M categories (months).
|
||||
// Each category gets a cluster of bars, one per series. The chart grows
|
||||
// the bars from 0 on mount with a staggered delay so the statement
|
||||
// doesn't appear all at once. Hover shows the exact value as a small
|
||||
// ledger callout. Designed to live INSIDE the paper statement — no dark
|
||||
// chrome, hairline grid only.
|
||||
//
|
||||
// The paper variant: cream background, dark ink, hairline rule in
|
||||
// --surface-line-soft. Three series uses --accent (billed), --success
|
||||
// (received), --signal (AR outstanding) so each carries its own
|
||||
// semantic colour rather than three random hues.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface BarSeries {
|
||||
/** Stable id used for keying, hover hit-tests, and legend swatches. */
|
||||
id: string;
|
||||
/** Display name shown in the legend. */
|
||||
label: string;
|
||||
/** Hex/HSL colour used for bar fill + legend swatch. */
|
||||
color: string;
|
||||
/** Values aligned with `categories`. */
|
||||
values: number[];
|
||||
/** Optional formatter for tooltip + axis labels. Defaults to identity. */
|
||||
format?: (n: number) => string;
|
||||
}
|
||||
|
||||
export interface BarChartProps {
|
||||
/** Category labels along the X axis (e.g. month abbreviations). */
|
||||
categories: string[];
|
||||
/** Ordered series. First series renders leftmost in each cluster. */
|
||||
series: BarSeries[];
|
||||
/** Height in px. Default 260. */
|
||||
height?: number;
|
||||
/** Optional y-axis tick formatter. If omitted, the chart self-scales
|
||||
* to a "round" max and labels with a compact thousands suffix. */
|
||||
formatY?: (n: number) => string;
|
||||
/** When true, draws a hairline rule for each y tick. Default true. */
|
||||
showGrid?: boolean;
|
||||
/** Force a specific Y-axis maximum. When omitted, the chart auto-scales
|
||||
* to a "round" max with a small headroom. */
|
||||
yMax?: number;
|
||||
/** When true, prints the per-cluster total above each month. Default true. */
|
||||
showTotals?: boolean;
|
||||
/** Index of the "current" category to highlight (e.g. latest month).
|
||||
* Adds a subtle background band and an eyebrow above. */
|
||||
highlightIndex?: number;
|
||||
}
|
||||
|
||||
const PAD_LEFT = 44;
|
||||
const PAD_RIGHT = 16;
|
||||
const PAD_TOP = 16;
|
||||
const PAD_BOTTOM = 28;
|
||||
|
||||
const niceMax = (raw: number): number => {
|
||||
if (raw <= 0) return 1;
|
||||
const exp = Math.floor(Math.log10(raw));
|
||||
const base = Math.pow(10, exp);
|
||||
const norm = raw / base;
|
||||
let nice: number;
|
||||
if (norm <= 1) nice = 1;
|
||||
else if (norm <= 2) nice = 2;
|
||||
else if (norm <= 2.5) nice = 2.5;
|
||||
else if (norm <= 5) nice = 5;
|
||||
else nice = 10;
|
||||
return nice * base;
|
||||
};
|
||||
|
||||
const compact = (n: number): string => {
|
||||
const abs = Math.abs(n);
|
||||
if (abs >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (abs >= 1_000) return `$${(n / 1_000).toFixed(0)}k`;
|
||||
return `$${n.toFixed(0)}`;
|
||||
};
|
||||
|
||||
interface Hover {
|
||||
cat: number;
|
||||
series: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function BarChart({
|
||||
categories,
|
||||
series,
|
||||
height = 260,
|
||||
formatY,
|
||||
showGrid = true,
|
||||
yMax: yMaxProp,
|
||||
showTotals = true,
|
||||
highlightIndex,
|
||||
}: BarChartProps) {
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const [width, setWidth] = useState(800);
|
||||
const [hover, setHover] = useState<Hover | null>(null);
|
||||
|
||||
// Track container width so the SVG reflows with the layout grid.
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width ?? 800;
|
||||
setWidth(Math.max(320, Math.floor(w)));
|
||||
});
|
||||
ro.observe(el);
|
||||
setWidth(Math.max(320, Math.floor(el.clientWidth)));
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const innerW = width - PAD_LEFT - PAD_RIGHT;
|
||||
const innerH = height - PAD_TOP - PAD_BOTTOM;
|
||||
|
||||
const allValues = series.flatMap((s) => s.values);
|
||||
const rawMax = allValues.length ? Math.max(...allValues) : 1;
|
||||
// If caller forces a yMax, use it; otherwise compute a "round" max
|
||||
// with a small (5%) headroom so the tallest bar doesn't kiss the lid.
|
||||
const yMax = yMaxProp ?? niceMax(rawMax * 1.05);
|
||||
const yTicks = 4;
|
||||
|
||||
const clusterW = innerW / Math.max(1, categories.length);
|
||||
const barGap = 4;
|
||||
const barW = Math.max(
|
||||
4,
|
||||
(clusterW - barGap * (series.length - 1)) / Math.max(1, series.length)
|
||||
);
|
||||
|
||||
const yToPx = (v: number) => PAD_TOP + innerH - (v / yMax) * innerH;
|
||||
const catCenterX = (i: number) => PAD_LEFT + clusterW * i + clusterW / 2;
|
||||
|
||||
const fmtY = formatY ?? compact;
|
||||
|
||||
// Stagger the bars so they grow from baseline on first paint. We just
|
||||
// emit a CSS animation-delay per bar via inline style.
|
||||
const barIndex = (catI: number, serI: number) =>
|
||||
catI * series.length + serI;
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative w-full" style={{ height }}>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="block"
|
||||
role="img"
|
||||
aria-label="Monthly billed, received, and AR trend"
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{/* Y-axis grid + labels */}
|
||||
{showGrid &&
|
||||
Array.from({ length: yTicks + 1 }, (_, i) => {
|
||||
const v = (yMax / yTicks) * i;
|
||||
const y = yToPx(v);
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={PAD_LEFT}
|
||||
x2={width - PAD_RIGHT}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="hsl(30 14% 14% / 0.10)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={i === 0 ? "" : "2 3"}
|
||||
/>
|
||||
<text
|
||||
x={PAD_LEFT - 10}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
className="mono"
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
style={{ fill: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{fmtY(v)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Highlight band on the current month (subtle, like a sticky note) */}
|
||||
{highlightIndex !== undefined &&
|
||||
highlightIndex >= 0 &&
|
||||
highlightIndex < categories.length ? (
|
||||
<g>
|
||||
<rect
|
||||
x={catCenterX(highlightIndex) - clusterW / 2}
|
||||
y={PAD_TOP}
|
||||
width={clusterW}
|
||||
height={innerH}
|
||||
fill="hsl(36 92% 56% / 0.06)"
|
||||
/>
|
||||
<line
|
||||
x1={catCenterX(highlightIndex)}
|
||||
x2={catCenterX(highlightIndex)}
|
||||
y1={PAD_TOP}
|
||||
y2={PAD_TOP + innerH}
|
||||
stroke="hsl(36 92% 56% / 0.45)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 3"
|
||||
/>
|
||||
<text
|
||||
x={catCenterX(highlightIndex)}
|
||||
y={PAD_TOP + 12}
|
||||
textAnchor="middle"
|
||||
className="mono"
|
||||
fontSize={9}
|
||||
fontWeight={600}
|
||||
style={{ fill: "hsl(36 92% 36%)", letterSpacing: "0.18em" }}
|
||||
>
|
||||
NOW
|
||||
</text>
|
||||
</g>
|
||||
) : null}
|
||||
|
||||
{/* Bars */}
|
||||
{categories.map((cat, ci) =>
|
||||
series.map((s, si) => {
|
||||
const v = s.values[ci] ?? 0;
|
||||
const x =
|
||||
catCenterX(ci) -
|
||||
(series.length * barW + (series.length - 1) * barGap) / 2 +
|
||||
si * (barW + barGap);
|
||||
const y = yToPx(v);
|
||||
const h = Math.max(0, PAD_TOP + innerH - y);
|
||||
const delay = barIndex(ci, si) * 35;
|
||||
const isHover =
|
||||
hover?.cat === ci && hover?.series === si ? true : false;
|
||||
return (
|
||||
<rect
|
||||
key={`${cat}-${s.id}`}
|
||||
x={x}
|
||||
y={y}
|
||||
width={barW}
|
||||
height={h}
|
||||
rx={1.5}
|
||||
fill={s.color}
|
||||
opacity={hover && !isHover ? 0.55 : 1}
|
||||
style={{
|
||||
transformOrigin: `${x + barW / 2}px ${PAD_TOP + innerH}px`,
|
||||
animation: `bar-grow 600ms cubic-bezier(0.2,0.8,0.2,1) ${delay}ms both`,
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHover({
|
||||
cat: ci,
|
||||
series: si,
|
||||
x: x + barW / 2,
|
||||
y,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{categories.map((cat, i) => (
|
||||
<text
|
||||
key={cat}
|
||||
x={catCenterX(i)}
|
||||
y={height - 14}
|
||||
textAnchor="middle"
|
||||
className="mono"
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
style={{
|
||||
fill:
|
||||
highlightIndex === i
|
||||
? "hsl(var(--surface-ink))"
|
||||
: "hsl(var(--surface-ink-2))",
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Per-cluster totals above each month — quick read of the spine. */}
|
||||
{showTotals &&
|
||||
categories.map((_, ci) => {
|
||||
const total = series.reduce(
|
||||
(s, sr) => s + (sr.values[ci] ?? 0),
|
||||
0
|
||||
);
|
||||
const x = catCenterX(ci);
|
||||
const topY = yToPx(
|
||||
Math.max(...series.map((s) => s.values[ci] ?? 0))
|
||||
);
|
||||
const labelY = Math.max(14, topY - 8);
|
||||
return (
|
||||
<text
|
||||
key={`total-${ci}`}
|
||||
x={x}
|
||||
y={labelY}
|
||||
textAnchor="middle"
|
||||
className="display mono"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
style={{
|
||||
fill: "hsl(var(--surface-ink-2))",
|
||||
opacity: 0.75,
|
||||
}}
|
||||
>
|
||||
{fmtY(total)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hover callout */}
|
||||
{hover ? (
|
||||
(() => {
|
||||
const s = series[hover.series]!;
|
||||
const v = s.values[hover.cat] ?? 0;
|
||||
const catLabel = categories[hover.cat] ?? "";
|
||||
const text = `${s.label} · ${catLabel}`;
|
||||
const valueText = s.format ? s.format(v) : fmtY(v);
|
||||
// Position the chip to the right of the bar; flip left if it
|
||||
// would overflow the canvas edge.
|
||||
const flipLeft = hover.x > width - 160;
|
||||
const chipX = flipLeft ? hover.x - 8 : hover.x + 8;
|
||||
const anchor: "end" | "start" = flipLeft ? "end" : "start";
|
||||
return (
|
||||
<g style={{ pointerEvents: "none" }}>
|
||||
<line
|
||||
x1={hover.x}
|
||||
x2={hover.x}
|
||||
y1={PAD_TOP}
|
||||
y2={PAD_TOP + innerH}
|
||||
stroke={s.color}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 3"
|
||||
opacity={0.55}
|
||||
/>
|
||||
<text
|
||||
x={chipX}
|
||||
y={Math.max(14, hover.y - 10)}
|
||||
textAnchor={anchor}
|
||||
className="mono"
|
||||
fontSize={10}
|
||||
style={{ fill: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
<text
|
||||
x={chipX}
|
||||
y={Math.max(26, hover.y + 2)}
|
||||
textAnchor={anchor}
|
||||
className="display mono"
|
||||
fontSize={16}
|
||||
style={{ fill: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{valueText}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 mt-3 flex-wrap">
|
||||
{series.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 rounded-sm"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
<span
|
||||
className="mono text-[10.5px] uppercase tracking-[0.16em]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// HBarChart — horizontal bar chart for ranked lists (e.g. Top Providers).
|
||||
//
|
||||
// Each row gets a label slot on the left, a bar that grows from 0 to
|
||||
// its value on mount, and a trailing value. Bars share a common scale
|
||||
// derived from the max value. Designed for 4–8 rows; collapses to a
|
||||
// list if rows are empty. Paper-friendly: cream background, dark ink.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface HBarRow {
|
||||
/** Stable id used as React key. */
|
||||
id: string;
|
||||
/** Left label (e.g. provider name). */
|
||||
label: string;
|
||||
/** Sub-label under the row (e.g. NPI). Optional. */
|
||||
sublabel?: string;
|
||||
/** Bar value. */
|
||||
value: number;
|
||||
/** Optional pre-formatted trailing value. Defaults to fmt.num(value). */
|
||||
formatted?: string;
|
||||
/** Optional color override. Defaults to --accent. */
|
||||
color?: string;
|
||||
/** Optional leading slot (icon, initials avatar, etc). */
|
||||
leading?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface HBarChartProps {
|
||||
rows: HBarRow[];
|
||||
/** Bar fill colour when a row doesn't override. Defaults to accent. */
|
||||
defaultColor?: string;
|
||||
/** Pixel height of each row. Default 44. */
|
||||
rowHeight?: number;
|
||||
/** Compact mode: smaller text, tighter padding. */
|
||||
compact?: boolean;
|
||||
/** Optional click handler. */
|
||||
onRowClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function HBarChart({
|
||||
rows,
|
||||
defaultColor = "hsl(var(--accent))",
|
||||
rowHeight = 44,
|
||||
compact = false,
|
||||
onRowClick,
|
||||
}: HBarChartProps) {
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const [labelW, setLabelW] = useState(180);
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width ?? 800;
|
||||
// Reserve ~38% of width for label + leading slot, capped between
|
||||
// 140 and 260 so bars stay readable on wide and narrow layouts.
|
||||
setLabelW(Math.max(140, Math.min(260, Math.floor(w * 0.38))));
|
||||
});
|
||||
ro.observe(el);
|
||||
setLabelW(Math.max(140, Math.min(260, Math.floor(el.clientWidth * 0.38))));
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="mono text-[11px] py-6 text-center"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
No data yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const max = Math.max(...rows.map((r) => r.value), 1);
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="w-full">
|
||||
{rows.map((r, i) => {
|
||||
const pct = (r.value / max) * 100;
|
||||
const color = r.color ?? defaultColor;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
role={onRowClick ? "button" : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
onClick={onRowClick ? () => onRowClick(r.id) : undefined}
|
||||
onKeyDown={
|
||||
onRowClick
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick(r.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"group flex items-center gap-3",
|
||||
compact ? "py-1.5" : "py-2.5",
|
||||
onRowClick ? "cursor-pointer" : ""
|
||||
)}
|
||||
style={{ minHeight: rowHeight }}
|
||||
>
|
||||
{/* Leading slot (e.g. initials avatar) */}
|
||||
{r.leading ? (
|
||||
<div className="shrink-0">{r.leading}</div>
|
||||
) : null}
|
||||
|
||||
{/* Label column */}
|
||||
<div
|
||||
className="shrink-0 min-w-0"
|
||||
style={{ width: labelW - (r.leading ? 36 : 0) }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"truncate font-medium",
|
||||
compact ? "text-[12.5px]" : "text-[13.5px]"
|
||||
)}
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{r.label}
|
||||
</div>
|
||||
{r.sublabel ? (
|
||||
<div
|
||||
className="mono truncate"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
fontSize: 10.5,
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{r.sublabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
{/* Track (hairline) */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
|
||||
style={{ backgroundColor: "hsl(30 14% 14% / 0.08)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="relative h-[10px] rounded-[2px]"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.85,
|
||||
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
|
||||
i * 50
|
||||
}ms both`,
|
||||
transformOrigin: "left center",
|
||||
boxShadow: `inset 0 1px 0 hsl(0 0% 100% / 0.15)`,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
{/* Rank tick on the bar */}
|
||||
<span
|
||||
className="mono absolute top-1/2 -translate-y-1/2 text-[9px] font-semibold tabular-nums"
|
||||
style={{
|
||||
left: 6,
|
||||
color: "hsl(0 0% 100% / 0.92)",
|
||||
mixBlendMode: "screen",
|
||||
opacity: pct > 12 ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Trailing value */}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 text-right mono tabular-nums",
|
||||
compact ? "text-[12.5px]" : "text-[14px]"
|
||||
)}
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
minWidth: 56,
|
||||
}}
|
||||
>
|
||||
{r.formatted ?? r.value.toLocaleString("en-US")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// SegmentedBar — thin horizontal status distribution strip.
|
||||
//
|
||||
// A single bar split into N color segments showing the share of each
|
||||
// status. The legend below the bar shows the segment label + count +
|
||||
// percentage, color-coded to the segment. Designed for 4–6 statuses.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { fmt } from "@/lib/format";
|
||||
|
||||
export interface Segment {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface SegmentedBarProps {
|
||||
segments: Segment[];
|
||||
/** Optional caption shown above the bar (e.g. "Status distribution"). */
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export function SegmentedBar({ segments, caption }: SegmentedBarProps) {
|
||||
const total = segments.reduce((s, x) => s + x.count, 0);
|
||||
if (total <= 0) {
|
||||
return (
|
||||
<div
|
||||
className="mono text-[11px] py-6 text-center"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
No claims yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{caption ? (
|
||||
<div
|
||||
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{caption}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The bar */}
|
||||
<div
|
||||
className="flex w-full h-[10px] overflow-hidden rounded-[3px]"
|
||||
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
|
||||
>
|
||||
{segments.map((s, i) => {
|
||||
const pct = (s.count / total) * 100;
|
||||
if (pct <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: s.color,
|
||||
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
|
||||
i * 50
|
||||
}ms both`,
|
||||
transformOrigin: "left center",
|
||||
}}
|
||||
title={`${s.label}: ${s.count} (${((s.count / total) * 100).toFixed(0)}%)`}
|
||||
aria-label={`${s.label}: ${s.count} of ${total}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<ul className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{segments.map((s) => {
|
||||
const pct = (s.count / total) * 100;
|
||||
if (s.count === 0) return null;
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-2 text-[11.5px] min-w-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
<span
|
||||
className="truncate"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 mono tabular-nums">
|
||||
<span style={{ color: "hsl(var(--surface-ink))" }}>
|
||||
{fmt.num(s.count)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
fontSize: 10.5,
|
||||
minWidth: 30,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{pct.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user