import { useEffect, useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../api' import { BAND_CAVEAT, BAND_LABEL, BAND_VAR } from '../bands' import { Tooltip } from './Tooltip' import type { Band } from '../bands' import type { ReviewRow } from '../types' // Tebra's one template; runs from before the rename carry "T7: ...". const isOfficeLocation = (t: string | null) => t === 'T1: Office Location' || (t ?? '').startsWith('T7:') // One review card per record on a page. An ECW roster can list the same // person twice (two Sleep Study encounters for one patient) — line_index // splits those into separate cards. A Gulf Coast ticket spreads its charges // over several line_index values and must stay ONE card, so it is excluded. // The name stays in the key so runs stored before line_index existed (every // row LineIndex 0) still group exactly as before. const patientKey = (page: number, name: string, line: number, template: string | null) => `${page}:${isOfficeLocation(template) ? '' : line}:${name}` /** * What a field's band means, said once. * * The queue's `reason` restates the band label before adding anything of its * own, so joining the two produced "Clean print — clean print" and, worse, * "Slightly unclear — slightly unclear — sample check". The restatement is * stripped rather than the whole reason dropped: the tail ("sample check") is * the only part that says anything the label did not. */ function bandNote(band: Band, reason: string | null): string { const label = BAND_LABEL[band] if (!reason) return label let rest = reason.trim() if (rest.toLowerCase().startsWith(label.toLowerCase())) { // Drop the echoed label and whatever punctuation was joining it on. rest = rest.slice(label.length).replace(/^[\s—–,:-]+/, '') } if (!rest) return label // The echo is not always literal: "Not found in transcription" against a // reason of "value not found in the page transcription" is the same sentence // reworded. When the reason already contains every word of the label, it is // the more specific of the two — so it replaces the label rather than // trailing behind it. const words = (s: string) => s.toLowerCase().match(/[a-z]+/g) ?? [] const inReason = new Set(words(rest)) if (words(label).every((w) => inReason.has(w))) { return rest.charAt(0).toUpperCase() + rest.slice(1) } return `${label} — ${rest}` } // Green first, worst last — a patient's record reads top to bottom as good news // first, then what needs attention (blank/unscored trail behind red: they carry // no legibility signal at all, not even a bad one). const BAND_ORDER = ['green', 'amber', 'red', 'blank', 'unscored'] as const const bandRank = (b: string) => BAND_ORDER.indexOf(b as never) type Tone = { className: string; style?: React.CSSProperties } // Three button/badge tones, factored out of the JSX ternaries that used to // hold them. They now resolve to the design system's own component classes — // the brand ramp is the only saturated thing on this screen, so "the action to // take" is the gradient primary and everything else is quiet. function nextButtonTone(complete: boolean): Tone { return { className: complete ? 'sb-btn-primary' : 'sb-btn-quiet' } } function verifiedBadgeTone(complete: boolean): Tone { return { className: complete ? 'sb-chip-count sb-chip-done' : 'sb-chip-count' } } function saveButtonTone(verified: boolean, dirty: boolean): Tone { if (verified) return { className: 'sb-btn-quiet' } // "You edited this, confirm" stays a semantic warning rather than the brand // ramp, so it cannot be mistaken for the ordinary sign-off. if (dirty) return { className: 'sb-btn-next' } return { className: 'sb-btn-primary' } } /** The band's 3px grade rule down the left edge of a field input. */ const GRADE_CLASS: Record = { green: 'sb-grade-clean', amber: 'sb-grade-sample', red: 'sb-grade-missing', blank: 'sb-grade-absent', unscored: 'sb-grade-absent', } type Patient = { key: string page: number name: string /** Position on the page — the lowest line_index among this patient's rows. */ order: number flagged: ReviewRow[] } type SaveArgs = { row: ReviewRow; draft: string; verified: boolean } // ── Office Location ──────────────────────────────────────────────────────── // A Gulf Coast ticket whose yellow note the Office Location rules read. The // backend stores the parsed note on the ticket's first line under this field // (src/superbill/extraction/office_location.py, ParsedNote); a ticket without // it is a run from before the rules existed and keeps the flat layout. const NOTE_FIELD = 'Office Location Note' const NO_SHOW = 'No Show' /** Drawn as structure, never as a field to OK. From/To DOS only exist on runs * from before the columns were dropped. */ const OL_STRUCTURAL = new Set([NOTE_FIELD, 'Encounter#', 'From DOS', 'To DOS']) /** The note-rule fields, with the rule that fills each. */ const OL_RULE_FIELDS = [ { field: 'Auth#', rule: 'R1' }, { field: 'Referring Provider Update', rule: 'R3' }, { field: 'Rendering Provider Update', rule: 'R3' }, ] as const const OL_PAGE_FIELDS = new Set(['Ticket#', ...OL_RULE_FIELDS.map((f) => f.field)]) /** The rule fields each encounter can set for itself. */ const PROVIDER_FIELDS = new Set(['Referring Provider Update', 'Rendering Provider Update']) type OfficeCharge = { cpt: string; units: number | null; icd: string[]; source_line: number } type OfficeNote = { no_show: boolean auth_number: string | null rules_fired: string[] encounters: { encounter_no: string; separate: boolean; charges: OfficeCharge[] }[] lines: string[] sources: Record warnings: string[] } function readOfficeNote(rows: ReviewRow[]): OfficeNote | null { const row = rows.find((r) => r.field === NOTE_FIELD) if (!row?.value) return null try { return JSON.parse(row.value) as OfficeNote } catch { return null } } const valueOf = (r: ReviewRow) => r.corrected_value ?? r.value /** Every field the Office Location layout shows -- which is also every field * that has to be OK'd before "Verified — next patient" unlocks. Units only * shows when the note billed more than one; at 1 it is the default, not a * reading anyone needs to confirm. */ function officeVisibleRows(rows: ReviewRow[]): ReviewRow[] { return rows.filter((r) => { if (OL_STRUCTURAL.has(r.field)) return false if (r.field === 'Units') return Number(valueOf(r) ?? 1) > 1 return true }) } /** What PdfPane's toolbar needs to show and drive the same Save/Discard the * review list uses, so there is one save action, not two competing ones. */ export type ReviewActions = { patientName: string | null dirtyCount: number saving: boolean onSave: () => void onDiscard: () => void } /** * One patient at a time, filling the pane, with explicit navigation. * * The earlier accordion put 33 collapsed patients in a narrow column and asked * the reviewer to scroll and aim — every field row was ~28px in a pane sharing * the window with the PDF. Verification is inherently one-patient-at-a-time * work: read this patient's line on the page, confirm these fields, move on. So * the pane shows exactly that, and moving on is a button (or ←/→) rather than a * hunt for the next collapsed header. */ export function ReviewPane({ jobId, activePage, onPickPage, onActionsChange, }: { jobId: string activePage: number | null onPickPage: (page: number) => void /** Reports the current patient's unsaved-edit state up to App, so PdfPane's * toolbar can Save/Discard the same edits rather than owning a second copy. */ onActionsChange?: (a: ReviewActions | null) => void }) { const [index, setIndex] = useState(0) // Drafts live here, not in FieldRow — a correction is no longer committed on // blur (that is exactly how four ARMSTRONG fields got saved mid-keystroke: // "ARMSTRONG, CHARLES T" as "\ONG, CHARLES T", "99291" as "992"). Text typed // into a field is held until an explicit Save, keyed by row_id rather than // bare field name — a bare name repeats once a page can carry more than one // row per field (Gulf Coast's repeated CPT lines), and DemographyTab.tsx // hit exactly this before switching its own drafts to row_id. const [drafts, setDrafts] = useState>({}) const qc = useQueryClient() const { data, isError, isFetching, refetch } = useQuery({ queryKey: ['review', jobId], queryFn: () => api.getReview(jobId), }) // The flagged queue decides who appears and drives progress. This is the // separate, unfiltered record of EVERY field a patient has, green included — // a reviewer confirming one value wants the rest of that patient's row for // context, not just the parts that were flagged. const { data: allFields } = useQuery({ queryKey: ['review-all-fields', jobId], queryFn: () => api.getAllFields(jobId), }) const patients = useMemo(() => { const byKey = new Map() const ensure = (page: number, name: string, line: number, template: string | null) => { const key = patientKey(page, name, line, template) if (!byKey.has(key)) byKey.set(key, { key, page, name, order: line, flagged: [] }) const p = byKey.get(key)! p.order = Math.min(p.order, line) return p } for (const row of data?.rows ?? []) { ensure(row.page ?? 0, row.patient ?? '(no name)', row.line_index ?? 0, row.template).flagged.push(row) } // Every patient in the document, not only the ones with a flagged field. // A document where everything read cleanly used to show "nothing needs a // human" and nothing else — leaving no way to correct a value the model // misread confidently, which is exactly the failure green cannot rule out. for (const row of allFields?.rows ?? []) { ensure(row.page ?? 0, row.patient ?? '(no name)', row.line_index ?? 0, row.template) } // PDF order: page, then where the patient sits on it. line_index is each // record's position on its page, set by build_field_rows. Sorting by name // here used to put ANDERSON above the first patient on the sheet, so the // list no longer matched the document the reviewer had open beside it. // Name is only the tie-break, for runs stored before line_index existed. return [...byKey.values()].sort( (a, b) => a.page - b.page || a.order - b.order || a.name.localeCompare(b.name), ) }, [data?.rows, allFields?.rows]) const allByPatient = useMemo(() => { const map = new Map() for (const row of allFields?.rows ?? []) { const key = patientKey(row.page ?? 0, row.patient ?? '(no name)', row.line_index ?? 0, row.template) if (!map.has(key)) map.set(key, []) map.get(key)!.push(row) } for (const rows of map.values()) { // Gulf Coast (Template 7) records keep the order the API sent — the flat // 20-column superbill layout, Ticket# through Units. Re-sorting by band // would scatter it (that is what put ICD11 above ICD2 and CPT at the // bottom). Every other template keeps worst-band-last, which is what the // band filter pills were removed in favour of. const gulf = rows.some((r) => isOfficeLocation(r.template)) rows.sort( (a, b) => (a.line_index ?? 0) - (b.line_index ?? 0) || (gulf ? 0 : bandRank(a.band) - bandRank(b.band)), ) } return map }, [allFields?.rows]) const save = useMutation({ mutationFn: ({ row, draft, verified }: SaveArgs) => { const text = draft.trim() // Storing a "correction" identical to what was extracted would turn every // sign-off into an edit and make the audit trail useless. Same value, or // an emptied box, means "the extracted value was already right." const corrected = text === '' || text === (row.value ?? '') ? null : text // A field that was never queued (needs_review === false) has no review // row until this moment on most templates — build_field_rows only emits // a row when a value exists or the field is billing-critical, so a // genuinely blank optional field (e.g. Gulf Coast's From DOS / To DOS / // Units, always null today) is addressed by the cell it occupies, // line_index included, rather than by row_id. if (row.needs_review === false) { return api.editField(jobId, { page: row.page, patient: row.patient, field: row.field, value: row.value, band: row.band, score: row.score, corrected_value: corrected, verified, line_index: row.line_index, }) } return api.patchRow(row.row_id, { corrected_value: corrected, verified }) }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['review', jobId] }) qc.invalidateQueries({ queryKey: ['review-all-fields', jobId] }) qc.invalidateQueries({ queryKey: ['run', jobId] }) qc.invalidateQueries({ queryKey: ['batches'] }) }, }) // Changing the filter shortens the list; an index left past the end would // render nothing at all rather than the first match. useEffect(() => { if (index > patients.length - 1) setIndex(0) }, [patients.length, index]) const current = patients[index] const currentRows = current ? (allByPatient.get(current.key) ?? current.flagged) : [] const settledMap = useMemo(() => { const m: Record = {} for (const row of currentRows) m[row.row_id] = row.corrected_value ?? row.value ?? '' return m }, [currentRows]) // Fresh drafts for the patient just opened — never carry the last patient's // typed-but-unsaved text into this one's fields. useEffect(() => { setDrafts(settledMap) // settledMap intentionally excluded: a background refetch (e.g. from // approving elsewhere) must not silently wipe an in-progress edit. Drafts // reset only when the OPEN PATIENT changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [current?.key]) // Only the OPEN patient's rows can be dirty. On the render right after a // Next, `current` is already the new patient while `drafts` still holds the // previous one's row ids (the reset effect below runs a task later), so // without the `id in settledMap` test every old value counted as an unsaved // edit for a frame: the Save bar flashed, App re-rendered twice, and a quick // second click hit guardLeave's window.confirm — a modal that blocks the tab. const dirtyFields = useMemo( () => Object.keys(drafts) .map(Number) .filter((id) => id in settledMap && drafts[id] !== settledMap[id]), [drafts, settledMap], ) const saveAll = useMutation({ mutationFn: async () => { // One request per dirty field, run together — there is no bulk endpoint, // and a reviewer who edited three fields expects one action to land all // three, not three separate confirmations. await Promise.all( dirtyFields.map((rowId) => { const row = currentRows.find((r) => r.row_id === rowId) if (!row) return Promise.resolve() return save.mutateAsync({ row, draft: drafts[rowId], verified: true }) }), ) }, }) const discardAll = () => setDrafts(settledMap) // Leaving a patient with typed-but-unsaved edits used to be silent — the old // blur handler saved automatically, which is what produced the ARMSTRONG // corruption. Now nothing commits without Save, so leaving instead has to // ask, or the edit is just lost with no signal either way. const guardLeave = (): boolean => { if (dirtyFields.length === 0) return true return window.confirm( `${dirtyFields.length} unsaved change(s) for ${current?.name ?? 'this patient'}. ` + 'Discard them and continue?', ) } const gotoIndex = (next: number) => { if (!guardLeave()) return discardAll() setIndex(next) } // PdfPane's toolbar drives the exact same save/discard, not a second copy of // this state — one save action, reachable from either pane. useEffect(() => { onActionsChange?.( current ? { patientName: current.name, dirtyCount: dirtyFields.length, saving: saveAll.isPending, onSave: () => saveAll.mutate(), onDiscard: discardAll, } : null, ) return () => onActionsChange?.(null) // eslint-disable-next-line react-hooks/exhaustive-deps }, [current?.key, dirtyFields.length, saveAll.isPending]) // Opened at a page ("Go to page" on the Automation board): start on that // page's patient. App sets the page in the same render as the job, so the // page seen when the job changes is the one asked for; land once per job, // after both reads are in so the patient list is complete. const wanted = useRef({ job: jobId, page: activePage }) if (wanted.current.job !== jobId) wanted.current = { job: jobId, page: activePage } const landedFor = useRef(null) useEffect(() => { if (landedFor.current === jobId || !data || !allFields) return landedFor.current = jobId const at = patients.findIndex((p) => p.page === wanted.current.page) if (at > 0) setIndex(at) }, [jobId, data, allFields, patients]) // The PDF follows the patient. Without this the reviewer would be reading // page 1 while confirming a patient from page 3. useEffect(() => { if (current && current.page !== activePage) onPickPage(current.page) // activePage is deliberately not a dependency: the reviewer is free to page // around the PDF manually, and re-asserting on every change would fight it. // eslint-disable-next-line react-hooks/exhaustive-deps }, [current?.key]) // ←/→ move between patients. Guarded so typing a correction into an input // still moves the caret rather than jumping to another patient. useEffect(() => { const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement | null if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return if (e.key === 'ArrowRight') gotoIndex(Math.min(index + 1, patients.length - 1)) if (e.key === 'ArrowLeft') gotoIndex(Math.max(index - 1, 0)) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) // eslint-disable-next-line react-hooks/exhaustive-deps }, [patients.length, index, dirtyFields.length]) if (!data) { // Keyed on "no data yet", not isLoading: react-query pauses a retry while // the tab is hidden, and a paused query is pending but not fetching, so // isLoading went false and the pane rendered nothing at all. The error // state is reached once a read has timed out AND its one retry failed — // it used to be a blank pane with no way forward but a reload. // isFetching too: after an error react-query keeps isError true while a // Retry is in flight, and the button would sit there looking dead. return isError && !isFetching ? (
The review queue did not load.
) : (
Loading review queue…
) } const pct = data.progress.total ? Math.round((data.progress.verified / data.progress.total) * 100) : 0 return (
{/* ── overall progress ─────────────────────────────────────────────── The band filter pills (All / Red / Amber / Missing code / Unscored) and the "Hide done" checkbox were removed. The review pane already opens on the patients that need attention and orders every record worst-band-last, so the pills mostly re-stated the ordering while costing a full row of chrome at the top of the narrowest pane in the app — and switching one silently changed which patients existed, which read as data disappearing. */} {/* The legend, so a grade rule down a field input means something without crossing to the other pane to read the rail's swatches. */}
≥0.95 clean 0.90–0.95 sample <0.90 review print clarity, not correctness
{data.progress.total > 0 && (
{data.progress.verified}/{data.progress.total} fields
)} {!current ? (
✓

{data.progress.total === 0 ? 'Nothing extracted from this document yet.' : 'Nothing matches this filter.'}

) : ( setDrafts((d) => ({ ...d, [rowId]: value }))} dirtyFields={dirtyFields} onVerifyToggle={(row, verified) => save.mutate({ row, draft: drafts[row.row_id] ?? '', verified })} onSaveAll={() => saveAll.mutate()} onDiscardAll={discardAll} saving={saveAll.isPending} onJump={gotoIndex} onPrev={() => gotoIndex(Math.max(index - 1, 0))} onNext={() => gotoIndex(Math.min(index + 1, patients.length - 1))} /> )}
) } function PatientView({ patient, allRows, position, count, patients, drafts, onDraftChange, dirtyFields, onVerifyToggle, onSaveAll, onDiscardAll, saving, onJump, onPrev, onNext, }: { patient: Patient allRows: ReviewRow[] position: number count: number patients: Patient[] drafts: Record onDraftChange: (rowId: number, value: string) => void dirtyFields: number[] onVerifyToggle: (row: ReviewRow, verified: boolean) => void onSaveAll: () => void onDiscardAll: () => void saving: boolean onJump: (index: number) => void onPrev: () => void onNext: () => void }) { const rows = allRows const [find, setFind] = useState('') const [missed, setMissed] = useState(false) const note = useMemo(() => readOfficeNote(allRows), [allRows]) // Office Location asks for every visible field to be OK'd, green included -- // the note rewrites what gets billed, so nothing on it is taken on trust. const olVisible = useMemo(() => (note ? officeVisibleRows(allRows) : []), [note, allRows]) const olDone = olVisible.filter((r) => r.verified).length const done = patient.flagged.filter((r) => r.verified).length const complete = note ? olDone === olVisible.length && dirtyFields.length === 0 : done === patient.flagged.length && dirtyFields.length === 0 const atStart = position === 1 const atEnd = position === count const nextTone = nextButtonTone(complete) const badgeTone = verifiedBadgeTone(complete) const dirty = dirtyFields.length > 0 return (
{/* ── the patient, and how to leave them ─────────────────────────── */}
{position} / {count} {/* Find a ticket by number: the Automation board names tickets, and stepping the jump list to one of 41 is slow. Enter jumps. */} { setFind(e.target.value) setMissed(false) }} onKeyDown={(e) => { if (e.key !== 'Enter' || !find.trim()) return const at = patients.findIndex((p) => p.name.includes(find.trim())) if (at === -1) setMissed(true) else onJump(at) }} placeholder="Ticket #" inputMode="numeric" aria-label="Find a ticket number" title={missed ? 'No ticket with that number in this document' : 'Type a ticket number and press Enter'} className={`sb-input ml-auto w-28 shrink-0 px-2 py-1 text-xs ${missed ? 'border-review' : ''}`} /> {/* A jump list, so moving to a named patient does not mean stepping through everyone in between. */}

{patient.name}

page {patient.page} {note && Office Location} {note ? `${olDone}/${olVisible.length} verified` : patient.flagged.length === 0 ? 'nothing flagged' : `${done}/${patient.flagged.length} verified`}
{/* ── the fields, with room to read ──────────────────────────────── */}
{note ? ( ( onDraftChange(row.row_id, v)} onVerifyToggle={(v) => onVerifyToggle(row, v)} /> )} /> ) : (
{/* Keyed on row_id — a real database identity from /all-fields (SPB_Extraction.RowId), unique across every field on every page. A page-and-field key would collide on Gulf Coast's repeated CPT lines, where two rows share the same (page, field). */} {/* No entry animation. Rows are keyed by row_id, so every Next remounts them all, and the old staggered fade (opacity 0 over 280ms, up to 144ms delay) replayed on every patient: a reviewer clicking through quickly only ever saw fields half faded in, which read as the screen still loading. Values appear at once. */} {rows.map((row) => (
onDraftChange(row.row_id, v)} onVerifyToggle={(v) => onVerifyToggle(row, v)} />
))}
)}

{BAND_CAVEAT}

{/* Repeated at the end of the list: after working down a long record the reviewer is at the bottom, and the header's Next is scrolled away. */} {!atEnd && !dirty && ( )}
{/* Nothing commits on blur anymore — this bar is the one place edits are actually written. PdfPane carries an identical pair of buttons driven by the same state, for whichever pane is in view. */} {dirty && (
)}
) } /** * One field, edited where it is displayed. * * There used to be two boxes per row: the banded value, read-only, and a * separate empty "correct value" input beside it. That asked the reviewer to * read a value in one place and retype it in another. Now the banded chip IS * the input — the band colour stays on it, so the legibility signal is not * lost by making it editable. * * Nothing here commits on its own. An earlier version saved on blur, which is * exactly how "ARMSTRONG, CHARLES T" got recorded as "\ONG, CHARLES T" — the * moment focus left the box mid-keystroke, whatever half-typed text was there * became the correction. Typing only updates the shared draft in ReviewPane; * the patient-level Save bar (here and in PdfPane) is the one place an edit is * actually written. * * Every field is editable, green included. Green means the print was legible, * not that the value is right: a confident misread scores green like anything * else, and the reviewer is the only thing that catches those. */ function FieldRow({ row, label, wide, draft, dirty, onChange, onVerifyToggle, }: { row: ReviewRow /** Shown in place of the field name; the accessible name keeps the real one. */ label?: string /** A wider label column, for names like "Referring Provider Update". */ wide?: boolean draft: string dirty: boolean onChange: (v: string) => void onVerifyToggle: (verified: boolean) => void }) { const [focused, setFocused] = useState(false) const corrected = row.corrected_value != null && row.corrected_value !== row.value // While being edited the value takes its own full-width line. A DX field is // five comma-separated codes and a provider name runs past twenty // characters; in the shared row they were truncated to "ARMSTRONG, C", so a // reviewer was correcting a value they could not fully see. Expanding only // on focus keeps the scan-many-fields-fast layout for reading. const expanded = focused || dirty // The per-row button never writes a typed edit — only Save does that. It // stays reserved for the one action that IS safe to fire immediately: sign // off on (or undo signing off on) the value exactly as it already reads. const saveTone = saveButtonTone(row.verified, false) return (
{label ?? row.field} {/* While being edited the value takes its own full-width row of the grid: a DX field is five comma-separated codes and a provider name runs past twenty characters, and in the shared 4-column row they were clipped. */} onChange(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} onKeyDown={(e) => { // Reverting one field is safe to do instantly — it only throws away // text that was never saved. Reverting is the opposite risk of the // old blur-commit and is fine to leave immediate. if (e.key === 'Escape') onChange(row.corrected_value ?? row.value ?? '') }} // The field's own label (DOB, CPT Code, …) makes the accessible name // specific per row; a placeholder would not survive having content. // The band note is folded in rather than left to the tooltip: the // tooltip is visual only, so this is the sole route by which a screen // reader learns the value was handwritten rather than printed. aria-label={`${row.field}${row.patient ? ` for ${row.patient}` : ''} — ${bandNote(row.band, row.reason)}`} placeholder="empty" // The grade is a 3px rule down the input's left edge, not a fill: // every value then sits on the same surface and only its margin is // coloured, so a column of fields reads as a list rather than a wash. className={`sb-field-input ${GRADE_CLASS[row.band]}`} /> {row.score?.toFixed(2) ?? '—'} {dirty ? ( edited ) : ( )} {corrected && !dirty && (

extracted {row.value || 'empty'}

)}
) } /** * A Gulf Coast ticket laid out the way its yellow note is: the note itself, * the page-level rules it fired, the visit, then one tab per encounter. * * Hovering a field lights the note line it came from. The page preview is an * image with no text positions, so the line is lit here, in the note as read, * rather than drawn over the scan. */ function OfficeLocationView({ rows, note, renderField, }: { rows: ReviewRow[] note: OfficeNote renderField: (row: ReviewRow, label?: string, wide?: boolean) => React.ReactNode }) { // Charge k of the note is the ticket's line_index k: the backend expands // the note into lines in exactly this order. const charges = note.encounters.flatMap((e) => e.charges) const visible = new Set(officeVisibleRows(rows).map((r) => r.row_id)) const lines = new Map() for (const r of rows) { const i = r.line_index ?? 0 if (!lines.has(i)) lines.set(i, []) lines.get(i)!.push(r) } const lineIds = [...lines.keys()].sort((a, b) => a - b) const encounterOf = (i: number) => valueOf(lines.get(i)?.find((r) => r.field === 'Encounter#') ?? ({} as ReviewRow)) ?? 'E1' const encounterIds = [...new Set(lineIds.map(encounterOf))] const [sel, setSel] = useState(encounterIds[0] ?? 'E1') const selected = note.encounters.find((e) => e.encounter_no === sel) const first = lines.get(lineIds[0] ?? 0) ?? [] const pageRow = (field: string) => first.find((r) => r.field === field) const ticket = pageRow('Ticket#') const keyed = (key: string | number, node: React.ReactNode) =>
{node}
// A separate encounter that names its own specialty or provider carries // them on its first line; it is shown beside E1's under Note rules. const providerRows = (line: number | undefined) => (lines.get(line ?? -1) ?? []).filter( (r) => PROVIDER_FIELDS.has(r.field) && visible.has(r.row_id), ) const ownRules = encounterIds.slice(1).flatMap((id) => { const rows = providerRows(lineIds.find((i) => encounterOf(i) === id)) return rows.length ? [{ id, rows }] : [] }) const fired = new Set(note.rules_fired) const encounterTag = note.no_show ? 'R5 · no show' : ['R2', 'R4', 'R6', 'R7'].filter((r) => fired.has(r)).join(' · ') const ruleTag = ['R1', 'R3'].filter((r) => fired.has(r)).join(' · ') return (
{note.warnings.map((w) => (

{w}

))}
Note rules {ruleTag && {ruleTag}}
{ownRules.length === 0 ? OL_RULE_FIELDS.map(({ field }) => { const row = pageRow(field) return row && keyed(row.row_id, renderField(row, undefined, true)) }) : [ pageRow('Auth#') && keyed('auth', renderField(pageRow('Auth#')!, undefined, true)), ...[{ id: encounterIds[0] ?? 'E1', rows: providerRows(lineIds[0]) }, ...ownRules].map( ({ id, rows }) => (
{id} {id !== encounterIds[0] && own provider} {id === encounterIds[0] ? 'main encounter' : 'separate encounter'}
{rows.map((row) => keyed(row.row_id, renderField(row, undefined, true)))}
), ), ]}
Visit
{ticket && keyed(ticket.row_id, renderField(ticket))}
Encounters {encounterTag && {encounterTag}}
{encounterIds.length > 1 && (
{encounterIds.map((id) => { const e = note.encounters.find((x) => x.encounter_no === id) const n = lineIds.filter((i) => encounterOf(i) === id).length return ( ) })}
)}

{note.no_show ? 'No show — CPT “No Show” with the placeholder dx. Nothing is sent to Tebra.' : selected?.separate ? `Separate encounter — same patient and ticket, posted as its own charge.${ note.auth_number ? ` Auth# ${note.auth_number} carries over.` : '' }` : 'Main encounter — these lines post together as one charge.'}

{lineIds .filter((i) => encounterOf(i) === sel) .map((i, n) => { const charge = charges[i] const own = (lines.get(i) ?? []).filter( (r) => visible.has(r.row_id) && !OL_PAGE_FIELDS.has(r.field), ) const cpt = valueOf(own.find((r) => r.field === 'CPT') ?? ({} as ReviewRow)) const units = charge?.units ?? 1 const dx = own.filter((r) => /^ICD\d+$/.test(r.field) && valueOf(r)).length return (
Line {n + 1} · {cpt || 'no CPT'} {units > 1 ? ` × ${units}` : ''} {cpt === NO_SHOW ? 'R5 · no show · ' : ''} {/^\d{5}[A-Z]{2}$/i.test(cpt ?? '') ? 'R7 · CPT with suffix · ' : ''} {dx} dx{charge ? ` · note line ${charge.source_line + 1}` : ''}
{own.map((r) => keyed( r.row_id, renderField( r, cpt === NO_SHOW && r.field === 'ICD1' ? 'ICD1 (dummy)' : undefined, ), ), )}
) })} {encounterIds.length > 1 && (

Switch to {encounterIds.filter((id) => id !== sel).join(', ')} to verify the other encounter.

)}
) }