361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// Inbox page (sub-project 6).
|
|
//
|
|
// Full-bleed dark working surface for the four-lane triage workflow.
|
|
// 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,
|
|
resubmitRejectedWithDownload,
|
|
} from "@/lib/inbox-api";
|
|
import { downloadBlob } from "@/lib/download";
|
|
import { RoleGate } from "@/auth/RoleGate";
|
|
|
|
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, 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 }));
|
|
}
|
|
|
|
// Resubmit-bundle download modal state (SP8). When the user selects N>1
|
|
// rejected claims and hits Resubmit, we surface a confirm modal that
|
|
// lets them choose "Resubmit only" (just move the state, no download)
|
|
// vs "Resubmit + Download" (move the state AND hand them a zip of the
|
|
// regenerated 837 files). Single-claim resubmit skips the modal —
|
|
// there's nothing to bundle, the download is implicit in the action.
|
|
const [bundleModalOpen, setBundleModalOpen] = useState(false);
|
|
const [bundleSubmitting, setBundleSubmitting] = useState(false);
|
|
|
|
async function performResubmit(ids: string[], withDownload: boolean) {
|
|
if (withDownload) {
|
|
const { blob, filename, serializeErrors } =
|
|
await resubmitRejectedWithDownload(ids);
|
|
downloadBlob(filename, blob);
|
|
// Surface per-claim serialization failures (rare — usually means
|
|
// raw_json is corrupted for one of the claims). Fail-soft: don't
|
|
// throw, the user still got the zip with the claims that did work.
|
|
if (serializeErrors.length > 0) {
|
|
console.warn(
|
|
"resubmit bundle: skipped",
|
|
serializeErrors.length,
|
|
"claims (couldn't regenerate 837):",
|
|
serializeErrors,
|
|
);
|
|
}
|
|
} else {
|
|
await resubmitRejected(ids);
|
|
}
|
|
setSelected((prev) => ({ ...prev, rejected: [] }));
|
|
await refetch();
|
|
}
|
|
|
|
async function onResubmit() {
|
|
const ids = selected.rejected;
|
|
if (ids.length === 0) return;
|
|
if (ids.length === 1) {
|
|
// No bundle to download — single file, just do it. The user can
|
|
// always grab the .x12 from the claim drawer afterward.
|
|
setBundleSubmitting(true);
|
|
try {
|
|
await performResubmit(ids, false);
|
|
} finally {
|
|
setBundleSubmitting(false);
|
|
}
|
|
return;
|
|
}
|
|
setBundleModalOpen(true);
|
|
}
|
|
|
|
async function onResubmitOnly() {
|
|
const ids = selected.rejected;
|
|
setBundleModalOpen(false);
|
|
setBundleSubmitting(true);
|
|
try {
|
|
await performResubmit(ids, false);
|
|
} finally {
|
|
setBundleSubmitting(false);
|
|
}
|
|
}
|
|
|
|
async function onResubmitAndDownload() {
|
|
const ids = selected.rejected;
|
|
setBundleModalOpen(false);
|
|
setBundleSubmitting(true);
|
|
try {
|
|
await performResubmit(ids, true);
|
|
} finally {
|
|
setBundleSubmitting(false);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className="min-h-screen p-8 mono flex items-center gap-2"
|
|
style={{ background: "var(--tt-bg)", color: "var(--tt-ink-dim)", fontSize: 12 }}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
|
|
style={{ background: "var(--tt-amber)" }}
|
|
/>
|
|
loading inbox…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className="min-h-screen p-8 mono flex flex-col gap-2"
|
|
style={{ background: "var(--tt-bg)", color: "var(--tt-oxblood)", fontSize: 12 }}
|
|
>
|
|
<span className="uppercase tracking-[0.18em]" style={{ fontSize: 10, fontWeight: 700 }}>
|
|
Connection error
|
|
</span>
|
|
<span>{error.message}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const needEyes =
|
|
lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length;
|
|
|
|
return (
|
|
<div
|
|
className="min-h-screen"
|
|
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
|
>
|
|
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
|
<main className="px-6 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
|
|
<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.
|
|
The two write-affordance BulkBars (rejected → resubmit,
|
|
candidates → dismiss) are gated by RoleGate so a viewer-role
|
|
account can still SEE the rows and select them but cannot
|
|
trigger a state change. The export buttons live inside the
|
|
same BulkBar component but are read-only, so we let them
|
|
remain visible to viewers. */}
|
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
|
<BulkBar
|
|
lane="rejected"
|
|
count={selected.rejected.length}
|
|
onResubmit={onResubmit}
|
|
onDismiss={() => {}}
|
|
onExport={() => onExport("rejected")}
|
|
/>
|
|
</RoleGate>
|
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
|
<BulkBar
|
|
lane="candidates"
|
|
count={selected.candidates.length}
|
|
onResubmit={() => {}}
|
|
onDismiss={onDismiss}
|
|
onExport={() => onExport("candidates")}
|
|
/>
|
|
</RoleGate>
|
|
<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")}
|
|
/>
|
|
|
|
{/* Resubmit bundle modal — SP8. Shown when N>1 rejected claims are
|
|
selected; lets the user choose to also download a zip of the
|
|
regenerated 837 files. Single-claim resubmit skips this. */}
|
|
{bundleModalOpen && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style={{ background: "rgba(0,0,0,0.65)", backdropFilter: "blur(4px)" }}
|
|
data-testid="resubmit-bundle-modal"
|
|
role="dialog"
|
|
aria-label="Resubmit and download bundle"
|
|
>
|
|
<div
|
|
className="w-[min(34rem,90vw)] rounded-md border p-6 mono"
|
|
style={{
|
|
background: "var(--tt-bg)",
|
|
borderColor: "var(--tt-amber)",
|
|
color: "var(--tt-ink)",
|
|
boxShadow:
|
|
"0 0 0 1px hsl(36 88% 56% / 0.18), 0 24px 60px -12px hsl(0 0% 0% / 0.7), inset 0 1px 0 0 hsl(0 0% 100% / 0.04)",
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span
|
|
aria-hidden
|
|
className="inline-block h-2 w-2 rounded-full animate-pulse-dot"
|
|
style={{
|
|
background: "var(--tt-amber)",
|
|
boxShadow: "0 0 8px var(--tt-amber)",
|
|
}}
|
|
/>
|
|
<span
|
|
className="eyebrow"
|
|
style={{ color: "var(--tt-amber)" }}
|
|
>
|
|
Resubmit batch
|
|
</span>
|
|
</div>
|
|
|
|
<h2
|
|
className="display text-2xl mb-3"
|
|
style={{ color: "var(--tt-ink)", letterSpacing: "-0.02em" }}
|
|
>
|
|
Resubmit {selected.rejected.length} claims
|
|
</h2>
|
|
|
|
<p
|
|
className="mb-6 text-sm leading-relaxed"
|
|
style={{ color: "var(--tt-ink-dim)" }}
|
|
>
|
|
Move these claims back to{" "}
|
|
<span
|
|
className="px-1.5 py-0.5 rounded-sm"
|
|
style={{
|
|
background: "var(--tt-amber)",
|
|
color: "var(--tt-bg)",
|
|
fontWeight: 700,
|
|
}}
|
|
>
|
|
submitted
|
|
</span>
|
|
? Optionally download a bundle of regenerated 837 files
|
|
(one <code>.x12</code> per claim) — useful if you're
|
|
about to ship them to the clearinghouse.
|
|
</p>
|
|
|
|
<div className="flex justify-end gap-2.5">
|
|
<button
|
|
type="button"
|
|
onClick={onResubmitOnly}
|
|
disabled={bundleSubmitting}
|
|
data-testid="resubmit-modal-only"
|
|
className="px-4 py-2 rounded-sm text-[11px] uppercase tracking-[0.14em] transition-all hover:bg-[color:var(--tt-amber)]/10 disabled:opacity-50 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
|
|
style={{
|
|
border: "1px solid var(--tt-amber)",
|
|
color: "var(--tt-amber)",
|
|
background: "transparent",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
Resubmit only
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onResubmitAndDownload}
|
|
disabled={bundleSubmitting}
|
|
data-testid="resubmit-modal-download"
|
|
className="px-4 py-2 rounded-sm text-[11px] uppercase tracking-[0.14em] transition-all hover:brightness-110 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--tt-amber)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--tt-bg)]"
|
|
style={{
|
|
background: "var(--tt-amber)",
|
|
color: "var(--tt-bg)",
|
|
fontWeight: 700,
|
|
}}
|
|
>
|
|
Resubmit + Download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Subtle progress overlay while a bulk resubmit is in flight, so
|
|
the user knows the click registered even when the network is slow.
|
|
Single-claim resubmit also benefits — no visible feedback before
|
|
this and the inbox can sit there for a few hundred ms. */}
|
|
{bundleSubmitting && (
|
|
<div
|
|
className="fixed inset-0 z-40 cursor-wait"
|
|
style={{ background: "rgba(0,0,0,0.15)", backdropFilter: "blur(1px)" }}
|
|
data-testid="resubmit-progress-overlay"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|