pediatric-ai-scribe-v3/client/src/components/RosPeTable.tsx
Daniel 9ebdcaf4b6 feat(notes): structured ROS + PE + ICD-10 diagnosis pickers (WellVisit, SickVisit)
Ports the vanilla ROS / Physical Exam / ICD-10 diagnosis cards from
public/js/shadess.js (@be14578) — renderRosRows, wireRosContainer,
renderDxComponent — which the earlier React port had collapsed into
plain-text textareas.

Clinical data → shared/clinical/ros-pe-dx.ts:
  • ROS_SYSTEMS (15 systems, each with label + detail hint)
  • PE_SYSTEMS (17 systems)
  • COMMON_DX (28 pediatric quick-pick ICD-10 codes)
  • formatRosForAI / formatDxForAI — byte-identical to vanilla so
    the server-side prompt context is unchanged.
  Tests: ros-pe-dx.test.ts asserts the table counts, ordering, and
  format strings — catches the class of bug where an LLM silently
  drops an entry or re-orders the clinical reference during a port.

New React components:
  client/src/components/RosPeTable.tsx — tri-state row (WNL/Abnormal/
    Not reviewed) with auto-revealed note field on Abnormal.
    rosAllWnl / rosClear helpers mirror the "All WNL" / "Clear"
    buttons from vanilla. Accepts a btnLabels prop so the same
    component renders ROS ("WNL"/"Not reviewed") and PE ("Normal"/
    "Not examined").

  client/src/components/DxPicker.tsx — ICD-10 live search via NLM
    Clinical Tables API (free, no auth, CORS-OK) with 280ms debounce
    + AbortController; chip-style selected tags with × remove;
    28-entry common-diagnosis quick-pick grid. Enter key adds the
    top result.

Wired into WellVisit / VisitNote.tsx and SickVisit.tsx:
  • Submit now sends ros / physicalExam / diagnoses as formatted
    strings to /api/well-visit/note and /api/sick-visit/note.
  • State persists through the Save/Load encounter flow via
    partialData → the rosData/peData/diagnoses round-trip.
2026-04-24 05:24:09 +02:00

97 lines
3.9 KiB
TypeScript

// ============================================================
// RosPeTable — structured ROS or PE input. Per-system row with
// WNL / Abnormal / Not reviewed toggle + a note field that reveals
// when "Abnormal" is selected. Mirrors window.renderRosRows /
// wireRosContainer from public/js/shadess.js (@be14578).
//
// Consumers (WellVisit Note, Sick Visit Note) pass systems list +
// data object + label set so the same component renders both
// review-of-systems and physical-exam tables.
// ============================================================
import type { RosData, RosStatus, SystemEntry } from '@shared/clinical/ros-pe-dx';
interface BtnLabels { wnl: string; abnormal: string; notrev: string }
interface Props {
systems: ReadonlyArray<SystemEntry>;
data: RosData;
onChange: (next: RosData) => void;
btnLabels?: BtnLabels;
testIdPrefix?: string;
}
export function rosAllWnl(systems: ReadonlyArray<SystemEntry>, data: RosData): RosData {
const next: RosData = { ...data };
for (const s of systems) next[s.key] = { status: 'wnl', note: next[s.key]?.note };
return next;
}
export function rosClear(data: RosData, systems: ReadonlyArray<SystemEntry>): RosData {
const next: RosData = { ...data };
for (const s of systems) next[s.key] = {};
return next;
}
const DEFAULT_LABELS: BtnLabels = { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' };
function statusClass(active: boolean, kind: RosStatus) {
if (!active) return 'bg-background border-border hover:bg-muted';
if (kind === 'wnl') return 'bg-green-100 text-green-800 border-green-300';
if (kind === 'abnormal') return 'bg-red-100 text-red-800 border-red-300';
return 'bg-muted text-muted-foreground border-border';
}
export default function RosPeTable({
systems, data, onChange, btnLabels, testIdPrefix = 'ros',
}: Props) {
const labels = btnLabels || DEFAULT_LABELS;
function setStatus(key: string, status: RosStatus) {
const cur = data[key]?.status || '';
const nextStatus: RosStatus = cur === status ? '' : status;
onChange({ ...data, [key]: { status: nextStatus, note: data[key]?.note || '' } });
}
function setNote(key: string, note: string) {
onChange({ ...data, [key]: { status: data[key]?.status || '', note } });
}
return (
<div className="divide-y divide-border" data-testid={testIdPrefix + '-table'}>
{systems.map((sys) => {
const cell = data[sys.key] || {};
const active = cell.status || '';
return (
<div key={sys.key} className="flex flex-wrap items-center gap-2 px-2 py-1.5" data-testid={testIdPrefix + '-row-' + sys.key}>
<div className="flex-1 min-w-[180px]" title={sys.detail}>
<span className="text-sm font-medium">{sys.label}</span>
<span className="ml-1 text-[10px] text-muted-foreground">({sys.detail})</span>
</div>
<div className="flex gap-1 shrink-0">
{(['wnl', 'abnormal', 'notrev'] as const).map((kind) => (
<button
key={kind}
type="button"
onClick={() => setStatus(sys.key, kind)}
className={'text-[10px] uppercase tracking-wider px-2 py-1 rounded border ' + statusClass(active === kind, kind)}
data-testid={testIdPrefix + '-btn-' + sys.key + '-' + kind}
>
{labels[kind]}
</button>
))}
</div>
{active === 'abnormal' && (
<input
type="text"
value={cell.note || ''}
onChange={(e) => setNote(sys.key, e.target.value)}
placeholder="Describe finding…"
className="flex-1 min-w-[200px] rounded-md border border-input bg-background px-2 py-1 text-xs"
data-testid={testIdPrefix + '-note-' + sys.key}
/>
)}
</div>
);
})}
</div>
);
}