9bca4b608a
Backend:
- New POST /api/batches/{id}/export-837: regenerate X12 837 files
for a list of claim_ids into a ZIP using HCPF file naming standards,
with a unique interchange/group control number per export. Wire
the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
(NM1*40) blocks so the serializer no longer falls back to
CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
batch_id in both JSON and NDJSON response shapes so the frontend
can hit batch-scoped endpoints without an extra listBatches
round-trip.
- Filename helpers and the 837 serializer updated to match the new
HCPF envelope; tests cover batch export, parse batch_id, and the
serializer's control-number uniqueness guarantee.
Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
EditorialNote, ExportBar, TickerTape, and a charts/ set
(BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
colors to Tailwind theme tokens (bg-card, text-foreground,
border/60, etc.) for consistency with the rest of the instrument
chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
reworked to compose the new shared components and consume the new
batch-scoped API surface (notably ExportBar wired into Batches).
Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
components and the useBatchExport hook.
Rolls up into the v0.2.0 release tag.
744 lines
26 KiB
TypeScript
744 lines
26 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// Inbox page (sub-project 6, SP14).
|
|
//
|
|
// Full-bleed dark working surface for the five-lane triage workflow.
|
|
// Wires useInboxLanes + Lane + InboxHeader + BulkBar. Bulk actions call
|
|
// the inbox API client and refetch on completion.
|
|
//
|
|
// SP14: added the `payer_rejected` lane (277CA STC A4/A6/A7) and the
|
|
// Acknowledge bulk action. The Payer-Rejected lane sits between
|
|
// `rejected` (envelope) and `candidates` (recon opportunities) so the
|
|
// operator's eye flow is: envelope problems → payer problems →
|
|
// auto-recon opportunities → claims still in flight → done today.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { cn } from "@/lib/utils";
|
|
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
|
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
|
import { BulkBar } from "@/components/inbox/BulkBar";
|
|
import { RemitDrawer } from "@/components/RemitDrawer";
|
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
|
import { useInboxLanes } from "@/hooks/useInboxLanes";
|
|
import {
|
|
exportInboxCsvUrl,
|
|
dismissCandidates,
|
|
resubmitRejected,
|
|
resubmitRejectedWithDownload,
|
|
acknowledgePayerRejected,
|
|
} from "@/lib/inbox-api";
|
|
import { downloadBlob } from "@/lib/download";
|
|
|
|
type LaneKey =
|
|
| "rejected"
|
|
| "payer_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();
|
|
// SP21 Phase 4 Task 4.4: drill-down from inbox rows. The hook reads
|
|
// `?remit=` off `window.location.search` so opening a row from the
|
|
// /inbox URL pushes `?remit=ID` onto /inbox itself (it doesn't
|
|
// navigate to /remittances). The drawer just opens in-place.
|
|
const navigate = useNavigate();
|
|
const { remitId, open, close } = useRemitDrawerUrlState();
|
|
const [selected, setSelected] = useState<Record<LaneKey, string[]>>({
|
|
rejected: [],
|
|
payer_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();
|
|
}
|
|
}
|
|
|
|
async function onAcknowledge() {
|
|
// SP14: drop selected payer-rejected claims from the working
|
|
// surface. The backend persists an `acknowledged_at` timestamp;
|
|
// the original 277CA rejection (status code, reason, source 277CA
|
|
// id) stays intact for the SP11 audit log.
|
|
const ids = selected.payer_rejected;
|
|
if (ids.length === 0) return;
|
|
await acknowledgePayerRejected(ids);
|
|
setSelected((prev) => ({ ...prev, payer_rejected: [] }));
|
|
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"
|
|
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
|
>
|
|
<InboxHeader needEyesCount={0} doneTodayCount={0} />
|
|
<div
|
|
className="px-6 lg:px-10 py-10 mono flex items-center gap-2"
|
|
style={{ 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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className="min-h-screen"
|
|
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
|
>
|
|
<InboxHeader needEyesCount={0} doneTodayCount={0} />
|
|
<div
|
|
className="px-6 lg:px-10 py-10 mono flex flex-col gap-2"
|
|
style={{ 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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// SP14: payer_rejected is also actionable — a 277CA rejection that
|
|
// needs operator follow-up. Without it, payer-side rejections would
|
|
// hide behind the 999 envelope "rejected" lane and the operator
|
|
// could miss a high-value denial.
|
|
const needEyes =
|
|
lanes.rejected.length +
|
|
lanes.payer_rejected.length +
|
|
lanes.candidates.length +
|
|
lanes.unmatched.length;
|
|
|
|
return (
|
|
<div
|
|
className="min-h-screen relative"
|
|
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
|
>
|
|
{/* Ambient halo — a soft, fixed radial that creates a subtle
|
|
lighter zone behind the InboxHeader. The inbox has its own
|
|
dark ticker-tape background so it doesn't receive the body
|
|
wash; this overlay restores the "lit from above" feel that
|
|
the main pages get from the body::before softbox, in the
|
|
same warm-amber accent family as the rest of the surface.
|
|
Pointer-events disabled. */}
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[55vh]"
|
|
style={{
|
|
background: `
|
|
radial-gradient(ellipse 60% 38% at 50% 0%, hsla(36, 60%, 30%, 0.10), transparent 65%),
|
|
radial-gradient(ellipse 35% 28% at 88% 8%, hsla(36, 70%, 50%, 0.07), transparent 60%)
|
|
`,
|
|
}}
|
|
/>
|
|
{/* SP21 Phase 4 Task 4.4: RemitDrawer mounts here so a row click
|
|
in the candidates / unmatched lanes drills into the parent
|
|
remit. The drawer portals into document.body (Radix Dialog),
|
|
so the surrounding dark surface is decorative — the drawer
|
|
overlays it when open. The hook reads `?remit=` off the URL,
|
|
so deep links restore the open remit on reload. */}
|
|
<RemitDrawer
|
|
remitId={remitId}
|
|
remits={[]}
|
|
onClose={close}
|
|
onNavigate={open}
|
|
onToggleHelp={() => {
|
|
// No cheatsheet on the inbox surface — `?` is a no-op
|
|
// here, but the prop is required by the drawer's contract.
|
|
}}
|
|
/>
|
|
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
|
|
|
{/* Lane grid transitions directly into the summary strip below.
|
|
The previous "fold" divider with italic serif arrows and
|
|
"Five lanes · one queue" caption was the page's last warm-
|
|
paper print-shop artifact — removed so the ticker-tape
|
|
aesthetic carries all the way through. */}
|
|
|
|
<main className="px-6 lg:px-10 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
|
|
<Lane
|
|
name="REJECTED"
|
|
accent="oxblood"
|
|
rows={lanes.rejected}
|
|
// SP21 Phase 5 Task 5.4: rejected claims drill into the
|
|
// ClaimDrawer. All rows here are claims (kind === "claim"),
|
|
// so the branch is straightforward — the type union still
|
|
// requires the defensive check, but a row click on a claim
|
|
// here is always a claim drill.
|
|
onRowClick={(row) => {
|
|
if (row.kind === "claim") {
|
|
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
|
|
}
|
|
}}
|
|
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
|
|
/>
|
|
{/*
|
|
SP14: payer-rejected (277CA STC A4/A6/A7). Distinct accent
|
|
from the 999 envelope rejected lane — the operator's eye
|
|
flow is: 999 problems → payer problems → reconciliation
|
|
opportunities → claims still in flight → done today.
|
|
*/}
|
|
<Lane
|
|
name="PAYER REJECTED"
|
|
accent="oxblood"
|
|
rows={lanes.payer_rejected}
|
|
// SP21 Phase 5 Task 5.4: payer-rejected claims also drill
|
|
// into the ClaimDrawer — same shape as the rejected lane.
|
|
onRowClick={(row) => {
|
|
if (row.kind === "claim") {
|
|
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
|
|
}
|
|
}}
|
|
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
|
|
/>
|
|
<Lane
|
|
name="CANDIDATES"
|
|
accent="amber"
|
|
rows={lanes.candidates}
|
|
// Candidates are remits waiting for a claim match — drill
|
|
// straight into the RemitDrawer for the source remit.
|
|
onRowClick={(row) => open(row.id)}
|
|
onSelectionChange={(ids) => setLaneSelected("candidates", ids)}
|
|
/>
|
|
<Lane
|
|
name="UNMATCHED"
|
|
accent="ink-blue"
|
|
rows={lanes.unmatched}
|
|
// Unmatched is a mixed bucket (kind === "claim" | "remit" per
|
|
// the InboxClaimRow union). Defensive branch — today the
|
|
// backend only emits "claim" rows here, but the type allows
|
|
// both, and the next data-source change shouldn't require a
|
|
// page edit.
|
|
onRowClick={(row) => {
|
|
if (row.kind === "remit") {
|
|
open(row.id);
|
|
} else if (row.kind === "claim") {
|
|
navigate(
|
|
`/claims?claim=${encodeURIComponent(row.id)}`,
|
|
);
|
|
}
|
|
}}
|
|
onSelectionChange={(ids) => setLaneSelected("unmatched", ids)}
|
|
/>
|
|
<Lane
|
|
name="DONE"
|
|
accent="muted"
|
|
rows={lanes.done_today}
|
|
// SP21 Phase 5 Task 5.4: done_today rows are also claim
|
|
// drills — same as rejected / payer_rejected. The drawer
|
|
// surfaces the claim's current state + recent history,
|
|
// which is exactly what an operator wants when reviewing
|
|
// "what got shipped today".
|
|
onRowClick={(row) => {
|
|
if (row.kind === "claim") {
|
|
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
|
|
}
|
|
}}
|
|
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
|
|
/>
|
|
</main>
|
|
|
|
{/* ----------------------------------------------------------------
|
|
TICKER-TAPE QUEUE SUMMARY
|
|
A dark terminal panel below the lanes that aggregates the
|
|
eye-flow across the queue. Sits on the same --tt-bg-elev
|
|
surface as the Lane cards so the page reads as one continuous
|
|
instrument instead of mixing a paper-toned "ledger" panel
|
|
into the terminal aesthetic.
|
|
---------------------------------------------------------------- */}
|
|
<section
|
|
aria-label="Queue summary"
|
|
className="relative mx-6 lg:mx-10 mt-5 mb-6"
|
|
>
|
|
<div
|
|
className="relative rounded-md overflow-hidden"
|
|
style={{
|
|
// "Lit from above" gradient — same treatment as the lane
|
|
// cards and the main .surface panels, so the queue
|
|
// summary reads as a slightly lifted, subtly lighter
|
|
// surface against the ticker-tape backdrop.
|
|
background:
|
|
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
|
|
border: "1px solid hsl(220 8% 18%)",
|
|
boxShadow:
|
|
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
|
|
}}
|
|
>
|
|
{/* Header row — machine-tag eyebrow + editorial headline +
|
|
aggregate Need eyes count, separated from the lane metrics
|
|
by a hairline. */}
|
|
<div
|
|
className="px-5 lg:px-6 py-3.5 flex items-center justify-between gap-4 flex-wrap"
|
|
style={{ borderBottom: "1px solid hsl(220 8% 16%)" }}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2.5 mb-1.5">
|
|
<span
|
|
aria-hidden
|
|
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
|
|
style={{ background: "var(--tt-amber)" }}
|
|
/>
|
|
<span
|
|
className="mono uppercase"
|
|
style={{
|
|
color: "var(--tt-amber)",
|
|
fontSize: 10.5,
|
|
letterSpacing: "0.22em",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
Eye-flow
|
|
</span>
|
|
<span
|
|
className="mono uppercase"
|
|
style={{
|
|
color: "var(--tt-muted)",
|
|
fontSize: 10,
|
|
letterSpacing: "0.18em",
|
|
}}
|
|
aria-hidden
|
|
>
|
|
· five lanes, one queue
|
|
</span>
|
|
</div>
|
|
<h2
|
|
className="display italic"
|
|
style={{
|
|
color: "var(--tt-ink)",
|
|
fontSize: "clamp(20px, 1.9vw, 24px)",
|
|
lineHeight: 1.1,
|
|
fontWeight: 400,
|
|
letterSpacing: "-0.02em",
|
|
}}
|
|
>
|
|
at a glance.
|
|
</h2>
|
|
</div>
|
|
<div className="text-right" aria-label="Aggregate metrics">
|
|
<div
|
|
className="mono uppercase"
|
|
style={{
|
|
color: "var(--tt-ink-dim)",
|
|
fontSize: 10,
|
|
letterSpacing: "0.20em",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
Need eyes
|
|
</div>
|
|
<div
|
|
className="display tabular-nums"
|
|
style={{
|
|
color: "var(--tt-amber)",
|
|
fontSize: 28,
|
|
lineHeight: 1,
|
|
fontWeight: 400,
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{needEyes}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lane metrics row — each tile carries its own accent dot
|
|
and count, so the operator can read the queue breakdown
|
|
at a glance without scanning back up to the Lane grid. */}
|
|
<div
|
|
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
|
style={{ borderTop: "1px solid hsl(220 8% 16%)" }}
|
|
>
|
|
<SummaryTile
|
|
label="Rejected"
|
|
value={lanes.rejected.length}
|
|
tone="oxblood"
|
|
/>
|
|
<SummaryTile
|
|
label="Payer rejected"
|
|
value={lanes.payer_rejected.length}
|
|
tone="oxblood"
|
|
/>
|
|
<SummaryTile
|
|
label="Candidates"
|
|
value={lanes.candidates.length}
|
|
tone="amber"
|
|
/>
|
|
<SummaryTile
|
|
label="Unmatched"
|
|
value={lanes.unmatched.length}
|
|
tone="ink"
|
|
/>
|
|
<SummaryTile
|
|
label="Done today"
|
|
value={lanes.done_today.length}
|
|
tone="muted"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
|
|
<BulkBar
|
|
lane="rejected"
|
|
count={selected.rejected.length}
|
|
onResubmit={onResubmit}
|
|
onAcknowledge={() => {}}
|
|
onDismiss={() => {}}
|
|
onExport={() => onExport("rejected")}
|
|
/>
|
|
<BulkBar
|
|
lane="payer_rejected"
|
|
count={selected.payer_rejected.length}
|
|
onResubmit={() => {}}
|
|
onAcknowledge={onAcknowledge}
|
|
onDismiss={() => {}}
|
|
onExport={() => onExport("payer_rejected")}
|
|
/>
|
|
<BulkBar
|
|
lane="candidates"
|
|
count={selected.candidates.length}
|
|
onResubmit={() => {}}
|
|
onAcknowledge={() => {}}
|
|
onDismiss={onDismiss}
|
|
onExport={() => onExport("candidates")}
|
|
/>
|
|
<BulkBar
|
|
lane="unmatched"
|
|
count={selected.unmatched.length}
|
|
onResubmit={() => {}}
|
|
onAcknowledge={() => {}}
|
|
onDismiss={() => {}}
|
|
onExport={() => onExport("unmatched")}
|
|
/>
|
|
<BulkBar
|
|
lane="done_today"
|
|
count={selected.done_today.length}
|
|
onResubmit={() => {}}
|
|
onAcknowledge={() => {}}
|
|
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 75% 58% / 0.18), 0 24px 60px -12px hsl(0 0% 0% / 0.5), inset 0 1px 0 0 hsl(0 0% 100% / 0.03)",
|
|
}}
|
|
>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SummaryTile — ticker-tape metric for the eye-flow strip. Each tile
|
|
// carries the lane's accent dot + label + count in the same color
|
|
// family the Lane component uses (oxblood / amber / ink-blue / muted),
|
|
// so the operator can read the queue breakdown at a glance without
|
|
// scanning back up to the lane grid.
|
|
// ---------------------------------------------------------------------------
|
|
const SUMMARY_ACCENTS: Record<
|
|
"oxblood" | "amber" | "ink" | "muted",
|
|
{ dot: string; value: string }
|
|
> = {
|
|
oxblood: {
|
|
dot: "var(--tt-oxblood)",
|
|
value: "var(--tt-ink)",
|
|
},
|
|
amber: {
|
|
dot: "var(--tt-amber)",
|
|
value: "var(--tt-amber)",
|
|
},
|
|
ink: {
|
|
dot: "var(--tt-ink-blue)",
|
|
value: "var(--tt-ink)",
|
|
},
|
|
muted: {
|
|
dot: "var(--tt-muted)",
|
|
value: "var(--tt-ink-dim)",
|
|
},
|
|
};
|
|
|
|
function SummaryTile({
|
|
label,
|
|
value,
|
|
tone,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
tone: "oxblood" | "amber" | "ink" | "muted";
|
|
}) {
|
|
const a = SUMMARY_ACCENTS[tone];
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative px-4 py-4 first:pl-5 lg:first:pl-6 last:pr-5 lg:last:pr-6"
|
|
)}
|
|
style={{ borderRight: "1px solid hsl(220 8% 14%)" }}
|
|
>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span
|
|
aria-hidden
|
|
className="block h-1.5 w-1.5 rounded-full"
|
|
style={{
|
|
backgroundColor: a.dot,
|
|
boxShadow: `0 0 6px ${a.dot}`,
|
|
}}
|
|
/>
|
|
<span
|
|
className="mono uppercase"
|
|
style={{
|
|
color: "var(--tt-ink-dim)",
|
|
fontSize: 10.5,
|
|
letterSpacing: "0.18em",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="display tabular-nums"
|
|
style={{
|
|
color: a.value,
|
|
fontSize: "clamp(22px, 1.9vw, 28px)",
|
|
lineHeight: 1,
|
|
fontWeight: 400,
|
|
letterSpacing: "-0.02em",
|
|
}}
|
|
>
|
|
{value}
|
|
</div>
|
|
<div
|
|
className="mono uppercase mt-1.5"
|
|
style={{
|
|
color: "var(--tt-muted)",
|
|
fontSize: 9.5,
|
|
letterSpacing: "0.16em",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{value === 0 ? "clear" : value === 1 ? "row" : "rows"}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|