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,86 @@
|
||||
// Shared visual primitives used by ClaimCard837 and ClaimCard835.
|
||||
//
|
||||
// Both cards render validation status (Passed / Warnings / Failed) and
|
||||
// a grid of stat pills (Member, Place of service, …). The exact same
|
||||
// mark-up was being copy-pasted inside Upload.tsx for both cards; this
|
||||
// file is the shared home.
|
||||
|
||||
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Tri-state validation indicator.
|
||||
*
|
||||
* - `passed` → green "Passed" with check icon
|
||||
* - `hasWarnings` → amber "Warnings" with triangle
|
||||
* - else → red "Failed" with X
|
||||
*/
|
||||
export function ValidationDot({
|
||||
passed,
|
||||
hasWarnings,
|
||||
}: {
|
||||
passed: boolean;
|
||||
hasWarnings?: boolean;
|
||||
}) {
|
||||
if (passed) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--success))] font-medium"
|
||||
title="Validation passed"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
Passed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (hasWarnings) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--warning))] font-medium"
|
||||
title="Validation passed with warnings"
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
Warnings
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-destructive font-medium"
|
||||
title="Validation failed"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
Failed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small label + value cell for the expanded-card detail grid.
|
||||
*/
|
||||
export function StatPill({
|
||||
label,
|
||||
value,
|
||||
mono = true,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-[13px]",
|
||||
mono && "display mono"
|
||||
)}
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment happy-dom
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useAppStore } from "@/store";
|
||||
import { ClaimCard837 } from "./ClaimCard837";
|
||||
import type { ClaimOutput } from "@/types";
|
||||
|
||||
// Tiny fixture — just enough to exercise the card's UI surface.
|
||||
const CLAIM: ClaimOutput = {
|
||||
claim_id: "CLM-TEST",
|
||||
subscriber: { first_name: "Jane", last_name: "Doe", member_id: "MEM-1" },
|
||||
payer: { name: "Test Payer", id: "P1" },
|
||||
billing_provider: { npi: "1234567890" },
|
||||
claim: {
|
||||
total_charge: 100,
|
||||
place_of_service: "11",
|
||||
frequency_code: "1",
|
||||
prior_auth: null,
|
||||
},
|
||||
service_lines: [
|
||||
{
|
||||
line_number: 1,
|
||||
procedure: { qualifier: "HC", code: "99213", modifiers: [] },
|
||||
charge: "100",
|
||||
units: "1",
|
||||
unit_type: "UN",
|
||||
service_date: "2026-06-01",
|
||||
},
|
||||
],
|
||||
diagnoses: [{ qualifier: "ABK", code: "J20.9" }],
|
||||
validation: { passed: true, errors: [], warnings: [] },
|
||||
};
|
||||
|
||||
function renderCard(
|
||||
props: Partial<React.ComponentProps<typeof ClaimCard837>> = {},
|
||||
): {
|
||||
container: HTMLDivElement;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root: Root = createRoot(container);
|
||||
const onToggleSelect = props.onToggleSelect ?? (() => {});
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
null,
|
||||
React.createElement(ClaimCard837, {
|
||||
claim: CLAIM,
|
||||
selected: false,
|
||||
onToggleSelect,
|
||||
...props,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClaimCard837 — checkbox + select interaction", () => {
|
||||
beforeEach(() => {
|
||||
useAppStore.setState({ parsedBatches: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a checkbox with checked={selected}", () => {
|
||||
const { container, unmount } = renderCard({ selected: true });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
);
|
||||
expect(cb).not.toBeNull();
|
||||
expect(cb!.checked).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders an unchecked checkbox when selected={false}", () => {
|
||||
const { container, unmount } = renderCard({ selected: false });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
);
|
||||
expect(cb!.checked).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking the checkbox fires onToggleSelect with the claim_id", () => {
|
||||
const onToggleSelect = vi.fn();
|
||||
const { container, unmount } = renderCard({ onToggleSelect });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
act(() => {
|
||||
cb.click();
|
||||
});
|
||||
expect(onToggleSelect).toHaveBeenCalledWith("CLM-TEST");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking the checkbox does NOT toggle the card's expand state", () => {
|
||||
// Regression guard: the card body is a <button> that toggles expand
|
||||
// on click. The new checkbox must not bubble its click into that
|
||||
// button (which would also fire onToggleSelect, double-counting).
|
||||
// We assert this by checking the expanded content (the service lines
|
||||
// table) is NOT in the DOM after a checkbox click.
|
||||
const { container, unmount } = renderCard();
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
act(() => {
|
||||
cb.click();
|
||||
});
|
||||
// The service lines table only renders when the card is expanded.
|
||||
const table = container.querySelector("table");
|
||||
expect(table).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking the card body still toggles expand (existing behavior preserved)", () => {
|
||||
const { container, unmount } = renderCard();
|
||||
// The card body is a <button aria-expanded="false">. Clicking it
|
||||
// should reveal the expanded details (service lines table).
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-expanded]',
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
act(() => {
|
||||
expandButton!.click();
|
||||
});
|
||||
// After expand, the service lines table is in the DOM.
|
||||
const table = container.querySelector("table");
|
||||
expect(table).not.toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("checkbox has an accessible label that names the claim", () => {
|
||||
const { container, unmount } = renderCard();
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
);
|
||||
// aria-label or wrapping <label> — at minimum the input must be
|
||||
// findable by an accessible name. The simplest assertion is that an
|
||||
// aria-label exists on the input itself.
|
||||
expect(
|
||||
cb!.getAttribute("aria-label") ||
|
||||
cb!.closest("label")?.textContent,
|
||||
).toContain("CLM-TEST");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
// ClaimCard837 — the warm-paper card that represents one 837P claim in
|
||||
// the Upload page's streaming section. Originally inlined in
|
||||
// `src/pages/Upload.tsx`; extracted into its own file in SP9 (June 2026)
|
||||
// so the per-claim select-for-export interaction can live here without
|
||||
// bloating the page.
|
||||
//
|
||||
// Two click targets, by design (see approach A in the batch-export
|
||||
// design spec): a leading checkbox column for selection, and the rest
|
||||
// of the card (a <button>) for expand/collapse. Putting the checkbox
|
||||
// inside the button would be an a11y anti-pattern (nested interactive
|
||||
// elements).
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAppStore } from "@/store";
|
||||
import { fmt, toNum } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ClaimOutput, ServiceLine } from "@/types";
|
||||
import { StatPill, ValidationDot } from "./ClaimCard/shared";
|
||||
|
||||
export interface ClaimCard837Props {
|
||||
claim: ClaimOutput;
|
||||
/**
|
||||
* Whether this card is currently selected for the batch export.
|
||||
* Controlled by the parent (Upload.tsx) via the per-claim checkbox.
|
||||
* The checkbox column only renders when the parent passes
|
||||
* `onToggleSelect` — keeps the component backward-compatible with
|
||||
* callers that don't need the export flow (e.g. the persisted
|
||||
* batch's "Recent batches" view, when we add it).
|
||||
*/
|
||||
selected?: boolean;
|
||||
/** Called with the claim's id when the user clicks the checkbox. */
|
||||
onToggleSelect?: (claimId: string) => void;
|
||||
}
|
||||
|
||||
export function ClaimCard837({ claim, selected = false, onToggleSelect }: ClaimCard837Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const passed = claim.validation.passed;
|
||||
const hasWarnings = claim.validation.warnings.length > 0;
|
||||
// SP21 Phase 5 Task 5.7: a "See claim in detail →" link drills to
|
||||
// /claims?claim=ID — but ONLY when this streamed claim_id has
|
||||
// actually been persisted to a parsed batch. The Upload page
|
||||
// streams claims as the parser emits them; until the user clicks
|
||||
// "Save batch" the claim isn't visible to ClaimDrawer.
|
||||
const parsedBatches = useAppStore((s) => s.parsedBatches);
|
||||
const persistedClaimIds = useMemo(
|
||||
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
|
||||
[parsedBatches],
|
||||
);
|
||||
const canDrill = persistedClaimIds.has(claim.claim_id);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const showCheckbox = typeof onToggleSelect === "function";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden border flex"
|
||||
style={{
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
boxShadow: "inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Selection column — only when the parent wires up selection. */}
|
||||
{showCheckbox ? (
|
||||
<label
|
||||
className="flex items-center pl-3 pr-2 cursor-pointer shrink-0"
|
||||
// Belt-and-suspenders: the label is a sibling of the button
|
||||
// below, so click events don't bubble into it. stopPropagation
|
||||
// here guards against future refactors that might nest the
|
||||
// label inside the button.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={`claim-card-select-${claim.claim_id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => onToggleSelect?.(claim.claim_id)}
|
||||
aria-label={`Select claim ${claim.claim_id} for export`}
|
||||
className="h-3.5 w-3.5 rounded border-border accent-accent cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 text-left px-4 py-3 hover:bg-[hsl(36_22%_92%)] transition-colors",
|
||||
!showCheckbox && "rounded-l-lg",
|
||||
)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform shrink-0",
|
||||
open && "rotate-90"
|
||||
)}
|
||||
strokeWidth={1.75}
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<span
|
||||
className="display mono text-[12.5px]"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{claim.claim_id}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] truncate"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{claim.subscriber.first_name} {claim.subscriber.last_name}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
· {claim.payer.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mono text-[10.5px] flex items-center gap-2 mt-1"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
<span>NPI {claim.billing_provider.npi}</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>
|
||||
{claim.service_lines.length} line
|
||||
{claim.service_lines.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>{claim.diagnoses.length} dx</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div
|
||||
className="display mono text-[14px]"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{fmt.usdDecimal(claim.claim.total_charge)}
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
<ValidationDot passed={passed} hasWarnings={hasWarnings} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="basis-full border-t px-4 py-3 grid gap-4 animate-fade-in"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.08)",
|
||||
backgroundColor: "hsl(36 22% 92%)",
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatPill label="Member" value={claim.subscriber.member_id} />
|
||||
<StatPill
|
||||
label="Place of service"
|
||||
value={claim.claim.place_of_service ?? "—"}
|
||||
/>
|
||||
<StatPill label="Frequency" value={claim.claim.frequency_code ?? "—"} />
|
||||
<StatPill
|
||||
label="Prior auth"
|
||||
value={claim.claim.prior_auth ?? "—"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{claim.diagnoses.length > 0 ? (
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Diagnoses
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{claim.diagnoses.map((d, i) => (
|
||||
<Badge key={`${d.code}-${i}`} variant="muted">
|
||||
{d.qualifier ? `${d.qualifier}·` : ""}
|
||||
{d.code}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{claim.service_lines.length > 0 ? (
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Service lines
|
||||
</div>
|
||||
<div
|
||||
className="rounded-md border overflow-x-auto"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
backgroundColor: "hsl(36 22% 98%)",
|
||||
}}
|
||||
>
|
||||
<table className="w-full text-[12px]">
|
||||
<thead style={{ backgroundColor: "hsl(36 22% 90%)" }}>
|
||||
<tr className="text-left" style={{ color: "hsl(var(--surface-ink-2))" }}>
|
||||
<th className="px-2.5 py-1.5 font-medium">#</th>
|
||||
<th className="px-2.5 py-1.5 font-medium">Code</th>
|
||||
<th className="px-2.5 py-1.5 font-medium">Mods</th>
|
||||
<th className="px-2.5 py-1.5 font-medium">Date</th>
|
||||
<th className="px-2.5 py-1.5 font-medium">Units</th>
|
||||
<th className="px-2.5 py-1.5 font-medium text-right">Charge</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{claim.service_lines.map((line) => (
|
||||
<ServiceLine837Row key={line.line_number} line={line} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(claim.validation.errors.length > 0 ||
|
||||
claim.validation.warnings.length > 0) ? (
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Validation
|
||||
</div>
|
||||
<ul className="space-y-1 text-[12px]">
|
||||
{claim.validation.errors.map((issue, i) => (
|
||||
<li
|
||||
key={`e-${i}`}
|
||||
className="flex items-start gap-2 text-destructive"
|
||||
>
|
||||
<XCircle
|
||||
className="h-3.5 w-3.5 mt-0.5 shrink-0"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
<span>
|
||||
<span className="mono">{issue.rule}</span> — {issue.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{claim.validation.warnings.map((issue, i) => (
|
||||
<li
|
||||
key={`w-${i}`}
|
||||
className="flex items-start gap-2 text-[hsl(var(--warning))]"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="h-3.5 w-3.5 mt-0.5 shrink-0"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
<span>
|
||||
<span className="mono">{issue.rule}</span> — {issue.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* SP21 Phase 5 Task 5.7: drill to the persisted claim. Only
|
||||
renders when this streamed claim_id has been saved into
|
||||
a parsed batch (so ClaimDrawer can find it). Before
|
||||
"Save batch" the claim is streaming-only and the link
|
||||
would 404. */}
|
||||
{canDrill ? (
|
||||
<div className="pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/claims?claim=${encodeURIComponent(claim.claim_id)}`,
|
||||
)
|
||||
}
|
||||
data-testid="upload-claim-drill"
|
||||
aria-label={`See claim ${claim.claim_id} in detail`}
|
||||
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
See claim in detail
|
||||
<ArrowRight className="h-3 w-3" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceLine837Row({ line }: { line: ServiceLine }) {
|
||||
return (
|
||||
<tr
|
||||
className="border-t"
|
||||
style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}
|
||||
>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{line.line_number}
|
||||
</td>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{line.procedure.qualifier}·{line.procedure.code}
|
||||
</td>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{line.procedure.modifiers.length > 0
|
||||
? line.procedure.modifiers.join(", ")
|
||||
: "—"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{line.service_date ?? "—"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono text-right display"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{fmt.usdDecimal(line.charge)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -141,12 +141,14 @@ export function ClaimDrawer({
|
||||
// Right-anchored side panel. We override the Dialog primitive's
|
||||
// default centered positioning (`left-1/2 top-1/2 -translate-x-1/2
|
||||
// -translate-y-1/2`) by re-asserting `right-0 top-0 translate-x-0
|
||||
// translate-y-0`. `h-full` + `rounded-none` + the left border
|
||||
// translate-y-0`. `h-full` + `rounded-none` + the left hairline
|
||||
// give it the visual identity of a drawer rather than a modal.
|
||||
// `bg-card` matches the rest of the dark instrument chrome; the
|
||||
// outer shadow is a soft directional falloff to the left.
|
||||
// `aria-describedby={undefined}` suppresses the Radix warning
|
||||
// about a missing description — the drawer content is its own
|
||||
// description and there's nothing useful to point at.
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)]/60 bg-[color:var(--m-surface)] p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border/60 bg-card p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
|
||||
data-testid="claim-drawer"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
@@ -167,7 +169,7 @@ export function ClaimDrawer({
|
||||
>
|
||||
<ClaimDrawerHeader claim={data} onClose={onClose} />
|
||||
<div
|
||||
className="flex gap-0 px-6 border-b border-[color:var(--surface-line)]/40"
|
||||
className="flex gap-0 px-6 border-b border-border/40"
|
||||
data-testid="claim-drawer-tabs"
|
||||
>
|
||||
<button
|
||||
@@ -176,15 +178,15 @@ export function ClaimDrawer({
|
||||
className={cn(
|
||||
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
||||
activeTab === "details"
|
||||
? "text-[color:var(--surface-ink)]"
|
||||
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
data-testid="tab-button-details"
|
||||
data-active={activeTab === "details" ? "true" : "false"}
|
||||
>
|
||||
Details
|
||||
{activeTab === "details" ? (
|
||||
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
|
||||
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
@@ -193,29 +195,29 @@ export function ClaimDrawer({
|
||||
className={cn(
|
||||
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
||||
activeTab === "line-reconciliation"
|
||||
? "text-[color:var(--surface-ink)]"
|
||||
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
data-testid="tab-button-line-reconciliation"
|
||||
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
|
||||
>
|
||||
Line Reconciliation
|
||||
{activeTab === "line-reconciliation" ? (
|
||||
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
|
||||
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === "line-reconciliation" ? (
|
||||
lr.loading ? (
|
||||
<p
|
||||
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="px-6 py-4 text-sm text-muted-foreground"
|
||||
data-testid="line-reconciliation-loading"
|
||||
>
|
||||
Loading line reconciliation…
|
||||
</p>
|
||||
) : lr.error ? (
|
||||
<p
|
||||
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="px-6 py-4 text-sm text-muted-foreground"
|
||||
data-testid="line-reconciliation-error"
|
||||
>
|
||||
Failed to load line reconciliation.
|
||||
@@ -224,7 +226,7 @@ export function ClaimDrawer({
|
||||
<LineReconciliationTab data={lr.data} />
|
||||
) : null
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-[color:var(--surface-line)]/12">
|
||||
<div className="flex flex-col divide-y divide-border/40">
|
||||
<ValidationPanel validation={data.validation} />
|
||||
<ServiceLinesTable
|
||||
serviceLines={data.serviceLines}
|
||||
|
||||
@@ -32,21 +32,21 @@ export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorPro
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-start gap-4 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
|
||||
className="flex flex-col items-start gap-4 p-6 bg-card text-foreground"
|
||||
role="alert"
|
||||
data-testid={`claim-drawer-error-${kind}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className="h-5 w-5 text-[color:var(--m-error)]"
|
||||
className="h-5 w-5 text-destructive"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="eyebrow text-[color:var(--m-error)]">
|
||||
<span className="eyebrow text-destructive">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{kind === "network" && onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
export function ClaimDrawerSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-6 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
|
||||
className="flex flex-col gap-6 p-6 bg-card text-foreground"
|
||||
data-testid="claim-drawer-skeleton"
|
||||
aria-busy="true"
|
||||
>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
||||
<section className="flex flex-col gap-3 px-6 py-4">
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
Diagnoses ({diagnoses.length})
|
||||
</h3>
|
||||
@@ -43,7 +43,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
||||
{diagnoses.length === 0 ? (
|
||||
<p
|
||||
data-testid="diagnoses-empty"
|
||||
className="text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
No diagnoses
|
||||
</p>
|
||||
@@ -58,17 +58,17 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
||||
className="flex items-baseline gap-3 text-sm"
|
||||
>
|
||||
<span
|
||||
className="mono text-[color:var(--m-ink-primary)] font-medium"
|
||||
className="mono text-foreground font-medium"
|
||||
>
|
||||
{d.qualifier ? `${d.qualifier} ${d.code}` : d.code}
|
||||
</span>
|
||||
{desc ? (
|
||||
<>
|
||||
<span
|
||||
className="h-px w-3 bg-[color:var(--m-border-heavy)]/30"
|
||||
className="h-px w-3 bg-border/60"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[color:var(--m-ink-secondary)] italic">
|
||||
<span className="text-muted-foreground italic">
|
||||
{desc}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -31,26 +31,26 @@ export function LineReconciliationTab({
|
||||
>
|
||||
<div className="flex gap-5 font-mono tabular-nums text-sm">
|
||||
<span data-testid="billed-total" className="flex flex-col gap-0.5">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
|
||||
<span className="text-muted-foreground eyebrow">
|
||||
Billed
|
||||
</span>
|
||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
||||
<span className="text-foreground font-semibold">
|
||||
{fmt.usdPrecise(Number(summary.billedTotal))}
|
||||
</span>
|
||||
</span>
|
||||
<span data-testid="paid-total" className="flex flex-col gap-0.5">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
|
||||
<span className="text-muted-foreground eyebrow">
|
||||
Paid
|
||||
</span>
|
||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
||||
<span className="text-foreground font-semibold">
|
||||
{fmt.usdPrecise(Number(summary.paidTotal))}
|
||||
</span>
|
||||
</span>
|
||||
<span data-testid="adjustments-total" className="flex flex-col gap-0.5">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
|
||||
<span className="text-muted-foreground eyebrow">
|
||||
Adjustments
|
||||
</span>
|
||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
||||
<span className="text-foreground font-semibold">
|
||||
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
|
||||
</span>
|
||||
</span>
|
||||
@@ -70,7 +70,7 @@ export function LineReconciliationTab({
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div data-testid="left-column">
|
||||
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
|
||||
<h3 className="eyebrow text-muted-foreground mb-2">
|
||||
Billed (837 SV1)
|
||||
</h3>
|
||||
{leftRows.map((row) => (
|
||||
@@ -78,7 +78,7 @@ export function LineReconciliationTab({
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="right-column">
|
||||
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
|
||||
<h3 className="eyebrow text-muted-foreground mb-2">
|
||||
Adjudicated (835 SVC)
|
||||
</h3>
|
||||
{rightRows.map((row) => (
|
||||
@@ -114,7 +114,7 @@ function LineCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-[color:var(--m-ink-tertiary)]/5"
|
||||
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-muted/40"
|
||||
style={{
|
||||
borderLeft: `3px solid ${accentColor}`,
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
@@ -123,9 +123,9 @@ function LineCard({
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="font-mono text-sm">
|
||||
<span className="font-semibold text-[color:var(--m-ink-primary)]">{procCode}</span>
|
||||
<span className="font-semibold text-foreground">{procCode}</span>
|
||||
{lineNumber !== null ? (
|
||||
<span className="text-[color:var(--m-ink-tertiary)] ml-2">
|
||||
<span className="text-muted-foreground ml-2">
|
||||
#{lineNumber}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -146,16 +146,16 @@ function LineCard({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-mono tabular-nums text-xs text-[color:var(--m-ink-primary)] font-semibold">
|
||||
<div className="font-mono tabular-nums text-xs text-foreground font-semibold">
|
||||
{side === "left" && cl ? <span>{fmt.usdPrecise(Number(cl.charge))}</span> : null}
|
||||
{side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{row.adjustments.length > 0 ? (
|
||||
<ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5">
|
||||
<ul className="text-xs font-mono text-muted-foreground mt-1 space-y-0.5">
|
||||
{row.adjustments.map((a, i) => (
|
||||
<li key={i}>
|
||||
<span className="text-[color:var(--m-ink-secondary)]">{a.groupCode}-{a.reasonCode}</span>
|
||||
<span className="text-foreground/80">{a.groupCode}-{a.reasonCode}</span>
|
||||
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -56,20 +56,20 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
Matched Remittance
|
||||
</h3>
|
||||
|
||||
<div
|
||||
data-testid="matched-remit-card-inner"
|
||||
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
className="flex flex-col gap-4 rounded-lg border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
{/* Left: remit id + status badge + received date */}
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<span
|
||||
data-testid="matched-remit-id"
|
||||
className="mono text-sm text-[color:var(--m-ink-primary)]"
|
||||
className="mono text-sm text-foreground"
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
@@ -83,7 +83,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
</Badge>
|
||||
<span
|
||||
data-testid="matched-remit-received"
|
||||
className="text-xs text-[color:var(--m-ink-tertiary)]"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Received {fmt.date(receivedAt)}
|
||||
</span>
|
||||
@@ -94,7 +94,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||
<span
|
||||
data-testid="matched-remit-total-paid"
|
||||
className="display text-3xl text-[color:var(--m-ink-primary)] tabular-nums"
|
||||
className="display text-3xl text-foreground tabular-nums"
|
||||
>
|
||||
{fmt.usdPrecise(totalPaid)}
|
||||
</span>
|
||||
@@ -103,7 +103,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
size="sm"
|
||||
onClick={handleViewRemittance}
|
||||
data-testid="view-remittance-link"
|
||||
className="gap-1 text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]"
|
||||
className="gap-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View remittance
|
||||
<ArrowRight
|
||||
|
||||
@@ -28,7 +28,7 @@ function AddressBlock({ address }: { address: AddressLike }) {
|
||||
const a = address as ClaimDetailAddress;
|
||||
return (
|
||||
<div
|
||||
className="mt-1 border-t border-dashed border-[color:var(--m-border-heavy)]/30 pt-2 text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
|
||||
className="mt-1 border-t border-dashed border-border/60 pt-2 text-xs leading-relaxed text-muted-foreground"
|
||||
data-testid="party-address"
|
||||
>
|
||||
<div>{a.line1}</div>
|
||||
@@ -63,9 +63,9 @@ function PartyCard({
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 px-4 py-3 transition-colors hover:border-[color:var(--m-border-heavy)]/50"
|
||||
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 transition-colors hover:border-border hover:bg-muted/40"
|
||||
>
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{onNameClick ? (
|
||||
@@ -74,17 +74,17 @@ function PartyCard({
|
||||
onClick={onNameClick}
|
||||
data-testid={`${testId}-name-drill`}
|
||||
aria-label={`Drill into ${label.toLowerCase()} ${name}`}
|
||||
className="display text-lg text-[color:var(--m-ink-primary)] text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||
className="display text-lg text-foreground text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<div className="display text-lg text-[color:var(--m-ink-primary)]">
|
||||
<div className="display text-lg text-foreground">
|
||||
{name}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
className="mono text-[12.5px] text-muted-foreground tabular-nums"
|
||||
>
|
||||
{identity}
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
Parties
|
||||
</h3>
|
||||
|
||||
@@ -33,17 +33,17 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
Raw Segments ({rawSegments.length})
|
||||
</h3>
|
||||
|
||||
<details className="group rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/40 overflow-hidden transition-colors hover:border-[color:var(--m-border-heavy)]/50">
|
||||
<details className="group rounded-lg border border-border/60 bg-muted/30 overflow-hidden transition-colors hover:border-border">
|
||||
<summary
|
||||
className="cursor-pointer select-none px-4 py-2.5 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)] transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
|
||||
className="cursor-pointer select-none px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
|
||||
data-testid="raw-segments-summary"
|
||||
>
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--m-ink-tertiary)] group-open:bg-[color:var(--m-accent)] transition-colors" />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground group-open:bg-accent transition-colors" />
|
||||
{rawSegments.length === 0
|
||||
? `Show raw segments`
|
||||
: `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`}
|
||||
@@ -52,14 +52,14 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
|
||||
{rawSegments.length === 0 ? (
|
||||
<p
|
||||
data-testid="raw-segments-empty"
|
||||
className="px-4 pb-3 text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="px-4 pb-3 text-sm text-muted-foreground"
|
||||
>
|
||||
No segments
|
||||
</p>
|
||||
) : (
|
||||
<pre
|
||||
data-testid="raw-segments-pre"
|
||||
className="overflow-x-auto whitespace-pre-wrap border-t border-[color:var(--m-border-heavy)]/20 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono"
|
||||
className="overflow-x-auto whitespace-pre-wrap border-t border-border/40 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-foreground font-mono"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{rawSegments.map((segment, i) => (
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ServiceLinesTable({
|
||||
<section className="flex flex-col gap-3 px-6 py-4">
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
Service Lines ({serviceLines.length})
|
||||
</h3>
|
||||
@@ -57,7 +57,7 @@ export function ServiceLinesTable({
|
||||
{serviceLines.length === 0 ? (
|
||||
<p
|
||||
data-testid="service-lines-empty"
|
||||
className="text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
No service lines
|
||||
</p>
|
||||
@@ -84,34 +84,34 @@ export function ServiceLinesTable({
|
||||
data-line-number={line.lineNumber}
|
||||
className="row-hover"
|
||||
>
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
|
||||
<TableCell className="font-mono text-muted-foreground">
|
||||
{line.lineNumber}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
|
||||
<TableCell className="font-mono text-foreground">
|
||||
{formatProcedure(line)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
|
||||
className="text-right font-mono text-base tabular-nums text-foreground font-semibold"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{fmt.usdPrecise(line.charge)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
|
||||
<TableCell className="font-mono text-muted-foreground">
|
||||
{formatUnits(line)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
|
||||
<TableCell className="font-mono text-muted-foreground">
|
||||
{line.serviceDate ? fmt.date(line.serviceDate) : ""}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
data-testid="paid-cell"
|
||||
className="text-right font-mono tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
className="text-right font-mono tabular-nums text-foreground"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{formatMoneyOrDash(lr?.paid ?? null)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
data-testid="adjustments-cell"
|
||||
className="text-right font-mono tabular-nums text-[color:var(--m-ink-secondary)]"
|
||||
className="text-right font-mono tabular-nums text-muted-foreground"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{formatMoneyOrDash(lr?.adjustmentsSum ?? null)}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
State History ({history.length})
|
||||
</h3>
|
||||
@@ -72,14 +72,14 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
{history.length === 0 ? (
|
||||
<p
|
||||
data-testid="state-history-empty"
|
||||
className="text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
No history events
|
||||
</p>
|
||||
) : (
|
||||
<ol
|
||||
data-testid="state-history-timeline"
|
||||
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-[color:var(--m-border-heavy)]/40 pl-6"
|
||||
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-border/40 pl-6"
|
||||
>
|
||||
{history.map((event, i) => (
|
||||
<li
|
||||
@@ -91,18 +91,18 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
<span
|
||||
data-testid="state-history-dot"
|
||||
aria-hidden
|
||||
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-[color:var(--m-surface)] ${dotColorFor(event.kind)}`}
|
||||
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-card ${dotColorFor(event.kind)}`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span
|
||||
data-testid="state-history-kind"
|
||||
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-primary)]"
|
||||
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-foreground"
|
||||
>
|
||||
{kindLabel(event.kind)}
|
||||
</span>
|
||||
<span
|
||||
data-testid="state-history-ts"
|
||||
className="mono text-xs text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
className="mono text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
{fmt.date(event.ts)} · {fmt.time(event.ts)}
|
||||
</span>
|
||||
@@ -110,7 +110,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
{event.remittanceId ? (
|
||||
<span
|
||||
data-testid="history-remit-id"
|
||||
className="mono text-xs text-[color:var(--m-ink-tertiary)]"
|
||||
className="mono text-xs text-muted-foreground"
|
||||
>
|
||||
↳ Remit {event.remittanceId}
|
||||
</span>
|
||||
|
||||
@@ -63,12 +63,12 @@ function IssueGroup({
|
||||
onClick={() => openPeek({ kind: "rule", rule })}
|
||||
data-testid={`${testId}-rule-drill`}
|
||||
aria-label={`Drill into rule ${rule}`}
|
||||
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)] cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||
className="mono text-[12px] font-semibold tracking-tight text-foreground cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||
>
|
||||
{rule}
|
||||
</button>
|
||||
<span
|
||||
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
className="inline-flex items-center rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground tabular-nums"
|
||||
data-testid={`${testId}-count`}
|
||||
>
|
||||
{issues.length}
|
||||
@@ -78,7 +78,7 @@ function IssueGroup({
|
||||
{issues.map((issue, idx) => (
|
||||
<li
|
||||
key={`${rule}-${idx}`}
|
||||
className="flex items-start gap-2 text-[13px] leading-snug text-[color:var(--m-ink-secondary)]"
|
||||
className="flex items-start gap-2 text-[13px] leading-snug text-muted-foreground"
|
||||
data-testid={`${testId}-message`}
|
||||
>
|
||||
<Icon
|
||||
@@ -121,14 +121,14 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
className="flex items-center gap-2 px-6 py-3"
|
||||
>
|
||||
<CheckCircle2
|
||||
className="h-4 w-4 text-[color:var(--m-success)]"
|
||||
className="h-4 w-4 text-[hsl(var(--success))]"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[13px] font-medium text-[color:var(--m-ink-primary)]">
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
All checks passed
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center rounded-full bg-[color:var(--m-success)]/12 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-success)]">
|
||||
<span className="ml-auto inline-flex items-center rounded-full bg-[hsl(var(--success)/0.12)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[hsl(var(--success))]">
|
||||
Valid
|
||||
</span>
|
||||
</section>
|
||||
@@ -140,10 +140,10 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4 px-6 py-4 bg-[color:var(--m-surface)]"
|
||||
className="flex flex-col gap-4 px-6 py-4"
|
||||
data-testid="validation-panel"
|
||||
>
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-muted-foreground">
|
||||
Validation
|
||||
</span>
|
||||
|
||||
@@ -152,15 +152,15 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
data-testid="validation-errors"
|
||||
data-rule-group="errors"
|
||||
role="alert"
|
||||
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.06)] px-3.5 py-3"
|
||||
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.08)] px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle
|
||||
className="h-3.5 w-3.5 text-[color:var(--m-error)]"
|
||||
className="h-3.5 w-3.5 text-destructive"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-error)]">
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-destructive">
|
||||
Errors ({validation.errors.length})
|
||||
</span>
|
||||
</div>
|
||||
@@ -185,11 +185,11 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle
|
||||
className="h-3.5 w-3.5 text-[color:var(--m-warning)]"
|
||||
className="h-3.5 w-3.5 text-[hsl(var(--warning))]"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-warning)]">
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[hsl(var(--warning))]">
|
||||
Warnings ({validation.warnings.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// DominantKpiCard — the headline KPI tile.
|
||||
//
|
||||
// One KPI gets the dominant slot on the dashboard. It carries:
|
||||
// - a thicker accent rule on the left edge
|
||||
// - an oversized Instrument Serif number
|
||||
// - a wide sparkline (not a 28px afterthought)
|
||||
// - delta + hint row in mono
|
||||
// Same paper variant as the smaller KpiCard. Designed to live INSIDE
|
||||
// the statement, next to a column of 4 smaller readouts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { ArrowDownRight, ArrowUpRight, type LucideIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
import { AnimatedNumber } from "./AnimatedNumber";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DominantKpiCardProps {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
/** Pre-rendered value node. Required unless `rawValue`+`format`
|
||||
* are both supplied, in which case the AnimatedNumber is used. */
|
||||
value?: React.ReactNode;
|
||||
/** Used when value is a plain number — drives the AnimatedNumber. */
|
||||
rawValue?: number;
|
||||
/** Formatter for the AnimatedNumber. */
|
||||
format?: (n: number) => string;
|
||||
delta?: { value: string; direction: "up" | "down"; positive: boolean };
|
||||
hint?: string;
|
||||
sparkline?: number[];
|
||||
/** Sparkline stroke colour. Defaults to accent. */
|
||||
sparklineColor?: string;
|
||||
/** Accent rail colour (left edge). Defaults to accent. */
|
||||
accent?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function DominantKpiCard({
|
||||
label,
|
||||
icon: Icon,
|
||||
value,
|
||||
rawValue,
|
||||
format,
|
||||
delta,
|
||||
hint,
|
||||
sparkline,
|
||||
sparklineColor = "hsl(var(--accent))",
|
||||
accent = "hsl(var(--accent))",
|
||||
className,
|
||||
style,
|
||||
}: DominantKpiCardProps) {
|
||||
const computedStyle: React.CSSProperties = {
|
||||
backgroundColor: "hsl(var(--surface))",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={computedStyle}
|
||||
className={cn(
|
||||
"relative rounded-xl p-6 flex flex-col gap-4 overflow-hidden",
|
||||
"border border-[hsl(30_14%_14%/_0.10)] hover:bg-[hsl(36_22%_92%)] transition-colors",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Accent rail on the left — thicker than the small cards */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
|
||||
style={{ backgroundColor: accent, opacity: 0.85 }}
|
||||
/>
|
||||
|
||||
{/* Header row: label + icon */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className="eyebrow flex items-center gap-2"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{hint ? (
|
||||
<span
|
||||
className="mono normal-case tracking-normal"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
fontSize: 10,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
· {hint}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{Icon ? (
|
||||
<div
|
||||
className="h-7 w-7 rounded-md flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: "hsl(36 22% 90%)",
|
||||
boxShadow: "inset 0 0 0 1px hsl(30 14% 22% / 0.08)",
|
||||
color: "hsl(var(--surface-ink-2))",
|
||||
}}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Big number */}
|
||||
<div
|
||||
className="display tabular-nums tracking-[-0.04em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(48px, 6vw, 72px)",
|
||||
lineHeight: 0.92,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{rawValue !== undefined && format ? (
|
||||
<AnimatedNumber value={rawValue} format={format} />
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delta + sparkline */}
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-[11px] min-h-[16px]">
|
||||
{delta ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 font-medium mono",
|
||||
delta.positive
|
||||
? "text-[hsl(var(--success))]"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{delta.direction === "up" ? (
|
||||
<ArrowUpRight className="h-3 w-3" strokeWidth={2} />
|
||||
) : (
|
||||
<ArrowDownRight className="h-3 w-3" strokeWidth={2} />
|
||||
)}
|
||||
{delta.value}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="mono uppercase tracking-[0.14em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
vs last 6 mo
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sparkline ? (
|
||||
<div className="flex-1 max-w-[240px]">
|
||||
<Sparkline values={sparkline} stroke={sparklineColor} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// EditorialNote — italic Instrument Serif margin annotations.
|
||||
//
|
||||
// A small italicized serif note that "speaks" to the data next to it.
|
||||
// Used in the dashboard to narrate the day's narrative: "Three NPIs
|
||||
// are in flight" sits next to the KPI grid; an editorial note sits
|
||||
// beside the chart explaining what it shows. The note itself is a
|
||||
// single italic line of Instrument Serif with a tiny serif ampersand
|
||||
// or hairline rule introducing it.
|
||||
//
|
||||
// This is the dashboard's "voice" — the moment the data stops being
|
||||
// silent and reads as a story.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface EditorialNoteProps {
|
||||
children: React.ReactNode;
|
||||
/** Optional lead-in (e.g. "↘", "§", "—" or a short label). */
|
||||
lead?: string;
|
||||
/** Optional secondary line shown below the note in mono caps. */
|
||||
caption?: string;
|
||||
/** Tone — dark canvas (default) or paper. */
|
||||
tone?: "dark" | "paper";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EditorialNote({
|
||||
children,
|
||||
lead,
|
||||
caption,
|
||||
tone = "dark",
|
||||
className,
|
||||
}: EditorialNoteProps) {
|
||||
const noteColor =
|
||||
tone === "paper"
|
||||
? "hsl(var(--surface-ink))"
|
||||
: "hsl(var(--foreground))";
|
||||
const leadColor =
|
||||
tone === "paper"
|
||||
? "hsl(var(--surface-ink-3))"
|
||||
: "hsl(var(--muted-foreground))";
|
||||
const captionColor =
|
||||
tone === "paper"
|
||||
? "hsl(var(--surface-ink-3))"
|
||||
: "hsl(var(--muted-foreground))";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-start gap-3", className)}>
|
||||
{lead ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="display italic shrink-0 leading-none mt-1"
|
||||
style={{ color: leadColor, fontSize: 18 }}
|
||||
>
|
||||
{lead}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="display italic leading-snug"
|
||||
style={{
|
||||
color: noteColor,
|
||||
fontSize: 15,
|
||||
letterSpacing: "-0.005em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
{caption ? (
|
||||
<p
|
||||
className="mono uppercase tracking-[0.18em] mt-1.5"
|
||||
style={{ color: captionColor, fontSize: 9.5 }}
|
||||
>
|
||||
{caption}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment happy-dom
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act, useState } from "react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { ExportBar } from "./ExportBar";
|
||||
|
||||
function renderBar(
|
||||
props: Partial<React.ComponentProps<typeof ExportBar>> = {},
|
||||
): {
|
||||
container: HTMLDivElement;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root: Root = createRoot(container);
|
||||
const onToggleAll = props.onToggleAll ?? (() => {});
|
||||
const onExport = props.onExport ?? (() => {});
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(ExportBar, {
|
||||
total: 10,
|
||||
selectedCount: 10,
|
||||
exporting: false,
|
||||
onToggleAll,
|
||||
onExport,
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ExportBar", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the total and selected count", () => {
|
||||
const { container, unmount } = renderBar({ total: 24, selectedCount: 18 });
|
||||
expect(container.textContent).toContain("18");
|
||||
expect(container.textContent).toContain("24");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the select-all checkbox is checked when all items are selected", () => {
|
||||
const { container, unmount } = renderBar({ total: 5, selectedCount: 5 });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
expect(cb.checked).toBe(true);
|
||||
expect(cb.indeterminate).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the select-all checkbox is unchecked when nothing is selected", () => {
|
||||
const { container, unmount } = renderBar({ total: 5, selectedCount: 0 });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
expect(cb.checked).toBe(false);
|
||||
expect(cb.indeterminate).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the select-all checkbox is indeterminate when some items are selected", () => {
|
||||
const { container, unmount } = renderBar({ total: 5, selectedCount: 2 });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
expect(cb.checked).toBe(false);
|
||||
expect(cb.indeterminate).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking the select-all checkbox calls onToggleAll", () => {
|
||||
const onToggleAll = vi.fn();
|
||||
const { container, unmount } = renderBar({ onToggleAll });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
act(() => {
|
||||
cb.click();
|
||||
});
|
||||
expect(onToggleAll).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the export button is disabled when selectedCount is 0", () => {
|
||||
const { container, unmount } = renderBar({ selectedCount: 0 });
|
||||
const btn = container.querySelector<HTMLButtonElement>("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the export button is enabled when selectedCount > 0", () => {
|
||||
const { container, unmount } = renderBar({ selectedCount: 3 });
|
||||
const btn = container.querySelector<HTMLButtonElement>("button")!;
|
||||
expect(btn.disabled).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the export button is disabled while exporting", () => {
|
||||
const { container, unmount } = renderBar({ selectedCount: 3, exporting: true });
|
||||
const btn = container.querySelector<HTMLButtonElement>("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking the export button calls onExport", () => {
|
||||
const onExport = vi.fn();
|
||||
const { container, unmount } = renderBar({ onExport, selectedCount: 3 });
|
||||
const btn = container.querySelector<HTMLButtonElement>("button")!;
|
||||
act(() => {
|
||||
btn.click();
|
||||
});
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders the selected count in the export button label", () => {
|
||||
const { container, unmount } = renderBar({ selectedCount: 7 });
|
||||
const btn = container.querySelector<HTMLButtonElement>("button")!;
|
||||
expect(btn.textContent).toContain("7");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// ExportBar — sticky bar that sits at the top of the Upload page's
|
||||
// streaming section and lets the user select-all / deselect-all the
|
||||
// streamed claims and trigger the batch X12 export.
|
||||
//
|
||||
// Tri-state "Select all" checkbox:
|
||||
// - all selected → checked
|
||||
// - none selected → unchecked
|
||||
// - mixed → indeterminate (the dash / line state)
|
||||
//
|
||||
// The Export button is disabled when the selection is empty (no point
|
||||
// sending an empty bundle) or when an export is already in flight
|
||||
// (don't double-fire the network call).
|
||||
|
||||
import { CheckSquare, Download, Loader2, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ExportBarProps {
|
||||
/** Total claims in the stream (denominator for "X of Y selected"). */
|
||||
total: number;
|
||||
/** Current selection size (numerator). */
|
||||
selectedCount: number;
|
||||
/** When true, the export button shows a spinner and is disabled. */
|
||||
exporting: boolean;
|
||||
/** Called when the user toggles the select-all checkbox. */
|
||||
onToggleAll: () => void;
|
||||
/** Called when the user clicks the Export button. */
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
export function ExportBar({
|
||||
total,
|
||||
selectedCount,
|
||||
exporting,
|
||||
onToggleAll,
|
||||
onExport,
|
||||
}: ExportBarProps) {
|
||||
const allSelected = total > 0 && selectedCount === total;
|
||||
const noneSelected = selectedCount === 0;
|
||||
// Indeterminate = some selected, but not all. Bound to the input via
|
||||
// a ref because the `indeterminate` attribute is not a real HTML
|
||||
// attribute — it's a JS property only.
|
||||
const indeterminate = !allSelected && !noneSelected;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-3 py-2 rounded-md border bg-background/40",
|
||||
"flex-wrap",
|
||||
)}
|
||||
style={{ borderColor: "hsl(var(--border) / 0.6)" }}
|
||||
data-testid="export-bar"
|
||||
>
|
||||
<label
|
||||
className="inline-flex items-center gap-2 cursor-pointer select-none text-[12px] mono uppercase tracking-[0.14em] font-semibold text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="export-bar-toggle-all"
|
||||
>
|
||||
{allSelected ? (
|
||||
<CheckSquare
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
style={{ color: "hsl(var(--accent))" }}
|
||||
/>
|
||||
) : (
|
||||
<Square
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
style={{
|
||||
color: indeterminate
|
||||
? "hsl(var(--accent) / 0.7)"
|
||||
: "hsl(var(--muted-foreground) / 0.6)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = indeterminate;
|
||||
}}
|
||||
onChange={onToggleAll}
|
||||
className="sr-only"
|
||||
aria-label={
|
||||
allSelected
|
||||
? "Deselect all claims"
|
||||
: noneSelected
|
||||
? "Select all claims"
|
||||
: `Select all claims (${selectedCount} of ${total} currently selected)`
|
||||
}
|
||||
/>
|
||||
{allSelected ? "Deselect all" : "Select all"}
|
||||
</label>
|
||||
|
||||
<span
|
||||
className="text-[11.5px] mono text-muted-foreground/70"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="text-foreground">{selectedCount}</span> of{" "}
|
||||
<span className="text-foreground">{total}</span> selected
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={onExport}
|
||||
disabled={noneSelected || exporting}
|
||||
data-testid="export-bar-export"
|
||||
aria-label={`Export ${selectedCount} claims as X12 ZIP`}
|
||||
>
|
||||
{exporting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Exporting…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export ({selectedCount})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,24 @@ export function Layout() {
|
||||
return (
|
||||
<div className="relative min-h-screen z-10">
|
||||
<SkipLink />
|
||||
{/* Ambient page halo — a soft, fixed radial that creates a
|
||||
subtle lighter zone behind the page content (centered
|
||||
just below the top bar). Pure decoration; pointer-events
|
||||
disabled so it never intercepts clicks. Two layered
|
||||
radials: a neutral softbox centered above the page
|
||||
header, and a cool accent in the upper-right that
|
||||
mirrors the body::before composition. The halo sits
|
||||
behind everything else (z-0 inside the z-10 root). */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[70vh]"
|
||||
style={{
|
||||
background: `
|
||||
radial-gradient(ellipse 55% 30% at 50% 6%, hsla(220, 32%, 30%, 0.16), transparent 70%),
|
||||
radial-gradient(ellipse 35% 25% at 82% 10%, hsla(212, 70%, 60%, 0.10), transparent 65%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none transition-opacity duration-200"
|
||||
style={{ opacity: showScan ? 1 : 0 }}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// TickerTape — live vital-signs strip for the dashboard hero.
|
||||
//
|
||||
// A horizontal tape of small numeric readouts separated by hairline
|
||||
// dividers, rendered on the dark canvas. Each cell shows a label
|
||||
// (uppercase mono) and a value (mono). One cell can be marked
|
||||
// `accent` to draw the eye, and the optional `live` cell shows a
|
||||
// pulsing dot — the dashboard's only acknowledgement of time.
|
||||
//
|
||||
// This is a static ticker (not scrolling); the cells are just laid
|
||||
// out left-to-right with dividers. We use it to give the dark hero
|
||||
// data density without committing to a real chart.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TickerCell {
|
||||
/** Stable id used as React key. */
|
||||
id: string;
|
||||
/** Uppercase mono label (e.g. "Billed", "Pending", "Denial"). */
|
||||
label: string;
|
||||
/** The value, rendered verbatim in mono. Format it before passing. */
|
||||
value: string;
|
||||
/** Optional secondary value shown muted (e.g. delta). */
|
||||
delta?: string;
|
||||
/** Highlight this cell with the accent colour. */
|
||||
accent?: boolean;
|
||||
}
|
||||
|
||||
export interface TickerTapeProps {
|
||||
cells: TickerCell[];
|
||||
/** Show the "LIVE" pulse on the leading edge. */
|
||||
live?: boolean;
|
||||
/** Live label (default "Live"). */
|
||||
liveLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TickerTape({
|
||||
cells,
|
||||
live = false,
|
||||
liveLabel = "Live",
|
||||
className,
|
||||
}: TickerTapeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-stretch w-full overflow-hidden",
|
||||
"rounded-md border border-border/50 bg-card/40 backdrop-blur",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left rail — amber signal accent for the LIVE indicator */}
|
||||
{live ? (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 border-r border-border/60 shrink-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, hsl(36 92% 56% / 0.06), transparent)",
|
||||
}}
|
||||
>
|
||||
<span className="relative inline-flex h-1.5 w-1.5 shrink-0">
|
||||
<span
|
||||
className="absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping"
|
||||
style={{ backgroundColor: "hsl(var(--signal))" }}
|
||||
/>
|
||||
<span
|
||||
className="relative inline-flex h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: "hsl(var(--signal))" }}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="mono uppercase tracking-[0.18em] font-semibold"
|
||||
style={{
|
||||
color: "hsl(var(--signal))",
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{liveLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Cells */}
|
||||
<div className="flex items-stretch flex-1 min-w-0 overflow-x-auto">
|
||||
{cells.map((c, i) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"flex-1 min-w-[110px] px-4 py-2.5 flex flex-col gap-0.5",
|
||||
i > 0 ? "border-l border-border/40" : ""
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mono uppercase tracking-[0.18em] font-medium"
|
||||
style={{
|
||||
color: c.accent
|
||||
? "hsl(var(--accent))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
fontSize: 9.5,
|
||||
}}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className="display mono tabular-nums"
|
||||
style={{
|
||||
color: c.accent
|
||||
? "hsl(var(--foreground))"
|
||||
: "hsl(var(--foreground))",
|
||||
fontSize: 16,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{c.value}
|
||||
</span>
|
||||
{c.delta ? (
|
||||
<span
|
||||
className="mono tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: 10.5,
|
||||
}}
|
||||
>
|
||||
{c.delta}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right rail — perforated tick pattern, decoratively marks the
|
||||
end of the tape without using a heavy border. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="shrink-0 w-3 self-stretch"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, hsl(36 14% 88% / 0.16) 1px, transparent 1.5px)",
|
||||
backgroundSize: "6px 6px",
|
||||
backgroundPosition: "0 center",
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent, black 30%, black 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent, black 30%, black 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -102,10 +102,15 @@ export function Lane({
|
||||
<section
|
||||
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
|
||||
style={{
|
||||
background: "var(--tt-bg-elev)",
|
||||
// "Lit from above" gradient — same treatment as the main
|
||||
// .surface cards, in the inbox's cool/blue-grey hue family.
|
||||
// Each lane becomes its own small lighter area against the
|
||||
// surrounding ticker-tape background.
|
||||
background:
|
||||
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
|
||||
border: "1px solid hsl(220 8% 18%)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.03), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
|
||||
}}
|
||||
data-lane={name.toLowerCase()}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user