feat(sp6): bulk action bar wired into lanes
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { BulkBar } from "./BulkBar";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe("BulkBar", () => {
|
||||||
|
it("renders action buttons appropriate for the lane", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<BulkBar
|
||||||
|
lane="rejected"
|
||||||
|
count={3}
|
||||||
|
onResubmit={vi.fn()}
|
||||||
|
onDismiss={vi.fn()}
|
||||||
|
onExport={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.textContent).toMatch(/re-?submit/i);
|
||||||
|
expect(container.textContent).toMatch(/export/i);
|
||||||
|
expect(container.textContent).not.toMatch(/dismiss/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onResubmit when Resubmit clicked", () => {
|
||||||
|
const onResubmit = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<BulkBar
|
||||||
|
lane="rejected"
|
||||||
|
count={2}
|
||||||
|
onResubmit={onResubmit}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
onExport={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const buttons = container.querySelectorAll("button");
|
||||||
|
const resubmitBtn = Array.from(buttons).find((b) => /re-?submit/i.test(b.textContent || ""));
|
||||||
|
expect(resubmitBtn).toBeDefined();
|
||||||
|
fireEvent.click(resubmitBtn!);
|
||||||
|
expect(onResubmit).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides when count is zero", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<BulkBar
|
||||||
|
lane="rejected"
|
||||||
|
count={0}
|
||||||
|
onResubmit={() => {}}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
onExport={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export function BulkBar({
|
||||||
|
lane,
|
||||||
|
count,
|
||||||
|
onResubmit,
|
||||||
|
onDismiss,
|
||||||
|
onExport,
|
||||||
|
}: {
|
||||||
|
lane: "rejected" | "candidates" | "unmatched" | "done_today";
|
||||||
|
count: number;
|
||||||
|
onResubmit: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onExport: () => void;
|
||||||
|
}) {
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 left-1/2 px-4 py-2 font-mono flex gap-3 z-20"
|
||||||
|
style={{
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
background: "var(--tt-bg-elev)",
|
||||||
|
border: "1px solid var(--tt-amber)",
|
||||||
|
color: "var(--tt-ink)",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="tabular-nums" style={{ color: "var(--tt-amber)" }}>
|
||||||
|
{count} selected
|
||||||
|
</span>
|
||||||
|
{lane === "rejected" && (
|
||||||
|
<button
|
||||||
|
onClick={onResubmit}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: "var(--tt-ink)" }}
|
||||||
|
>
|
||||||
|
Re-submit ({count})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{lane === "candidates" && (
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: "var(--tt-ink)" }}
|
||||||
|
>
|
||||||
|
Dismiss ({count})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onExport}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: "var(--tt-ink)" }}
|
||||||
|
>
|
||||||
|
Export CSV ({count})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+114
-9
@@ -2,16 +2,69 @@
|
|||||||
// Inbox page (sub-project 6).
|
// Inbox page (sub-project 6).
|
||||||
//
|
//
|
||||||
// Full-bleed dark working surface for the four-lane triage workflow.
|
// Full-bleed dark working surface for the four-lane triage workflow.
|
||||||
// The page itself is a thin wrapper: data via useInboxLanes, layout via
|
// Wires useInboxLanes + Lane + InboxHeader + BulkBar. Bulk actions call
|
||||||
// Lane / InboxHeader. Bulk actions are wired in T18.
|
// the inbox API client and refetch on completion.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
||||||
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
||||||
|
import { BulkBar } from "@/components/inbox/BulkBar";
|
||||||
import { useInboxLanes } from "@/hooks/useInboxLanes";
|
import { useInboxLanes } from "@/hooks/useInboxLanes";
|
||||||
|
import {
|
||||||
|
exportInboxCsvUrl,
|
||||||
|
dismissCandidates,
|
||||||
|
resubmitRejected,
|
||||||
|
} from "@/lib/inbox-api";
|
||||||
|
|
||||||
|
type LaneKey = "rejected" | "candidates" | "unmatched" | "done_today";
|
||||||
|
|
||||||
|
function rowKey(row: LaneRow): string {
|
||||||
|
return row.kind === "remit" ? (row as { payer_claim_control_number: string }).payer_claim_control_number : row.id;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Inbox() {
|
export default function Inbox() {
|
||||||
const { lanes, loading, error } = useInboxLanes();
|
const { lanes, loading, error, refetch } = useInboxLanes();
|
||||||
|
const [selected, setSelected] = useState<Record<LaneKey, string[]>>({
|
||||||
|
rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
function setLaneSelected(lane: LaneKey, ids: string[]) {
|
||||||
|
setSelected((prev) => ({ ...prev, [lane]: ids }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onResubmit() {
|
||||||
|
if (selected.rejected.length === 0) return;
|
||||||
|
await resubmitRejected(selected.rejected);
|
||||||
|
setSelected((prev) => ({ ...prev, rejected: [] }));
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDismiss() {
|
||||||
|
if (selected.candidates.length === 0) return;
|
||||||
|
// Pair each selected remit with its first candidate claim (the row's
|
||||||
|
// top match). This matches the dismiss UX described in the spec: the
|
||||||
|
// user has already seen the top score, so we trust it for dismissal.
|
||||||
|
const pairs: Array<{ claim_id: string; remit_id: string }> = [];
|
||||||
|
for (const r of lanes.candidates) {
|
||||||
|
if (!selected.candidates.includes(rowKey(r))) continue;
|
||||||
|
const top = r.candidates[0];
|
||||||
|
if (top) pairs.push({ claim_id: top.claim_id, remit_id: r.id });
|
||||||
|
}
|
||||||
|
if (pairs.length) {
|
||||||
|
await dismissCandidates(pairs);
|
||||||
|
setSelected((prev) => ({ ...prev, candidates: [] }));
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExport(lane: LaneKey) {
|
||||||
|
if (selected[lane].length === 0) return;
|
||||||
|
window.open(exportInboxCsvUrl(lane), "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -38,8 +91,6 @@ export default function Inbox() {
|
|||||||
const needEyes =
|
const needEyes =
|
||||||
lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length;
|
lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length;
|
||||||
|
|
||||||
const noop = (_r: LaneRow) => {};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen"
|
className="min-h-screen"
|
||||||
@@ -47,11 +98,65 @@ export default function Inbox() {
|
|||||||
>
|
>
|
||||||
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
||||||
<main className="p-4 flex gap-4 items-start flex-wrap">
|
<main className="p-4 flex gap-4 items-start flex-wrap">
|
||||||
<Lane name="REJECTED" accent="oxblood" rows={lanes.rejected} onRowClick={noop} />
|
<Lane
|
||||||
<Lane name="CANDIDATES" accent="amber" rows={lanes.candidates} onRowClick={noop} />
|
name="REJECTED"
|
||||||
<Lane name="UNMATCHED" accent="ink-blue" rows={lanes.unmatched} onRowClick={noop} />
|
accent="oxblood"
|
||||||
<Lane name="DONE" accent="muted" rows={lanes.done_today} onRowClick={noop} />
|
rows={lanes.rejected}
|
||||||
|
onRowClick={() => {}}
|
||||||
|
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
|
||||||
|
/>
|
||||||
|
<Lane
|
||||||
|
name="CANDIDATES"
|
||||||
|
accent="amber"
|
||||||
|
rows={lanes.candidates}
|
||||||
|
onRowClick={() => {}}
|
||||||
|
onSelectionChange={(ids) => setLaneSelected("candidates", ids)}
|
||||||
|
/>
|
||||||
|
<Lane
|
||||||
|
name="UNMATCHED"
|
||||||
|
accent="ink-blue"
|
||||||
|
rows={lanes.unmatched}
|
||||||
|
onRowClick={() => {}}
|
||||||
|
onSelectionChange={(ids) => setLaneSelected("unmatched", ids)}
|
||||||
|
/>
|
||||||
|
<Lane
|
||||||
|
name="DONE"
|
||||||
|
accent="muted"
|
||||||
|
rows={lanes.done_today}
|
||||||
|
onRowClick={() => {}}
|
||||||
|
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
|
||||||
|
<BulkBar
|
||||||
|
lane="rejected"
|
||||||
|
count={selected.rejected.length}
|
||||||
|
onResubmit={onResubmit}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
onExport={() => onExport("rejected")}
|
||||||
|
/>
|
||||||
|
<BulkBar
|
||||||
|
lane="candidates"
|
||||||
|
count={selected.candidates.length}
|
||||||
|
onResubmit={() => {}}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
onExport={() => onExport("candidates")}
|
||||||
|
/>
|
||||||
|
<BulkBar
|
||||||
|
lane="unmatched"
|
||||||
|
count={selected.unmatched.length}
|
||||||
|
onResubmit={() => {}}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
onExport={() => onExport("unmatched")}
|
||||||
|
/>
|
||||||
|
<BulkBar
|
||||||
|
lane="done_today"
|
||||||
|
count={selected.done_today.length}
|
||||||
|
onResubmit={() => {}}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
onExport={() => onExport("done_today")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user