diff --git a/src/components/inbox/BulkBar.test.tsx b/src/components/inbox/BulkBar.test.tsx new file mode 100644 index 0000000..40ecfcc --- /dev/null +++ b/src/components/inbox/BulkBar.test.tsx @@ -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( + , + ); + 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( + {}} + 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( + {}} + onDismiss={() => {}} + onExport={() => {}} + />, + ); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/components/inbox/BulkBar.tsx b/src/components/inbox/BulkBar.tsx new file mode 100644 index 0000000..9df9a0f --- /dev/null +++ b/src/components/inbox/BulkBar.tsx @@ -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 ( +
+ + {count} selected + + {lane === "rejected" && ( + + )} + {lane === "candidates" && ( + + )} + +
+ ); +} diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index 30785bb..6b0c9d2 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -2,16 +2,69 @@ // Inbox page (sub-project 6). // // Full-bleed dark working surface for the four-lane triage workflow. -// The page itself is a thin wrapper: data via useInboxLanes, layout via -// Lane / InboxHeader. Bulk actions are wired in T18. +// Wires useInboxLanes + Lane + InboxHeader + BulkBar. Bulk actions call +// the inbox API client and refetch on completion. // --------------------------------------------------------------------------- +import { useState } from "react"; import { Lane, type LaneRow } from "@/components/inbox/Lane"; import { InboxHeader } from "@/components/inbox/InboxHeader"; +import { BulkBar } from "@/components/inbox/BulkBar"; 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() { - const { lanes, loading, error } = useInboxLanes(); + const { lanes, loading, error, refetch } = useInboxLanes(); + const [selected, setSelected] = useState>({ + 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) { return ( @@ -38,8 +91,6 @@ export default function Inbox() { const needEyes = lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length; - const noop = (_r: LaneRow) => {}; - return (
- - - - + {}} + onSelectionChange={(ids) => setLaneSelected("rejected", ids)} + /> + {}} + onSelectionChange={(ids) => setLaneSelected("candidates", ids)} + /> + {}} + onSelectionChange={(ids) => setLaneSelected("unmatched", ids)} + /> + {}} + onSelectionChange={(ids) => setLaneSelected("done_today", ids)} + />
+ + {/* Per-lane bulk bars. Each shows when its lane has a selection. */} + {}} + onExport={() => onExport("rejected")} + /> + {}} + onDismiss={onDismiss} + onExport={() => onExport("candidates")} + /> + {}} + onDismiss={() => {}} + onExport={() => onExport("unmatched")} + /> + {}} + onDismiss={() => {}} + onExport={() => onExport("done_today")} + />
); }