9bca4b608a
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.
194 lines
6.2 KiB
TypeScript
194 lines
6.2 KiB
TypeScript
// ---------------------------------------------------------------------------
|
||
// 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>
|
||
);
|
||
} |