Files
cyclone/src/components/charts/AgingBars.tsx
T
Tyler 9bca4b608a 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.
2026-06-22 11:01:58 -06:00

150 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------------------------------------------------------------------------
// AgingBars — AR aging stacked horizontal bar.
//
// One bar split into up to 4 segments by aging bucket (030 / 3160 /
// 6190 / 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. "030", "3160", "6190", "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>
);
}