1119 lines
45 KiB
TypeScript
1119 lines
45 KiB
TypeScript
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<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 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<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 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<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 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<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()) {
|
||
// 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<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 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<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 }} />
|
||
<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 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) => (
|
||
<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 "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 (
|
||
<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 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<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>
|
||
)
|
||
}
|