Files
Rpa_Skills/skills/rpa-dashboard-ai/templates/reference/web/components/ReviewPane.tsx
T

1120 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
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 Example Client 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<Band, string> = {
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 Example Client 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<string>(['Ticket#', ...OL_RULE_FIELDS.map((f) => f.field)])
/** The rule fields each encounter can set for itself. */
const PROVIDER_FIELDS = new Set<string>(['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<string, number>
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 fields of one record got saved mid-keystroke:
// "Doe, Jane A" as "\e, Jane A", "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 (Example Client's repeated CPT lines), and DemographyTab.tsx
// hit exactly this before switching its own drafts to row_id.
const [drafts, setDrafts] = useState<Record<number, string>>({})
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<Patient[]>(() => {
const byKey = new Map<string, Patient>()
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 a later surname 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<string, ReviewRow[]>()
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()) {
// Example Client (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. Example Client'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<number, string> = {}
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 truncated-name
// 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<string | null>(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 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted">
The review queue did not load.
<button
type="button"
className="sb-btn sb-btn-quiet sb-btn-sm"
// Both reads, not just this one: they time out together, and a Retry
// that brought back only the flagged queue would show a pane missing
// every clean field and unflagged patient with nothing to say so.
onClick={() => {
refetch()
qc.refetchQueries({ queryKey: ['review-all-fields', jobId] })
}}
>
Retry
</button>
</div>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted">
Loading review queue…
</div>
)
}
const pct = data.progress.total
? Math.round((data.progress.verified / data.progress.total) * 100)
: 0
return (
<div className="flex h-full min-h-0 w-full min-w-0 flex-col bg-ground">
{/* ── 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. */}
<div className="sb-rv-keys shrink-0">
<span>
<i className="sb-swatch" style={{ backgroundColor: BAND_VAR.green }} />
≥0.95 clean
</span>
<span>
<i className="sb-swatch" style={{ backgroundColor: BAND_VAR.amber }} />
0.90–0.95 sample
</span>
<span>
<i className="sb-swatch" style={{ backgroundColor: BAND_VAR.red }} />
&lt;0.90 review
</span>
<span>print clarity, not correctness</span>
</div>
{data.progress.total > 0 && (
<div className="sb-rv-status shrink-0">
<span className="sb-rv-progress flex-1">
<i style={{ width: `${pct}%` }} />
</span>
<span className="tnum shrink-0 text-xs text-muted">
{data.progress.verified}/{data.progress.total} fields
</span>
</div>
)}
{!current ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2.5 px-6 text-center">
<span
className="flex h-10 w-10 items-center justify-center rounded-full text-lg"
style={{ background: 'var(--brand-grad)', color: 'var(--accent-fg)' }}
>
✓
</span>
<p className="max-w-xs text-sm text-muted">
{data.progress.total === 0
? 'Nothing extracted from this document yet.'
: 'Nothing matches this filter.'}
</p>
</div>
) : (
<PatientView
patient={current}
allRows={currentRows}
position={index + 1}
count={patients.length}
patients={patients}
drafts={drafts}
onDraftChange={(rowId, value) => 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))}
/>
)}
</div>
)
}
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<number, string>
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 (
<div className="flex min-h-0 flex-1 flex-col">
{/* ── the patient, and how to leave them ─────────────────────────── */}
<div className="shrink-0 border-b border-line-soft bg-rail px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<button
onClick={onPrev}
disabled={atStart}
title="Previous patient (←)"
className="sb-btn sb-btn-quiet sb-btn-sm shrink-0"
>
← Prev
</button>
<span className="tnum shrink-0 text-xs text-muted">
{position} / {count}
</span>
<button
onClick={onNext}
disabled={atEnd}
title="Next patient (→)"
className={`sb-btn sb-btn-sm shrink-0 ${nextTone.className}`}
style={nextTone.style}
>
Next →
</button>
{/* Find a ticket by number: the Automation board names tickets, and
stepping the jump list to one of 41 is slow. Enter jumps. */}
<input
value={find}
onChange={(e) => {
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. */}
<select
value={position - 1}
onChange={(e) => onJump(Number(e.target.value))}
className="sb-select min-w-0 max-w-[45%] shrink text-xs"
title="Jump to a patient"
>
{patients.map((p, i) => {
const d = p.flagged.filter((r) => r.verified).length
return (
<option key={p.key} value={i}>
{d === p.flagged.length ? '✓ ' : ''}p{p.page} · {p.name} ({d}/
{p.flagged.length})
</option>
)
})}
</select>
</div>
<div className="mt-2.5 flex flex-wrap items-baseline gap-2.5">
<h2 className="sb-display tnum min-w-0 truncate text-[19px] font-semibold text-ink">
{patient.name}
</h2>
<span className="shrink-0 rounded-full border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-muted">
page {patient.page}
</span>
{note && <span className="sb-pill sb-pill-mute shrink-0">Office Location</span>}
<span
className={`ml-auto shrink-0 ${badgeTone.className}`}
style={badgeTone.style}
>
{note
? `${olDone}/${olVisible.length} verified`
: patient.flagged.length === 0
? 'nothing flagged'
: `${done}/${patient.flagged.length} verified`}
</span>
</div>
</div>
{/* ── the fields, with room to read ──────────────────────────────── */}
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{note ? (
<OfficeLocationView
key={patient.key}
rows={rows}
note={note}
renderField={(row, label, wide) => (
<FieldRow
row={row}
label={label}
wide={wide}
draft={drafts[row.row_id] ?? row.corrected_value ?? row.value ?? ''}
dirty={dirtyFields.includes(row.row_id)}
onChange={(v) => onDraftChange(row.row_id, v)}
onVerifyToggle={(v) => onVerifyToggle(row, v)}
/>
)}
/>
) : (
<div className="flex flex-col gap-[9px]">
{/* 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 Example Client'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) => (
<div key={row.row_id}>
<FieldRow
row={row}
draft={drafts[row.row_id] ?? row.corrected_value ?? row.value ?? ''}
dirty={dirtyFields.includes(row.row_id)}
onChange={(v) => onDraftChange(row.row_id, v)}
onVerifyToggle={(v) => onVerifyToggle(row, v)}
/>
</div>
))}
</div>
)}
<p className="mt-4 text-[11px] leading-snug text-muted">{BAND_CAVEAT}</p>
{/* 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 && (
<button
onClick={onNext}
// Office Location: locked until every visible field is OK'd.
disabled={!!note && !complete}
className={`sb-btn mt-3 w-full ${nextTone.className}`}
style={nextTone.style}
>
{complete
? 'Verified — next patient →'
: note
? `Verify ${olVisible.length - olDone} more field${olVisible.length - olDone === 1 ? '' : 's'}`
: 'Next patient →'}
</button>
)}
</div>
{/* 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 && (
<div
className="sb-slide-up flex shrink-0 flex-wrap items-center gap-2.5 border-t bg-rail px-4 py-2.5"
style={{ borderTopColor: 'color-mix(in srgb, var(--verify) 45%, transparent)' }}
>
<span
className="flex items-center gap-1.5 text-xs font-semibold"
style={{ color: 'var(--verify)' }}
>
<span
className="h-1.5 w-1.5 animate-pulse rounded-full"
style={{ backgroundColor: 'var(--verify)' }}
aria-hidden="true"
/>
{dirtyFields.length} unsaved change{dirtyFields.length === 1 ? '' : 's'}
</span>
<button
onClick={onDiscardAll}
disabled={saving}
className="sb-btn sb-btn-quiet sb-btn-sm ml-auto"
>
Discard
</button>
<button
onClick={onSaveAll}
disabled={saving}
className="sb-btn sb-btn-primary sb-btn-sm relative overflow-hidden"
>
<span className={saving ? 'sb-sweep' : undefined}>{saving ? 'Saving…' : 'Save'}</span>
</button>
</div>
)}
</div>
)
}
/**
* 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 "Doe, Jane A" got recorded as "\e, Jane A" — 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 "Doe, J", 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 (
<div
className={`sb-field-row transition-[border-color,opacity] duration-200 ${
wide ? 'sb-wide-lbl' : ''
} ${row.verified && !dirty ? 'sb-done opacity-70 hover:opacity-100' : ''}`}
style={
dirty ? { borderColor: 'color-mix(in srgb, var(--verify) 55%, transparent)' } : undefined
}
>
<span className="sb-lbl min-w-0 truncate" title={row.field}>
{label ?? row.field}
</span>
{/* 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. */}
<Tooltip
label={bandNote(row.band, row.reason)}
className={`block min-w-0 ${expanded ? 'mt-1 [grid-column:1/-1] [grid-row:2]' : ''}`}
>
<input
value={draft}
onChange={(e) => 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]}`}
/>
</Tooltip>
<Tooltip
label={
row.score != null
? `Legibility ${row.score.toFixed(2)} — how clearly the transcription read this, not whether it is correct`
: 'No legibility score: this value never appeared in the transcription'
}
className={`sb-conf block ${expanded ? '[grid-column:3] [grid-row:1]' : ''}`}
>
{row.score?.toFixed(2) ?? '—'}
</Tooltip>
{dirty ? (
<span
className={`sb-pill sb-pill-warn text-center ${
expanded ? '[grid-column:4] [grid-row:1]' : ''
}`}
>
edited
</span>
) : (
<Tooltip
label={
row.verified
? 'Undo the sign-off on this field'
: 'Confirm the value exactly as it reads — this never commits typed text'
}
className={`block ${expanded ? '[grid-column:4] [grid-row:1]' : ''}`}
>
<button
onClick={() => onVerifyToggle(!row.verified)}
className={`sb-btn sb-btn-sm w-full px-0 ${saveTone.className}`}
style={saveTone.style}
>
{row.verified ? 'Undo' : 'OK'}
</button>
</Tooltip>
)}
{corrected && !dirty && (
<p className="text-[11px] text-muted [grid-column:1/-1]">
extracted <span className="line-through">{row.value || 'empty'}</span>
</p>
)}
</div>
)
}
/**
* A Example Client 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<number, ReviewRow[]>()
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) => <div key={key}>{node}</div>
// 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 (
<div className="flex flex-col gap-[9px]">
{note.warnings.map((w) => (
<p key={w} className="sb-ol-warn" role="note">
{w}
</p>
))}
<div className="sb-ol-sect">
Note rules {ruleTag && <span className="sb-ol-tag">{ruleTag}</span>}
</div>
{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 }) => (
<div
key={id}
className={`sb-ol-line${id === encounterIds[0] ? '' : ' sb-ol-line-own'}`}
>
<div className="sb-ol-line-h">
<b>
{id}
{id !== encounterIds[0] && <span className="sb-ol-tag">own provider</span>}
</b>
<span>{id === encounterIds[0] ? 'main encounter' : 'separate encounter'}</span>
</div>
{rows.map((row) => keyed(row.row_id, renderField(row, undefined, true)))}
</div>
),
),
]}
<div className="sb-ol-sect">Visit</div>
{ticket && keyed(ticket.row_id, renderField(ticket))}
<div className="sb-ol-sect">
Encounters {encounterTag && <span className="sb-ol-tag">{encounterTag}</span>}
</div>
{encounterIds.length > 1 && (
<div className="sb-ol-tabs" role="tablist" aria-label="Encounters">
{encounterIds.map((id) => {
const e = note.encounters.find((x) => x.encounter_no === id)
const n = lineIds.filter((i) => encounterOf(i) === id).length
return (
<button
key={id}
role="tab"
aria-selected={id === sel}
onClick={() => setSel(id)}
className="sb-ol-tab"
>
{id}
{e?.separate ? ' · separate' : ''}
<small>
{n} line{n === 1 ? '' : 's'}
</small>
</button>
)
})}
</div>
)}
<p className="sb-ol-info">
{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.'}
</p>
{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 (
<div key={i} className="sb-ol-line">
<div className="sb-ol-line-h">
<b>
Line {n + 1} · {cpt || 'no CPT'}
{units > 1 ? ` × ${units}` : ''}
</b>
<span>
{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}` : ''}
</span>
</div>
{own.map((r) =>
keyed(
r.row_id,
renderField(
r,
cpt === NO_SHOW && r.field === 'ICD1' ? 'ICD1 (dummy)' : undefined,
),
),
)}
</div>
)
})}
{encounterIds.length > 1 && (
<p className="sb-ol-info">
Switch to {encounterIds.filter((id) => id !== sel).join(', ')} to verify the other
encounter.
</p>
)}
</div>
)
}