feat(sp6): Lane component with multi-select
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { Lane } from "./Lane";
|
||||||
|
import type { InboxClaimRow } from "@/lib/inbox-api";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const sampleRows: InboxClaimRow[] = [
|
||||||
|
{
|
||||||
|
id: "CLP-1",
|
||||||
|
kind: "claim",
|
||||||
|
patient_control_number: "PCN-1",
|
||||||
|
charge_amount: 100,
|
||||||
|
payer_id: "PAYER-A",
|
||||||
|
provider_npi: "1234567890",
|
||||||
|
state: "rejected",
|
||||||
|
rejection_reason: "R69",
|
||||||
|
rejected_at: "2026-06-20T09:00:00Z",
|
||||||
|
service_date_from: null,
|
||||||
|
score: null,
|
||||||
|
score_tier: null,
|
||||||
|
score_breakdown: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("Lane", () => {
|
||||||
|
it("renders the lane name and row count", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Lane name="REJECTED" accent="oxblood" rows={sampleRows} onRowClick={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("REJECTED");
|
||||||
|
expect(container.textContent).toContain("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty state when no rows", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Lane name="DONE" accent="muted" rows={[]} onRowClick={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(container.textContent).toMatch(/nothing here/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports multi-select via row checkbox toggle", () => {
|
||||||
|
const onSelectionChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<Lane
|
||||||
|
name="REJECTED"
|
||||||
|
accent="oxblood"
|
||||||
|
rows={sampleRows}
|
||||||
|
onRowClick={() => {}}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const cb = screen.getByRole("checkbox");
|
||||||
|
fireEvent.click(cb);
|
||||||
|
expect(onSelectionChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { InboxRow, type Accent } from "./InboxRow";
|
||||||
|
import type { InboxClaimRow, InboxCandidateRow } from "@/lib/inbox-api";
|
||||||
|
|
||||||
|
export type LaneRow = InboxClaimRow | InboxCandidateRow;
|
||||||
|
|
||||||
|
export function Lane({
|
||||||
|
name,
|
||||||
|
accent,
|
||||||
|
rows,
|
||||||
|
onRowClick,
|
||||||
|
onSelectionChange,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
accent: Accent;
|
||||||
|
rows: LaneRow[];
|
||||||
|
onRowClick: (row: LaneRow) => void;
|
||||||
|
onSelectionChange?: (ids: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
function rowId(row: LaneRow): string {
|
||||||
|
return row.kind === "remit" ? (row as InboxCandidateRow).payer_claim_control_number : row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
setSelected(next);
|
||||||
|
onSelectionChange?.(Array.from(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex-1 min-w-[280px] flex flex-col"
|
||||||
|
style={{
|
||||||
|
background: "var(--tt-bg-elev)",
|
||||||
|
border: "1px solid var(--tt-bg)",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
data-lane={name.toLowerCase()}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className="px-3 py-2 flex items-baseline justify-between"
|
||||||
|
style={{ borderBottom: "1px solid var(--tt-bg)" }}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="font-mono uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--tt-ink)", fontSize: 11, letterSpacing: "0.12em" }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
className="font-mono tabular-nums"
|
||||||
|
style={{ color: "var(--tt-amber)", fontSize: 18 }}
|
||||||
|
>
|
||||||
|
{rows.length}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="p-4 font-mono"
|
||||||
|
style={{ color: "var(--tt-muted)", fontSize: 11 }}
|
||||||
|
>
|
||||||
|
Nothing here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="w-full" style={{ fontSize: 12 }}>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const id = rowId(row);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="flex items-stretch"
|
||||||
|
style={{ borderBottom: "1px solid var(--tt-bg)" }}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
style={{ width: 28, padding: "0 4px 0 8px" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Select ${id}`}
|
||||||
|
checked={selected.has(id)}
|
||||||
|
onChange={() => toggle(id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div style={{ width: "100%" }}>
|
||||||
|
<InboxRow row={row} accent={accent} onClick={() => onRowClick(row)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user