feat(sp6): bulk action bar wired into lanes

This commit is contained in:
Tyler
2026-06-20 18:43:51 -06:00
parent b607e4c459
commit 3157c86340
3 changed files with 224 additions and 9 deletions
+114 -9
View File
@@ -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<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) {
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 (
<div
className="min-h-screen"
@@ -47,11 +98,65 @@ export default function Inbox() {
>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
<main className="p-4 flex gap-4 items-start flex-wrap">
<Lane name="REJECTED" accent="oxblood" rows={lanes.rejected} onRowClick={noop} />
<Lane name="CANDIDATES" accent="amber" rows={lanes.candidates} onRowClick={noop} />
<Lane name="UNMATCHED" accent="ink-blue" rows={lanes.unmatched} onRowClick={noop} />
<Lane name="DONE" accent="muted" rows={lanes.done_today} onRowClick={noop} />
<Lane
name="REJECTED"
accent="oxblood"
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>
{/* 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>
);
}