5c9365ec33
Closes the gap between the SP10 backend (5 lanes) and the SP6
frontend (4 lanes). The Payer-Rejected lane (277CA STC A4/A6/A7)
is now rendered alongside Rejected/Candidates/Unmatched/Done,
with an Acknowledge bulk action that drops claims from the
working surface without erasing the original 277CA rejection
event (audit log stays intact, SP11).
Backend:
* Migration 0010: add payer_rejected_acknowledged_at +
payer_rejected_acknowledged_actor columns + partial index.
* db.py: surface the two new columns on the Claim model.
* inbox_lanes.py: filter acknowledged claims out of the
payer_rejected lane; expose the new fields on the row payload
for forward-compat (e.g. a future 'Recently acknowledged' view).
* api.py:
- POST /api/inbox/payer-rejected/acknowledge
Bulk-acknowledge. Idempotent. Returns transitioned /
already_acked / not_found / not_rejected counts so the UI
can show '3 of 5 were already acknowledged' on a noop bulk.
Writes a 'claim.payer_rejected_acknowledged' event to the
SP11 hash-chained audit log.
- GET /api/inbox/export.csv: accept 'payer_rejected' lane.
* test_acks.py: bump user_version assertion to 10.
* test_lane_filter_acknowledged.py: 4 tests for the lane filter
and forward-compat row payload.
* test_payer_rejected_acknowledge.py: 6 tests for the endpoint
(happy path, idempotency, no-op on non-rejected, missing
ids, 400 on empty, audit-log wiring + chain integrity).
Frontend:
* lib/inbox-api.ts: add payer_rejected to InboxLanes, add
acknowledgePayerRejected(), update exportInboxCsvUrl union.
* hooks/useInboxLanes.ts: add payer_rejected to initial state.
* hooks/useInboxLanes.test.ts: add payer_rejected to mocks.
* components/inbox/BulkBar.tsx: add 'payer_rejected' lane with
Acknowledge action (no Resubmit, no Dismiss — payer-rejected
is not eligible for either).
* components/inbox/BulkBar.test.tsx: add payer_rejected test.
* pages/Inbox.tsx: render the 5th lane, hook up onAcknowledge,
include payer_rejected in the needEyes count.
* pages/Inbox.test.tsx: 3 new tests (5-lane render, need-eyes
count, acknowledge action hits the right endpoint).
* components/inbox/InboxHeader.tsx: doc comment now explains
why payer_rejected rolls up into need-eyes.
Pre-existing typecheck warnings in BulkBar.test.tsx / InboxRow
.test.tsx / Lane.tsx / download.test.ts are unchanged from
main — not touched here.
Test counts: backend 724 -> 734 (+10). Frontend 350 -> 354 (+4).
120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// Inbox lanes hook (sub-project 6, Phase 7 Task 19).
|
|
//
|
|
// Owns the four-lane payload (`rejected` / `candidates` / `unmatched` /
|
|
// `done_today`) for the Inbox page. T19 replaced the original 5s polling
|
|
// with the SP5 tail: the hook opens the claim and remittance streams via
|
|
// `useTailStream`, and whenever the tail store grows (a new `item` event
|
|
// arrived) it triggers a debounced refetch of the lanes payload. The
|
|
// debounce coalesces a burst of events into one network round-trip.
|
|
//
|
|
// The streams themselves stay open as long as a consumer of this hook is
|
|
// mounted (e.g. the `/inbox` page). When the user navigates away, the
|
|
// hook unmounts and `useTailStream`'s cleanup aborts the SSE connection.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { fetchInboxLanes, type InboxLanes } from "@/lib/inbox-api";
|
|
import { useTailStream } from "./useTailStream";
|
|
import { useTailStore } from "@/store/tail-store";
|
|
|
|
/**
|
|
* Coalesce bursty tail events into a single network round-trip. With 5s
|
|
* polling this was implicitly handled by the interval; with the tail we
|
|
* have to do it explicitly. 250ms is short enough that the user sees the
|
|
* new row within their natural attention span (well under the spec's
|
|
* "live updates" SLO), and long enough that a 10-event burst doesn't
|
|
* fire 10 fetches.
|
|
*/
|
|
const REFETCH_DEBOUNCE_MS = 250;
|
|
|
|
export function useInboxLanes() {
|
|
const [lanes, setLanes] = useState<InboxLanes>({
|
|
rejected: [],
|
|
// SP14: the 5th working-surface lane (277CA STC A4/A6/A7).
|
|
payer_rejected: [],
|
|
candidates: [],
|
|
unmatched: [],
|
|
done_today: [],
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
// Keep the SP5 tail connections alive while this hook is mounted. The
|
|
// hook itself doesn't read their return value — events are dispatched
|
|
// into the tail store, which we subscribe to below. The `useTailStream`
|
|
// call is what opens the SSE stream and keeps it alive; without these
|
|
// two lines the inbox would silently stop updating once the user
|
|
// navigated away from a page that already mounted a tail.
|
|
useTailStream("claims");
|
|
useTailStream("remittances");
|
|
|
|
// Subscribe to the tail-store order arrays. We watch the *length* (not
|
|
// the contents) because the store's `addClaim`/`addRemittance` returns
|
|
// a new array reference on every insert, which is exactly what zustand
|
|
// uses to flag a re-render.
|
|
const claimOrderLen = useTailStore((s) => s.claimOrder.length);
|
|
const remitOrderLen = useTailStore((s) => s.remitOrder.length);
|
|
|
|
const refetch = useCallback(async () => {
|
|
try {
|
|
const next = await fetchInboxLanes();
|
|
setLanes(next);
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(e as Error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// Hold the latest `refetch` in a ref so the debounce timer always
|
|
// closes over the current closure without forcing the effect below to
|
|
// re-subscribe on every render.
|
|
const refetchRef = useRef(refetch);
|
|
refetchRef.current = refetch;
|
|
|
|
// Track the order lengths we last observed so the very first render
|
|
// (with the store already at its initial size, possibly non-zero if a
|
|
// sibling page has been populating it) doesn't trigger a spurious
|
|
// refetch. -1 sentinel means "haven't observed yet".
|
|
const prevLenRef = useRef<{ claims: number; remits: number }>({
|
|
claims: -1,
|
|
remits: -1,
|
|
});
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
const prev = prevLenRef.current;
|
|
const claimsChanged = prev.claims !== claimOrderLen;
|
|
const remitsChanged = prev.remits !== remitOrderLen;
|
|
prevLenRef.current = { claims: claimOrderLen, remits: remitOrderLen };
|
|
|
|
// Skip the initial observation — the lanes have already been
|
|
// fetched via the mount effect below. Only react to *changes*.
|
|
if (prev.claims === -1) return;
|
|
if (!claimsChanged && !remitsChanged) return;
|
|
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
debounceRef.current = null;
|
|
void refetchRef.current();
|
|
}, REFETCH_DEBOUNCE_MS);
|
|
|
|
return () => {
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current);
|
|
debounceRef.current = null;
|
|
}
|
|
};
|
|
}, [claimOrderLen, remitOrderLen]);
|
|
|
|
// Initial fetch on mount. Runs once; the tail-driven effect above
|
|
// handles subsequent updates.
|
|
useEffect(() => {
|
|
void refetch();
|
|
}, [refetch]);
|
|
|
|
return { lanes, loading, error, refetch };
|
|
}
|