pediatric-ai-scribe-v3/client/src/pages/wellvisit/VisitNote.tsx
Daniel 8253b34dc5 feat(wellvisit): port the 4 sub-tabs vanilla had — By Visit / Milestones / SSHADESS / Note
Earlier WellVisit was a single-pane skeleton — the comment in that
file even admitted "Milestones and SSHADESS sub-tabs land in a
follow-up". This commit lands them, faithful to public/components/
wellvisit.html and the three modules behind it (wellVisit.js,
milestones.js, shadess.js, all @be14578).

WellVisit.tsx is now an 83-line shell that lazy-loads four panels:

  client/src/pages/wellvisit/
    ByVisitAge.tsx  — per-visit AAP Bright Futures recommendations
                       (billing codes, measurements, vaccines,
                       sensory/dev/proc/oral screens, growth +
                       feeding, expected reflexes, BMI table,
                       notes). Status buttons mirror vanilla
                       (Given/Refused/Deferred/Already-Done for
                       vaccines; Done/Refused/N-A for screens).
                       Statuses persist to localStorage under
                       ped_visit_statuses (same key as vanilla).
    Milestones.tsx  — checklist per age group, three-state toggle
                       (✓/✗/blank=not assessed), All-Yes/Clear,
                       Generate → /api/generate-milestone-narrative,
                       3-sentence summary → /api/generate-milestone-
                       summary, Copy-to-Note bridge.
    Shadess.tsx     — 8 SSHADESS domains verbatim from shadess.js
                       (Strengths, School, Home, Activities, Drugs,
                       Emotions/Eating, Sexuality, Safety) with
                       concern_if auto-flag, skip toggle, manual
                       concern toggle, optional Listen-In recorder,
                       Generate → /api/well-visit/shadess.
    VisitNote.tsx   — pre-existing note generator + carry-over
                       pickup from sessionStorage so the user can
                       flow Milestones → SSHADESS → Note without
                       retyping. Now also includes the recorder.

Server: GET /api/schedule-data now also returns wellVisitCodes,
growthReference, reflexesReference, and bmiClassification so the
React side has everything it needs without a second round trip.

Tests:
  shared/clinical/visit-status.ts (+ .test.ts) — pure helpers
  for the visitId → growth/reflex key mapping (the vanilla
  tables collapse 6y/7y/8y/9y/10y onto one window, etc.) and
  the reflex-status color rules. Lives in shared/ so the root
  vitest config picks it up; ByVisitAge.tsx imports from there.
2026-04-24 05:24:09 +02:00

242 lines
12 KiB
TypeScript

// ============================================================
// WELL VISIT — note generator (one of four sub-tabs).
// Picks up SSHADESS + Milestones carry-overs from sessionStorage
// (auto-set by Shadess.tsx and Milestones.tsx) so the user can
// flow byvisit → milestones → shadess → note without retyping.
// ============================================================
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
import Recorder from '@/components/Recorder';
const TYPE = 'wellvisit' as const;
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
export default function VisitNote() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [visitAge, setVisitAge] = useState('');
const [vitals, setVitals] = useState('');
const [measurements, setMeasurements] = useState('');
const [parentConcerns, setParentConcerns] = useState('');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [recError, setRecError] = useState<string | null>(null);
const [shadess, setShadess] = useState('');
const [milestones, setMilestones] = useState('');
const [screenings, setScreenings] = useState('');
const [vaccines, setVaccines] = useState('');
const [byvisit, setByvisit] = useState('');
const [noteStyle, setNoteStyle] = useState<'full' | 'short'>('full');
const [result, setResult] = useState<string | null>(null);
// On mount: pick up SSHADESS / Milestones / By-Visit carry-overs.
useEffect(() => {
try {
const s = sessionStorage.getItem('wv-shadess-assessment');
if (s) setShadess(s);
const m = sessionStorage.getItem('wv-milestones-narrative');
if (m) setMilestones(m);
const b = sessionStorage.getItem('wv-byvisit-statuses');
if (b) setByvisit(b);
const va = sessionStorage.getItem('ped_visit_age');
if (va) setVisitAge((cur) => cur || va);
} catch { /* ignore */ }
}, []);
const generate = useMutation<VisitNoteOk, Error, Record<string, unknown>>({
mutationFn: (body) => api.post<VisitNoteOk>('/api/well-visit/note', body),
onSuccess: (data) => setResult(data.note),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
generate.mutate({
patientAge, patientGender, visitAge,
vitals, measurements, parentConcerns,
transcript: (interim || transcript).trim(),
shadessAssessment: shadess || undefined,
// The byvisit summary is appended to screenings so the AI sees it.
screenings: [screenings, byvisit].filter(Boolean).join('\n\n'),
vaccines,
// Milestones get folded into transcript context as a developmental block.
physicianMemories: milestones ? '[DEVELOPMENTAL ASSESSMENT]\n' + milestones : undefined,
noteStyle,
});
}
const displayedTranscript = interim || transcript;
return (
<div className="space-y-4">
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, visitAge, vitals, measurements, parentConcerns, shadess, milestones, byvisit, screenings, vaccines, noteStyle }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.visitAge) setVisitAge(pd.visitAge);
if (pd?.vitals) setVitals(pd.vitals);
if (pd?.measurements) setMeasurements(pd.measurements);
if (pd?.parentConcerns) setParentConcerns(pd.parentConcerns);
if (pd?.shadess) setShadess(pd.shadess);
if (pd?.milestones) setMilestones(pd.milestones);
if (pd?.byvisit) setByvisit(pd.byvisit);
if (pd?.screenings) setScreenings(pd.screenings);
if (pd?.vaccines) setVaccines(pd.vaccines);
if (pd?.noteStyle) setNoteStyle(pd.noteStyle);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setTranscript(''); setInterim(''); setResult(null);
setPatientAge(''); setPatientGender(''); setVisitAge('');
setVitals(''); setMeasurements(''); setParentConcerns('');
setShadess(''); setMilestones(''); setByvisit('');
setScreenings(''); setVaccines(''); setNoteStyle('full');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option><option>Male</option><option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Visit age</span>
<input className={input} placeholder="e.g. 6 months" value={visitAge} onChange={(e) => setVisitAge(e.target.value)} />
</label>
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Vital signs</span>
<textarea className={input + ' min-h-[60px] font-mono text-xs'} value={vitals} onChange={(e) => setVitals(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Measurements / growth</span>
<textarea className={input + ' min-h-[60px] font-mono text-xs'} value={measurements} onChange={(e) => setMeasurements(e.target.value)} />
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Parent / patient concerns</span>
<textarea className={input + ' min-h-[60px] text-sm'} value={parentConcerns} onChange={(e) => setParentConcerns(e.target.value)} />
</label>
<Recorder
module="wellvisit"
onTranscript={(text, meta) => {
setTranscript((prev) => meta.appended ? (prev ? prev + ' ' + text : text) : text);
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(m) => setRecError(m)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript / dictation</span>
<textarea
className={input + ' min-h-[160px] font-mono text-sm'}
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
placeholder="Click Start recording, or type / paste."
/>
</label>
{(shadess || milestones || byvisit) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/30 p-3 space-y-2">
<div className="text-xs font-semibold text-amber-800 dark:text-amber-200">Carry-overs from other tabs (used in note generation)</div>
{milestones && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">Developmental assessment ({milestones.length} chars)</summary>
<textarea className={input + ' mt-1 min-h-[80px] font-mono text-xs'} value={milestones} onChange={(e) => setMilestones(e.target.value)} />
</details>
)}
{shadess && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">SSHADESS assessment ({shadess.length} chars)</summary>
<textarea className={input + ' mt-1 min-h-[80px] font-mono text-xs'} value={shadess} onChange={(e) => setShadess(e.target.value)} />
</details>
)}
{byvisit && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">By-visit-age statuses ({byvisit.split('\n').length} lines)</summary>
<textarea className={input + ' mt-1 min-h-[80px] font-mono text-xs'} value={byvisit} onChange={(e) => setByvisit(e.target.value)} />
</details>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Screenings completed</span>
<textarea className={input + ' min-h-[60px] text-xs'} value={screenings} onChange={(e) => setScreenings(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Immunizations today</span>
<textarea className={input + ' min-h-[60px] text-xs'} value={vaccines} onChange={(e) => setVaccines(e.target.value)} />
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Note style</span>
<select className={input} value={noteStyle} onChange={(e) => setNoteStyle(e.target.value as 'full' | 'short')}>
<option value="full">Full encounter note</option>
<option value="short">Brief SOAP</option>
</select>
</label>
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || (!patientAge.trim() && !visitAge.trim())}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate Well Visit Note'}
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Well Visit Note</h2>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="well-visit-note"
exportType="well-visit"
/>
</div>
</section>
)}
</div>
);
}