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.
This commit is contained in:
parent
8d815ba09e
commit
83f3ac70d5
8 changed files with 1648 additions and 157 deletions
|
|
@ -1,176 +1,83 @@
|
|||
// ============================================================
|
||||
// WELL VISIT — /api/well-visit/note (minimum-viable single-pane
|
||||
// port; the vanilla tab has 4 sub-panes for byvisit / milestones /
|
||||
// SSHADESS / note — each becomes its own sub-route or tab in a
|
||||
// follow-up commit).
|
||||
// WELL VISIT — sub-tab shell. Mirrors public/components/wellvisit.html:
|
||||
// • By Visit Age — AAP Bright Futures recs per visit
|
||||
// • Milestones — developmental checklist → AI narrative
|
||||
// • SSHADESS — psychosocial screening (12+ only)
|
||||
// • Visit Note — final preventive-care note generator
|
||||
//
|
||||
// SSHADESS pill is hidden for under-12 visits to match vanilla.
|
||||
// Each panel lazy-loads so the initial WellVisit bundle stays small.
|
||||
// ============================================================
|
||||
|
||||
import { 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 { Suspense, lazy, useState } from 'react';
|
||||
|
||||
const TYPE = 'wellvisit' as const;
|
||||
const ByVisitAge = lazy(() => import('./wellvisit/ByVisitAge'));
|
||||
const Milestones = lazy(() => import('./wellvisit/Milestones'));
|
||||
const Shadess = lazy(() => import('./wellvisit/Shadess'));
|
||||
const VisitNote = lazy(() => import('./wellvisit/VisitNote'));
|
||||
|
||||
type SubTab = 'byvisit' | 'milestones' | 'shadess' | 'note';
|
||||
|
||||
const TABS: { id: SubTab; icon: string; label: string }[] = [
|
||||
{ id: 'byvisit', icon: '👶', label: 'By Visit Age' },
|
||||
{ id: 'milestones', icon: '🍼', label: 'Milestones' },
|
||||
{ id: 'shadess', icon: '🧠', label: 'SSHADESS (12+)' },
|
||||
{ id: 'note', icon: '📄', label: 'Visit Note' },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'ped_wellvisit_subtab';
|
||||
|
||||
function loadSubTab(): SubTab {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v === 'byvisit' || v === 'milestones' || v === 'shadess' || v === 'note') return v;
|
||||
} catch { /* ignore */ }
|
||||
return 'byvisit';
|
||||
}
|
||||
|
||||
export default function WellVisit() {
|
||||
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 [screenings, setScreenings] = useState('');
|
||||
const [vaccines, setVaccines] = useState('');
|
||||
const [noteStyle, setNoteStyle] = useState<'full' | 'short'>('full');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [active, setActive] = useState<SubTab>(loadSubTab);
|
||||
|
||||
const generate = useMutation<VisitNoteOk, Error, any>({
|
||||
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, screenings, vaccines,
|
||||
noteStyle,
|
||||
});
|
||||
function pick(tab: SubTab) {
|
||||
setActive(tab);
|
||||
try { localStorage.setItem(STORAGE_KEY, tab); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Well Visit</h1>
|
||||
<h1 className="text-2xl font-semibold">Well Visit / Preventive Care</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Preventive-care note generation. Milestones and SSHADESS sub-tabs land in a follow-up; this first port covers the Visit Note pane.
|
||||
AAP 2025 Bright Futures periodicity — vaccines, screenings, billing codes, milestones, SSHADESS, and the encounter note.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={transcript} generatedNote={result || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, visitAge, vitals, measurements, parentConcerns, screenings, vaccines, noteStyle }}
|
||||
onLoad={(enc) => {
|
||||
setTranscript(enc.transcript || '');
|
||||
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?.screenings) setScreenings(pd.screenings);
|
||||
if (pd?.vaccines) setVaccines(pd.vaccines);
|
||||
if (pd?.noteStyle) setNoteStyle(pd.noteStyle);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => {
|
||||
setTranscript(''); setResult(null);
|
||||
setPatientAge(''); setPatientGender(''); setVisitAge('');
|
||||
setVitals(''); setMeasurements(''); setParentConcerns('');
|
||||
setScreenings(''); setVaccines(''); setNoteStyle('full');
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2" data-testid="wellvisit-subnav">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => pick(t.id)}
|
||||
className={
|
||||
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
|
||||
(active === t.id
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted hover:bg-muted/80 border-border')
|
||||
}
|
||||
data-testid={'wellvisit-pill-' + t.id}
|
||||
>
|
||||
<span className="mr-1" aria-hidden>{t.icon}</span>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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={transcript} onChange={(e) => setTranscript(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<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={transcript}
|
||||
exportLabel="well-visit-note"
|
||||
exportType="well-visit"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading…</div>}>
|
||||
{active === 'byvisit' && <ByVisitAge />}
|
||||
{active === 'milestones' && <Milestones />}
|
||||
{active === 'shadess' && <Shadess />}
|
||||
{active === 'note' && <VisitNote />}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
462
client/src/pages/wellvisit/ByVisitAge.tsx
Normal file
462
client/src/pages/wellvisit/ByVisitAge.tsx
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
// ============================================================
|
||||
// BY VISIT AGE — AAP Bright Futures recommendations per visit.
|
||||
// Faithful port of public/js/wellVisit.js (@be14578) renderVisitPanel.
|
||||
//
|
||||
// Reads /api/schedule-data and renders, per visit:
|
||||
// • Billing codes (ICD-10 + CPT)
|
||||
// • Measurements (height/weight/HC/BMI/BP)
|
||||
// • Vaccines due (with Given/Refused/Deferred/Already Done buttons)
|
||||
// • Screenings (sensory, developmental, procedures, oral)
|
||||
// • Expected growth + feeding guidance
|
||||
// • Expected reflexes
|
||||
// • BMI classification table (AAP 2023, ages 2+)
|
||||
// • Notes
|
||||
//
|
||||
// Visit statuses persist to localStorage under ped_visit_statuses
|
||||
// (same key as vanilla so cross-app continuity is preserved).
|
||||
// "Copy to Visit Note" writes a summary to sessionStorage so the
|
||||
// Visit Note tab can carry it into the encounter note.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
mapToGrowthKey,
|
||||
mapToReflexKey,
|
||||
reflexStatusColor,
|
||||
getStatusValue,
|
||||
type VisitStatusVal,
|
||||
} from '@shared/clinical/visit-status';
|
||||
|
||||
interface VisitAge { id: string; label: string; era: string }
|
||||
interface VaccineDose { vaccine: string; dose?: number | string; notes?: string }
|
||||
type ItemStatus = 'dot' | 'range' | 'arrow' | string;
|
||||
interface VisitData {
|
||||
measurements?: Record<string, ItemStatus>;
|
||||
vaccines?: VaccineDose[];
|
||||
sensory?: Record<string, ItemStatus>;
|
||||
developmental?: Record<string, ItemStatus>;
|
||||
procedures?: Record<string, ItemStatus>;
|
||||
oralHealth?: Record<string, ItemStatus>;
|
||||
notes?: string;
|
||||
}
|
||||
interface BillingCodes { icd10: string; cpt: string; description: string }
|
||||
interface GrowthRef {
|
||||
weight?: string; length?: string; headCirc?: string;
|
||||
feeding?: string[]; bmiClassification?: boolean;
|
||||
}
|
||||
interface ReflexEntry { name: string; status: string; note: string }
|
||||
interface ReflexRef { intro?: string; reflexes: ReflexEntry[] }
|
||||
interface BmiCategory { label: string; range: string; action: string; color: string }
|
||||
interface BmiClassification { categories: BmiCategory[]; notes?: string }
|
||||
interface ScheduleDataOk {
|
||||
visitAges: VisitAge[];
|
||||
periodicity: Record<string, VisitData>;
|
||||
wellVisitCodes: Record<string, BillingCodes>;
|
||||
growthReference: Record<string, GrowthRef>;
|
||||
reflexesReference: Record<string, ReflexRef>;
|
||||
bmiClassification: BmiClassification;
|
||||
vaccineFullNames: Record<string, string>;
|
||||
}
|
||||
|
||||
// Status persistence shape — keyed `<visitId>.<itemKey>`. Mirrors the
|
||||
// vanilla _visitStatuses state stored under localStorage["ped_visit_statuses"].
|
||||
type VisitStatuses = Record<string, VisitStatusVal>;
|
||||
|
||||
const SCREEN_LABELS: Record<string, string> = {
|
||||
maternalDepression: 'Maternal/Caregiver Depression Screen (Edinburgh/PHQ)',
|
||||
developmentalScreening: 'Developmental Screening (ASQ / PEDS)',
|
||||
autismScreening: 'Autism Screening (M-CHAT-R)',
|
||||
developmentalSurveillance: 'Developmental Surveillance',
|
||||
behavioralScreening: 'Social-Emotional/Behavioral Screening (ASQ:SE)',
|
||||
tobaccoAlcoholDrugs: 'Tobacco / Alcohol / Drug Use Screening (CRAFFT/AUDIT)',
|
||||
depressionSuicideRisk: 'Depression & Suicide Risk Screening (PHQ-A)',
|
||||
};
|
||||
const PROC_LABELS: Record<string, string> = {
|
||||
newbornBlood: 'Newborn Blood Spot Screening (NBS)',
|
||||
newbornBilirubin: 'Newborn Bilirubin (TcB or TSB)',
|
||||
criticalCHD: 'Critical CHD Screening (Pulse Ox)',
|
||||
immunization: 'Immunizations Review & Update',
|
||||
anemia: 'Anemia Screening (Hgb/Hct)',
|
||||
lead: 'Lead Exposure Risk / Blood Lead Level',
|
||||
tuberculosis: 'Tuberculosis / Latent TB Risk Assessment',
|
||||
dyslipidemia: 'Dyslipidemia Screening (lipid panel)',
|
||||
sti: 'STI Screening (gonorrhea / chlamydia / syphilis)',
|
||||
hiv: 'HIV Screening',
|
||||
hepB: 'Hepatitis B Screening (HBsAg)',
|
||||
hepC: 'Hepatitis C Screening (anti-HCV)',
|
||||
suddenCardiacArrest: 'Sudden Cardiac Arrest Risk Assessment',
|
||||
cervicalDysplasia: 'Cervical Dysplasia Screening (Pap smear)',
|
||||
};
|
||||
const MEASURE_LABELS: Record<string, string> = {
|
||||
lengthHeight: 'Length / Height', weight: 'Weight',
|
||||
headCircumference: 'Head Circumference', weightForLength: 'Weight-for-Length',
|
||||
bmi: 'BMI', bloodPressure: 'Blood Pressure',
|
||||
};
|
||||
const ORAL_LABELS: Record<string, string> = {
|
||||
assessment: 'Oral Health Risk Assessment',
|
||||
fluorideVarnish: 'Fluoride Varnish Application',
|
||||
fluorideSupplementation: 'Fluoride Supplementation (if water <0.6 ppm)',
|
||||
};
|
||||
const SENSORY_LABELS: Record<string, string> = {
|
||||
vision: 'Vision Screening', hearing: 'Hearing Screening',
|
||||
};
|
||||
|
||||
const ERA_NAMES: Record<string, string> = {
|
||||
prenatal: 'Prenatal',
|
||||
infancy: 'Infancy (0–12 mo)',
|
||||
earlyChildhood: 'Early Childhood (1–5 y)',
|
||||
middleChildhood: 'Middle Childhood (6–11 y)',
|
||||
adolescence: 'Adolescence (11–21 y)',
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'ped_visit_statuses';
|
||||
|
||||
const VAX_STATUSES = ['Given', 'Refused', 'Deferred', 'Already Done'] as const;
|
||||
const SCREEN_STATUSES = ['Done', 'Refused', 'Not Due / N/A'] as const;
|
||||
|
||||
// localStorage helpers — defensive against quota / blocked storage.
|
||||
function loadStatuses(): VisitStatuses {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as VisitStatuses) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
function saveStatuses(s: VisitStatuses) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Sub-components ─────────────────────────────────────────────────
|
||||
|
||||
function StatusBar(props: {
|
||||
statuses: readonly string[];
|
||||
current: string;
|
||||
onPick: (next: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{props.statuses.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => props.onPick(props.current === s ? '' : s)}
|
||||
className={
|
||||
'text-[10px] uppercase tracking-wider px-2 py-0.5 rounded border ' +
|
||||
(props.current === s
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background border-border hover:bg-muted')
|
||||
}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section(props: { icon: string; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className="px-4 py-2 bg-muted/40 flex items-center gap-2 text-sm font-semibold">
|
||||
<span>{props.icon}</span><span>{props.title}</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-2">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────
|
||||
|
||||
export default function ByVisitAge() {
|
||||
const { data, isLoading, error } = useQuery<ScheduleDataOk>({
|
||||
queryKey: ['schedule-data'],
|
||||
queryFn: () => api.get<ScheduleDataOk>('/api/schedule-data'),
|
||||
});
|
||||
const [visitId, setVisitId] = useState<string>('newborn');
|
||||
const [statuses, setStatuses] = useState<VisitStatuses>(() => loadStatuses());
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
// Persist on every change.
|
||||
useEffect(() => { saveStatuses(statuses); }, [statuses]);
|
||||
|
||||
// Persist selected visit age to sessionStorage so the Visit Note tab can
|
||||
// pick it up (matches vanilla `ped_visit_age` key).
|
||||
useEffect(() => {
|
||||
if (!data || !visitId) return;
|
||||
const label = data.visitAges.find((v) => v.id === visitId)?.label || visitId;
|
||||
try { sessionStorage.setItem('ped_visit_age', label); } catch { /* ignore */ }
|
||||
}, [data, visitId]);
|
||||
|
||||
const groupedAges = useMemo(() => {
|
||||
if (!data) return {} as Record<string, VisitAge[]>;
|
||||
const out: Record<string, VisitAge[]> = {};
|
||||
data.visitAges.forEach((v) => {
|
||||
if (!out[v.era]) out[v.era] = [];
|
||||
out[v.era].push(v);
|
||||
});
|
||||
return out;
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading schedule…</div>;
|
||||
if (error) return <div className="text-sm text-destructive">{(error as Error).message}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
const periodicity = data.periodicity[visitId];
|
||||
const codes = data.wellVisitCodes[visitId];
|
||||
const growthKey = mapToGrowthKey(visitId, data.growthReference);
|
||||
const growth = growthKey ? data.growthReference[growthKey] : null;
|
||||
const reflexKey = mapToReflexKey(visitId, data.reflexesReference);
|
||||
const reflex = reflexKey ? data.reflexesReference[reflexKey] : null;
|
||||
|
||||
function setStatus(key: string, status: string) {
|
||||
setStatuses((s) => {
|
||||
const cur = s[key];
|
||||
const note = typeof cur === 'object' && cur ? cur.note : '';
|
||||
return { ...s, [key]: { status, note } };
|
||||
});
|
||||
}
|
||||
function setNote(key: string, note: string) {
|
||||
setStatuses((s) => {
|
||||
const cur = s[key];
|
||||
const status = typeof cur === 'object' && cur ? cur.status : (cur || '');
|
||||
return { ...s, [key]: { status, note } };
|
||||
});
|
||||
}
|
||||
|
||||
function clearVisit() {
|
||||
setStatuses((s) => {
|
||||
const out = { ...s };
|
||||
Object.keys(out).forEach((k) => { if (k.indexOf(visitId + '.') === 0) delete out[k]; });
|
||||
return out;
|
||||
});
|
||||
setMsg('Visit statuses cleared');
|
||||
}
|
||||
function copyToNote() {
|
||||
if (!data) return;
|
||||
const lines: string[] = [];
|
||||
const visitLabel = data.visitAges.find((v) => v.id === visitId)?.label || visitId;
|
||||
lines.push('Visit: ' + visitLabel);
|
||||
Object.keys(statuses).forEach((k) => {
|
||||
if (k.indexOf(visitId + '.') !== 0) return;
|
||||
const v = statuses[k];
|
||||
const status = typeof v === 'object' ? v.status : v;
|
||||
const note = typeof v === 'object' ? v.note : '';
|
||||
if (!status) return;
|
||||
const itemKey = k.substring(visitId.length + 1);
|
||||
lines.push(' - ' + itemKey + ': ' + status + (note ? ' (' + note + ')' : ''));
|
||||
});
|
||||
try {
|
||||
sessionStorage.setItem('wv-byvisit-statuses', lines.join('\n'));
|
||||
setMsg('Copied to Visit Note — switch tabs to use it');
|
||||
} catch { setMsg('Session storage unavailable'); }
|
||||
}
|
||||
|
||||
// Filter helpers for the screening sections.
|
||||
function buildItems(map: Record<string, ItemStatus> | undefined, labels: Record<string, string>, exclude: string[] = []) {
|
||||
if (!map) return [] as { key: string; label: string; status: ItemStatus }[];
|
||||
return Object.keys(map)
|
||||
.filter((k) => !exclude.includes(k))
|
||||
.filter((k) => map[k] === 'dot' || map[k] === 'range' || map[k] === 'arrow')
|
||||
.map((k) => ({ key: k, label: labels[k] || k, status: map[k] }));
|
||||
}
|
||||
|
||||
const measDue = periodicity?.measurements
|
||||
? Object.keys(periodicity.measurements).filter((k) => periodicity.measurements![k] === 'dot' || periodicity.measurements![k] === 'range')
|
||||
: [];
|
||||
const sensoryItems = buildItems(periodicity?.sensory, SENSORY_LABELS);
|
||||
const devItems = buildItems(periodicity?.developmental, SCREEN_LABELS);
|
||||
const procItems = buildItems(periodicity?.procedures, PROC_LABELS, ['immunization']);
|
||||
const oralItems = buildItems(periodicity?.oralHealth, ORAL_LABELS);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Select visit age</span>
|
||||
<select
|
||||
className="rounded-md border border-input bg-background px-3 py-2 text-sm min-w-[260px]"
|
||||
value={visitId}
|
||||
onChange={(e) => setVisitId(e.target.value)}
|
||||
data-testid="byvisit-select"
|
||||
>
|
||||
{Object.keys(groupedAges).map((era) => (
|
||||
<optgroup key={era} label={ERA_NAMES[era] || era}>
|
||||
{groupedAges[era].map((v) => (
|
||||
<option key={v.id} value={v.id}>{v.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" onClick={copyToNote} className="rounded-md bg-primary text-primary-foreground px-3 py-2 text-xs font-semibold">
|
||||
📋 Copy to Visit Note
|
||||
</button>
|
||||
<button type="button" onClick={clearVisit} className="rounded-md border border-border bg-background px-3 py-2 text-xs text-destructive">
|
||||
↻ Clear this visit
|
||||
</button>
|
||||
{msg && <span className="text-xs text-muted-foreground">{msg}</span>}
|
||||
</div>
|
||||
|
||||
{!periodicity && <p className="text-sm text-muted-foreground">No data for this visit.</p>}
|
||||
|
||||
{periodicity && codes && (
|
||||
<Section icon="🧾" title="Billing codes">
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<span><span className="text-xs text-muted-foreground mr-1">ICD-10:</span><code className="bg-muted px-1.5 py-0.5 rounded">{codes.icd10}</code></span>
|
||||
<span><span className="text-xs text-muted-foreground mr-1">CPT:</span><code className="bg-muted px-1.5 py-0.5 rounded">{codes.cpt}</code></span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{codes.description}</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{measDue.length > 0 && (
|
||||
<Section icon="📏" title="Measurements">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{measDue.map((k) => (
|
||||
<span key={k} className="text-xs px-2 py-1 rounded bg-muted">
|
||||
{MEASURE_LABELS[k] || k}{periodicity!.measurements![k] === 'range' ? ' (range)' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{periodicity?.vaccines && periodicity.vaccines.length > 0 && (
|
||||
<Section icon="💉" title="Vaccines due">
|
||||
<div className="space-y-2">
|
||||
{periodicity.vaccines.map((v) => {
|
||||
const itemKey = 'vax_' + v.vaccine + '_d' + (v.dose || '');
|
||||
const k = visitId + '.' + itemKey;
|
||||
const cur = getStatusValue(statuses[k]);
|
||||
const fullName = data.vaccineFullNames[v.vaccine] || v.vaccine;
|
||||
return (
|
||||
<div key={itemKey} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="flex-1 min-w-[200px]">
|
||||
<strong>{fullName}</strong>
|
||||
{v.dose && <span className="ml-2 text-xs text-muted-foreground">Dose {v.dose}</span>}
|
||||
{v.notes && <div className="text-xs text-muted-foreground">{v.notes}</div>}
|
||||
</span>
|
||||
<StatusBar statuses={VAX_STATUSES} current={cur} onPick={(s) => setStatus(k, s)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{sensoryItems.length > 0 && (
|
||||
<ScreenList title="Sensory screens" icon="👁" items={sensoryItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
|
||||
)}
|
||||
{devItems.length > 0 && (
|
||||
<ScreenList title="Developmental / behavioral screens" icon="🧠" items={devItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
|
||||
)}
|
||||
{procItems.length > 0 && (
|
||||
<ScreenList title="Labs & procedures" icon="🧪" items={procItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
|
||||
)}
|
||||
{oralItems.length > 0 && (
|
||||
<ScreenList title="Oral health" icon="🦷" items={oralItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
|
||||
)}
|
||||
|
||||
{growth && (
|
||||
<Section icon="📈" title="Expected growth">
|
||||
<div className="space-y-1 text-sm">
|
||||
{growth.weight && <div><span className="text-muted-foreground">⚖️ Weight: </span>{growth.weight}</div>}
|
||||
{growth.length && <div><span className="text-muted-foreground">📏 Length/Height: </span>{growth.length}</div>}
|
||||
{growth.headCirc && <div><span className="text-muted-foreground">🧠 Head circumference: </span>{growth.headCirc}</div>}
|
||||
</div>
|
||||
{growth.feeding && growth.feeding.length > 0 && (
|
||||
<div className="pt-2 border-t border-border mt-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground mb-1">🍽 Feeding & nutrition</div>
|
||||
<ul className="list-disc pl-5 text-sm space-y-0.5">
|
||||
{growth.feeding.map((f, i) => <li key={i}>{f}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{reflex && reflex.reflexes.length > 0 && (
|
||||
<Section icon="✋" title="Expected reflexes">
|
||||
{reflex.intro && <div className="text-xs text-muted-foreground mb-2">{reflex.intro}</div>}
|
||||
<div className="space-y-2">
|
||||
{reflex.reflexes.map((r, i) => {
|
||||
const c = reflexStatusColor(r.status);
|
||||
return (
|
||||
<div key={i} className="text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold">{r.name}</span>
|
||||
<span
|
||||
className="text-[11px] font-semibold px-2 py-0.5 rounded-full border"
|
||||
style={{ backgroundColor: c + '1a', color: c, borderColor: c + '66' }}
|
||||
>{r.status}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{r.note}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{growth?.bmiClassification && data.bmiClassification?.categories && (
|
||||
<Section icon="⚖" title="BMI / weight classification (AAP 2023)">
|
||||
<div className="space-y-1">
|
||||
{data.bmiClassification.categories.map((c, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_auto_2fr] gap-2 text-sm border-l-4 pl-2 py-1" style={{ borderLeftColor: c.color }}>
|
||||
<strong>{c.label}</strong>
|
||||
<span className="text-muted-foreground">{c.range}</span>
|
||||
<span>{c.action}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.bmiClassification.notes && <div className="text-xs text-muted-foreground pt-1">ℹ︎ {data.bmiClassification.notes}</div>}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{periodicity?.notes && (
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 p-3 text-sm">
|
||||
ℹ︎ {periodicity.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenList(props: {
|
||||
icon: string;
|
||||
title: string;
|
||||
items: { key: string; label: string; status: string }[];
|
||||
visitId: string;
|
||||
statuses: VisitStatuses;
|
||||
setStatus: (k: string, s: string) => void;
|
||||
setNote: (k: string, n: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Section icon={props.icon} title={props.title}>
|
||||
<div className="space-y-2">
|
||||
{props.items.map((it) => {
|
||||
const k = props.visitId + '.' + it.key;
|
||||
const v = props.statuses[k];
|
||||
const cur = getStatusValue(v);
|
||||
const note = typeof v === 'object' && v ? v.note : '';
|
||||
return (
|
||||
<div key={it.key} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="flex-1 min-w-[200px]">{it.label}</span>
|
||||
<StatusBar statuses={SCREEN_STATUSES} current={cur} onPick={(s) => props.setStatus(k, s)} />
|
||||
{cur === 'Refused' || cur === 'Done' ? (
|
||||
<input
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => props.setNote(k, e.target.value)}
|
||||
placeholder="note (optional)"
|
||||
className="rounded-md border border-input bg-background px-2 py-1 text-xs flex-1 min-w-[160px]"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
328
client/src/pages/wellvisit/Milestones.tsx
Normal file
328
client/src/pages/wellvisit/Milestones.tsx
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
// ============================================================
|
||||
// MILESTONES — developmental checklist per age group → AI narrative.
|
||||
// Faithful port of public/js/milestones.js (@be14578).
|
||||
//
|
||||
// Flow:
|
||||
// 1. GET /api/milestones-data → { [ageGroup]: { [domain]: string[] } }
|
||||
// 2. User picks age group → renders checklist with ✓ / ✗ toggles
|
||||
// (third state = null = "not assessed, omit from narrative")
|
||||
// 3. Generate → POST /api/generate-milestone-narrative
|
||||
// 4. Optional 3-sentence summary → /api/generate-milestone-summary
|
||||
// 5. "Copy to Note" carries the narrative to the Visit Note tab
|
||||
// (sessionStorage bridge: wv-milestones-narrative)
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import OutputActions from '@/components/OutputActions';
|
||||
|
||||
type Status = 'yes' | 'no' | null;
|
||||
type MilestonesData = Record<string, Record<string, string[]>>;
|
||||
|
||||
interface MilestonesDataOk { success: true; milestones: MilestonesData }
|
||||
interface NarrativeOk {
|
||||
success: true;
|
||||
narrative: string;
|
||||
model: string;
|
||||
summary?: { achieved: number; notAchieved: number; notAssessed: number };
|
||||
}
|
||||
interface SummaryOk { success: true; summary: string; model: string }
|
||||
|
||||
interface ItemState {
|
||||
domain: string;
|
||||
milestone: string;
|
||||
status: Status;
|
||||
}
|
||||
|
||||
// Icon + tint per developmental domain (from the vanilla DOMAIN_CONFIG).
|
||||
const DOMAIN_CONFIG: Record<string, { icon: string; className: string }> = {
|
||||
'Gross Motor': { icon: '🏃', className: 'border-l-4 border-l-blue-500' },
|
||||
'Fine Motor': { icon: '✋', className: 'border-l-4 border-l-purple-500' },
|
||||
'Language': { icon: '💬', className: 'border-l-4 border-l-emerald-500' },
|
||||
'Social/Emotional':{ icon: '😊', className: 'border-l-4 border-l-amber-500' },
|
||||
'Cognitive': { icon: '🧠', className: 'border-l-4 border-l-pink-500' },
|
||||
'Self-Help': { icon: '🧒', className: 'border-l-4 border-l-indigo-500' },
|
||||
'Feeding': { icon: '🍼', className: 'border-l-4 border-l-cyan-500' },
|
||||
'Sleep': { icon: '💤', className: 'border-l-4 border-l-slate-500' },
|
||||
};
|
||||
const DEFAULT_DOMAIN = { icon: '📋', className: 'border-l-4 border-l-border' };
|
||||
|
||||
const AGE_GROUP_ORDER = [
|
||||
'Newborn / 1 month', '2 months', '4 months', '6 months', '9 months',
|
||||
'12 months', '15 months', '18 months', '24 months', '30 months',
|
||||
'36 months', '48 months', '60 months',
|
||||
'6 years', '7 years', '8 years', '9 years', '10 years', '11 years',
|
||||
];
|
||||
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
|
||||
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm font-semibold disabled:opacity-50';
|
||||
|
||||
export default function Milestones() {
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [ageGroup, setAgeGroup] = useState('');
|
||||
const [format, setFormat] = useState<'narrative' | 'list'>('narrative');
|
||||
const [state, setState] = useState<Record<string, ItemState>>({});
|
||||
const [narrative, setNarrative] = useState<string | null>(null);
|
||||
const [summaryStats, setSummaryStats] = useState<NarrativeOk['summary']>(undefined);
|
||||
const [quickSummary, setQuickSummary] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery<MilestonesDataOk>({
|
||||
queryKey: ['milestones-data'],
|
||||
queryFn: () => api.get<MilestonesDataOk>('/api/milestones-data'),
|
||||
});
|
||||
|
||||
// Keep the age-group dropdown in the canonical order even if server returns
|
||||
// them alphabetically. Anything not in AGE_GROUP_ORDER appears at the bottom.
|
||||
const ageGroupKeys = useMemo(() => {
|
||||
const available = data?.milestones ? Object.keys(data.milestones) : [];
|
||||
const ordered = AGE_GROUP_ORDER.filter((a) => available.includes(a));
|
||||
const extras = available.filter((a) => !AGE_GROUP_ORDER.includes(a));
|
||||
return [...ordered, ...extras];
|
||||
}, [data]);
|
||||
|
||||
// When ageGroup changes, (re)build the checklist state.
|
||||
useEffect(() => {
|
||||
if (!ageGroup || !data?.milestones?.[ageGroup]) { setState({}); return; }
|
||||
const newState: Record<string, ItemState> = {};
|
||||
const domains = data.milestones[ageGroup];
|
||||
Object.keys(domains).forEach((domain) => {
|
||||
domains[domain].forEach((m, idx) => {
|
||||
newState[domain + '-' + idx] = { domain, milestone: m, status: null };
|
||||
});
|
||||
});
|
||||
setState(newState);
|
||||
setNarrative(null); setSummaryStats(undefined); setQuickSummary(null);
|
||||
}, [ageGroup, data]);
|
||||
|
||||
const narrativeMut = useMutation<NarrativeOk, Error, void>({
|
||||
mutationFn: async () => {
|
||||
const body = {
|
||||
milestones: Object.values(state),
|
||||
ageGroup,
|
||||
patientAge,
|
||||
patientGender,
|
||||
format,
|
||||
};
|
||||
return api.post<NarrativeOk>('/api/generate-milestone-narrative', body);
|
||||
},
|
||||
onSuccess: (d) => {
|
||||
setNarrative(d.narrative);
|
||||
setSummaryStats(d.summary);
|
||||
setQuickSummary(null);
|
||||
setMsg({ kind: 'ok', text: 'Generated' });
|
||||
},
|
||||
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Failed' }),
|
||||
});
|
||||
|
||||
const summaryMut = useMutation<SummaryOk, Error, void>({
|
||||
mutationFn: async () => api.post<SummaryOk>('/api/generate-milestone-summary', {
|
||||
narrative, ageGroup, patientAge, patientGender,
|
||||
}),
|
||||
onSuccess: (d) => { setQuickSummary(d.summary); setMsg({ kind: 'ok', text: 'Summary generated' }); },
|
||||
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Failed' }),
|
||||
});
|
||||
|
||||
function toggle(id: string, action: 'yes' | 'no') {
|
||||
setState((s) => {
|
||||
const cur = s[id];
|
||||
if (!cur) return s;
|
||||
const next: Status = cur.status === action ? null : action;
|
||||
return { ...s, [id]: { ...cur, status: next } };
|
||||
});
|
||||
}
|
||||
function allYes() { setState((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, { ...v, status: 'yes' as Status }]))); }
|
||||
function allClear() {
|
||||
setState((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, { ...v, status: null as Status }])));
|
||||
setNarrative(null); setSummaryStats(undefined); setQuickSummary(null);
|
||||
}
|
||||
|
||||
function generate() {
|
||||
if (!ageGroup) { setMsg({ kind: 'err', text: 'Select age group' }); return; }
|
||||
const assessed = Object.values(state).filter((m) => m.status !== null);
|
||||
if (!assessed.length) { setMsg({ kind: 'err', text: 'Assess at least one milestone' }); return; }
|
||||
setMsg(null);
|
||||
narrativeMut.mutate();
|
||||
}
|
||||
|
||||
function copyToNote() {
|
||||
if (!narrative) return;
|
||||
try {
|
||||
sessionStorage.setItem('wv-milestones-narrative', narrative);
|
||||
setMsg({ kind: 'ok', text: 'Copied to Visit Note — switch to that tab' });
|
||||
} catch {
|
||||
setMsg({ kind: 'err', text: 'Session storage unavailable' });
|
||||
}
|
||||
}
|
||||
|
||||
// Group checklist items by domain for rendering.
|
||||
const grouped = useMemo(() => {
|
||||
const out: Record<string, { id: string; text: string; status: Status }[]> = {};
|
||||
Object.entries(state).forEach(([id, v]) => {
|
||||
if (!out[v.domain]) out[v.domain] = [];
|
||||
out[v.domain].push({ id, text: v.milestone, status: v.status });
|
||||
});
|
||||
return out;
|
||||
}, [state]);
|
||||
|
||||
const domainCounts = useMemo(() => {
|
||||
const out: Record<string, { total: number; yes: number; no: number }> = {};
|
||||
Object.values(state).forEach((v) => {
|
||||
const c = out[v.domain] || (out[v.domain] = { total: 0, yes: 0, no: 0 });
|
||||
c.total++;
|
||||
if (v.status === 'yes') c.yes++;
|
||||
if (v.status === 'no') c.no++;
|
||||
});
|
||||
return out;
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Patient age</span>
|
||||
<input className={input} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} placeholder="e.g. 9 months" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase 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 text-muted-foreground">Age group</span>
|
||||
<select className={input} value={ageGroup} onChange={(e) => setAgeGroup(e.target.value)} data-testid="ms-age-group">
|
||||
<option value="">-- Select --</option>
|
||||
{ageGroupKeys.map((k) => (<option key={k} value={k}>{k}</option>))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Output format</span>
|
||||
<select className={input} value={format} onChange={(e) => setFormat(e.target.value as 'narrative' | 'list')}>
|
||||
<option value="narrative">Narrative</option>
|
||||
<option value="list">Structured list</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span><span className="inline-block w-4 h-4 align-middle bg-green-100 text-green-700 text-center rounded mr-1">✓</span> Achieved</span>
|
||||
<span><span className="inline-block w-4 h-4 align-middle bg-red-100 text-red-700 text-center rounded mr-1">✗</span> Not achieved</span>
|
||||
<span><span className="inline-block w-4 h-4 align-middle bg-muted text-center rounded mr-1">—</span> Not assessed (omitted)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-sm text-muted-foreground">Loading milestones…</div>}
|
||||
|
||||
{ageGroup && Object.keys(grouped).length === 0 && !isLoading && (
|
||||
<div className="text-sm text-muted-foreground italic">No data for this age group.</div>
|
||||
)}
|
||||
|
||||
{Object.entries(grouped).map(([domain, items]) => {
|
||||
const cfg = DOMAIN_CONFIG[domain] || DEFAULT_DOMAIN;
|
||||
const c = domainCounts[domain];
|
||||
return (
|
||||
<div key={domain} className={'rounded-lg bg-card p-0 overflow-hidden ' + cfg.className}>
|
||||
<div className="px-4 py-2 flex items-center gap-2 bg-muted/40">
|
||||
<span>{cfg.icon}</span>
|
||||
<span className="font-semibold">{domain}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground" data-testid={'ms-badge-' + domain.replace(/\W/g, '_')}>
|
||||
{c && c.yes + c.no > 0 ? c.yes + '✓ ' + c.no + '✗ / ' + c.total : c.total + ' items'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className="flex items-center gap-3 px-4 py-2 text-sm">
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(it.id, 'yes')}
|
||||
className={'w-8 h-8 rounded border text-xs font-bold ' +
|
||||
(it.status === 'yes' ? 'bg-green-100 text-green-700 border-green-400' : 'bg-background border-border hover:bg-muted')
|
||||
}
|
||||
aria-label={'Achieved: ' + it.text}
|
||||
>✓</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(it.id, 'no')}
|
||||
className={'w-8 h-8 rounded border text-xs font-bold ' +
|
||||
(it.status === 'no' ? 'bg-red-100 text-red-700 border-red-400' : 'bg-background border-border hover:bg-muted')
|
||||
}
|
||||
aria-label={'Not achieved: ' + it.text}
|
||||
>✗</button>
|
||||
</div>
|
||||
<span className={
|
||||
it.status === 'yes' ? 'text-green-700' :
|
||||
it.status === 'no' ? 'text-red-700 line-through decoration-red-400' : ''
|
||||
}>{it.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{Object.keys(grouped).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={allYes} className={btn}>✓ All yes</button>
|
||||
<button type="button" onClick={allClear} className={btn}>🧹 Clear all</button>
|
||||
<button type="button" onClick={generate} disabled={narrativeMut.isPending} className={btnPrimary}>
|
||||
{narrativeMut.isPending ? 'Generating…' : '✨ Generate narrative'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<div className={'text-sm ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{narrative && (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<header className="px-4 py-2 border-b border-border bg-muted/40 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Developmental assessment</h3>
|
||||
<button type="button" onClick={copyToNote} className={btn}>📋 Copy to Note</button>
|
||||
</header>
|
||||
{summaryStats && (
|
||||
<div className="flex gap-4 text-xs px-4 py-2 border-b border-border bg-background text-muted-foreground">
|
||||
<span>✓ Achieved: {summaryStats.achieved}</span>
|
||||
<span>✗ Not yet: {summaryStats.notAchieved}</span>
|
||||
<span>— Not assessed: {summaryStats.notAssessed} (omitted)</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 whitespace-pre-wrap text-sm">{narrative}</div>
|
||||
<div className="px-4 pb-3">
|
||||
<OutputActions
|
||||
text={narrative}
|
||||
onUpdate={setNarrative}
|
||||
exportLabel="milestones"
|
||||
exportType="milestones"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pb-4 pt-2 border-t border-border space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => summaryMut.mutate()}
|
||||
disabled={summaryMut.isPending}
|
||||
className={btn}
|
||||
data-testid="ms-summary-btn"
|
||||
>
|
||||
{summaryMut.isPending ? 'Summarizing…' : (quickSummary ? '↻ Regenerate summary' : '📏 3-sentence summary')}
|
||||
</button>
|
||||
{quickSummary && (
|
||||
<div className="rounded-md bg-muted/40 p-3 whitespace-pre-wrap text-sm">
|
||||
{quickSummary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
363
client/src/pages/wellvisit/Shadess.tsx
Normal file
363
client/src/pages/wellvisit/Shadess.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
// ============================================================
|
||||
// SSHADESS — psychosocial screening (age 12+).
|
||||
// Faithful port of public/js/shadess.js (@be14578) — same 8 domains,
|
||||
// same questions, same concern_if flags, same skip toggle.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Clinician fills Yes/No + free-text + comments per domain
|
||||
// (or hits "Listen in" and dictates the whole thing)
|
||||
// 2. Generate → POST /api/well-visit/shadess with {patientAge,
|
||||
// patientGender, domains, dictationText}
|
||||
// 3. Result auto-fills sessionStorage so the Visit Note tab can
|
||||
// carry it into the well-visit note (matches the vanilla
|
||||
// wv-shadess-text auto-fill behaviour).
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import OutputActions from '@/components/OutputActions';
|
||||
|
||||
interface YnQ { id: string; text: string; type: 'yn'; concern_if?: boolean }
|
||||
interface TxtQ { id: string; text: string; type: 'text'; placeholder?: string }
|
||||
type Q = YnQ | TxtQ;
|
||||
|
||||
interface Domain {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string; // emoji stand-in
|
||||
color: string; // border tint
|
||||
intro: string;
|
||||
questions: Q[];
|
||||
}
|
||||
|
||||
// Verbatim from public/js/shadess.js — 8 domains, exact question wording.
|
||||
const SHADESS_DOMAINS: Domain[] = [
|
||||
{ key: 'strengths', label: 'Strengths', icon: '⭐', color: '#f59e0b',
|
||||
intro: 'Starting with what you do well helps us get to know you better.',
|
||||
questions: [
|
||||
{ id: 'str1', text: 'Has something they are proud of or enjoy', type: 'yn' },
|
||||
{ id: 'str2', text: 'Describes self positively when asked', type: 'yn' },
|
||||
{ id: 'str3', text: 'Has at least one trusted adult they can talk to', type: 'yn' },
|
||||
]
|
||||
},
|
||||
{ key: 'school', label: 'School', icon: '🎓', color: '#3b82f6',
|
||||
intro: 'Ask about school performance, attendance, and future plans.',
|
||||
questions: [
|
||||
{ id: 'sch1', text: 'Grades are satisfactory / doing their best', type: 'yn' },
|
||||
{ id: 'sch2', text: 'Likes school or finds something enjoyable about it', type: 'yn' },
|
||||
{ id: 'sch3', text: 'Regular attendance (no truancy concerns)', type: 'yn' },
|
||||
{ id: 'sch4', text: 'Has plans or goals for the future', type: 'yn' },
|
||||
]
|
||||
},
|
||||
{ key: 'home', label: 'Home', icon: '🏠', color: '#10b981',
|
||||
intro: 'Ask about living situation and family relationships.',
|
||||
questions: [
|
||||
{ id: 'hom1', text: 'Stable living situation', type: 'yn' },
|
||||
{ id: 'hom2', text: 'Gets along with people at home', type: 'yn' },
|
||||
{ id: 'hom3', text: 'Would talk to family member if stressed', type: 'yn' },
|
||||
{ id: 'hom4', text: 'Has experienced household violence or instability (concern if YES)', type: 'yn', concern_if: true },
|
||||
]
|
||||
},
|
||||
{ key: 'activities', label: 'Activities', icon: '👥', color: '#8b5cf6',
|
||||
intro: 'Ask about friends, hobbies, and peer relationships.',
|
||||
questions: [
|
||||
{ id: 'act1', text: 'Has friends and spends time with them', type: 'yn' },
|
||||
{ id: 'act2', text: 'Involved in sports, clubs, or hobbies', type: 'yn' },
|
||||
{ id: 'act3', text: 'Social media/screen use within healthy limits', type: 'yn' },
|
||||
{ id: 'act4', text: 'Has experienced bullying (concern if YES)', type: 'yn', concern_if: true },
|
||||
]
|
||||
},
|
||||
{ key: 'drugs', label: 'Drugs / Substances', icon: '💊', color: '#ef4444',
|
||||
intro: 'This is confidential. Ask in private.',
|
||||
questions: [
|
||||
{ id: 'drg1', text: 'Has tried cigarettes / vaping / tobacco', type: 'yn', concern_if: true },
|
||||
{ id: 'drg2', text: 'Has tried alcohol', type: 'yn', concern_if: true },
|
||||
{ id: 'drg3', text: 'Has tried marijuana or other drugs', type: 'yn', concern_if: true },
|
||||
{ id: 'drg4', text: 'Friends use substances', type: 'yn' },
|
||||
{ id: 'drg5', text: 'CRAFFT screen result (if done)', type: 'text', placeholder: 'e.g., Score 0 — low risk' },
|
||||
]
|
||||
},
|
||||
{ key: 'emotions', label: 'Emotions / Eating', icon: '❤️', color: '#ec4899',
|
||||
intro: 'Screen for depression, anxiety, and disordered eating.',
|
||||
questions: [
|
||||
{ id: 'emo1', text: 'Feeling down, sad, or hopeless recently', type: 'yn', concern_if: true },
|
||||
{ id: 'emo2', text: 'Feeling unusually stressed or anxious', type: 'yn', concern_if: true },
|
||||
{ id: 'emo3', text: 'Trouble sleeping', type: 'yn' },
|
||||
{ id: 'emo4', text: 'PHQ-A / depression screen result (if done)', type: 'text', placeholder: 'e.g., PHQ-A score 3 — minimal' },
|
||||
{ id: 'emo5', text: 'Happy with eating habits and body image', type: 'yn' },
|
||||
{ id: 'emo6', text: 'Restricting food / purging / using diet pills (concern if YES)', type: 'yn', concern_if: true },
|
||||
]
|
||||
},
|
||||
{ key: 'sexuality', label: 'Sexuality', icon: '🛡️', color: '#f97316',
|
||||
intro: 'Ask in private. Normalize the questions.',
|
||||
questions: [
|
||||
{ id: 'sex1', text: 'Comfortable discussing attraction/identity', type: 'yn' },
|
||||
{ id: 'sex2', text: 'Sexually active', type: 'yn' },
|
||||
{ id: 'sex3', text: 'Uses protection consistently if sexually active', type: 'yn' },
|
||||
{ id: 'sex4', text: 'History of unwanted sexual contact (concern if YES)', type: 'yn', concern_if: true },
|
||||
{ id: 'sex5', text: 'STI screening indicated/done', type: 'yn' },
|
||||
]
|
||||
},
|
||||
{ key: 'safety', label: 'Safety', icon: '🛡', color: '#6366f1',
|
||||
intro: 'Ask about violence, weapons, and suicidal ideation.',
|
||||
questions: [
|
||||
{ id: 'saf1', text: 'Feels safe at school and home', type: 'yn' },
|
||||
{ id: 'saf2', text: 'Carries a weapon (concern if YES)', type: 'yn', concern_if: true },
|
||||
{ id: 'saf3', text: 'Has been in physical fights recently', type: 'yn', concern_if: true },
|
||||
{ id: 'saf4', text: 'Wears seatbelt; safe driving practices', type: 'yn' },
|
||||
{ id: 'saf5', text: 'Thoughts of hurting self or suicide (concern if YES — STAT eval)', type: 'yn', concern_if: true },
|
||||
{ id: 'saf6', text: 'Columbia/ASQ suicide screen result (if done)', type: 'text', placeholder: 'e.g., ASQ: negative' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
interface DomainAnswers {
|
||||
questions: Record<string, string>; // qid -> 'yes'|'no'|free text
|
||||
comment: string;
|
||||
concern: boolean;
|
||||
skipped: boolean;
|
||||
}
|
||||
type AnswersMap = Record<string, DomainAnswers>;
|
||||
|
||||
interface ShadessOk { success: true; assessment: string; model: string }
|
||||
|
||||
const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
|
||||
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-semibold disabled:opacity-50';
|
||||
|
||||
function emptyAnswers(): AnswersMap {
|
||||
const out: AnswersMap = {};
|
||||
SHADESS_DOMAINS.forEach((d) => {
|
||||
out[d.key] = { questions: {}, comment: '', concern: false, skipped: false };
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function Shadess() {
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [answers, setAnswers] = useState<AnswersMap>(emptyAnswers);
|
||||
const [dictationText, setDictationText] = useState('');
|
||||
const [recError, setRecError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
const generate = useMutation<ShadessOk, Error, void>({
|
||||
mutationFn: async () => {
|
||||
const domains: Record<string, { skipped: boolean; comment: string; concern: boolean; questions: { id: string; text: string; answer: string }[] }> = {};
|
||||
SHADESS_DOMAINS.forEach((d) => {
|
||||
const a = answers[d.key];
|
||||
const qs = d.questions
|
||||
.map((q) => ({ id: q.id, text: q.text, answer: a.questions[q.id] || '' }))
|
||||
.filter((q) => q.answer !== '');
|
||||
domains[d.key] = { skipped: a.skipped, comment: a.comment, concern: a.concern, questions: qs };
|
||||
});
|
||||
const hasData = Object.values(domains).some((d) => !d.skipped && (d.questions.length > 0 || d.comment));
|
||||
if (!hasData && !dictationText.trim()) {
|
||||
throw new Error('Fill in at least one domain or dictate something');
|
||||
}
|
||||
return api.post<ShadessOk>('/api/well-visit/shadess', {
|
||||
patientAge, patientGender, domains, dictationText: dictationText.trim() || null,
|
||||
});
|
||||
},
|
||||
onSuccess: (d) => {
|
||||
setResult(d.assessment);
|
||||
setMsg({ kind: 'ok', text: 'Generated' });
|
||||
try { sessionStorage.setItem('wv-shadess-assessment', d.assessment); } catch { /* ignore */ }
|
||||
},
|
||||
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Generation failed' }),
|
||||
});
|
||||
|
||||
function setQ(domainKey: string, qid: string, value: string, concernIf?: boolean) {
|
||||
setAnswers((m) => {
|
||||
const cur = m[domainKey];
|
||||
const newQuestions = { ...cur.questions, [qid]: value };
|
||||
// Auto-flag concern when the answer matches the concern_if rule.
|
||||
let concern = cur.concern;
|
||||
if (concernIf !== undefined && (value === 'yes') === concernIf) {
|
||||
concern = true;
|
||||
}
|
||||
return { ...m, [domainKey]: { ...cur, questions: newQuestions, concern } };
|
||||
});
|
||||
}
|
||||
function setComment(domainKey: string, value: string) {
|
||||
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], comment: value } }));
|
||||
}
|
||||
function toggleSkip(domainKey: string) {
|
||||
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], skipped: !m[domainKey].skipped } }));
|
||||
}
|
||||
function toggleConcern(domainKey: string) {
|
||||
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], concern: !m[domainKey].concern } }));
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setAnswers(emptyAnswers());
|
||||
setDictationText('');
|
||||
setResult(null);
|
||||
setMsg(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Patient age</span>
|
||||
<input className={input + ' w-32'} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} placeholder="e.g. 14 years" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Gender</span>
|
||||
<select className={input + ' w-40'} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
|
||||
<option value="">Select</option>
|
||||
<option>Male</option>
|
||||
<option>Female</option>
|
||||
<option>Non-binary/Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
Recommended age 12 and older. Ask in private.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Listen in (optional dictation)</span>
|
||||
<Recorder
|
||||
module="shadess"
|
||||
onTranscript={(text, meta) => {
|
||||
setDictationText((prev) => meta.appended ? (prev ? prev + ' ' + text : text) : text);
|
||||
setRecError(null);
|
||||
}}
|
||||
onError={(m) => setRecError(m)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</div>}
|
||||
<textarea
|
||||
className={input + ' w-full mt-1 min-h-[60px] font-mono text-xs'}
|
||||
placeholder="Or type the dictation directly. Used as supplementary input alongside the structured answers below."
|
||||
value={dictationText}
|
||||
onChange={(e) => setDictationText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{SHADESS_DOMAINS.map((d) => {
|
||||
const a = answers[d.key];
|
||||
return (
|
||||
<div
|
||||
key={d.key}
|
||||
className="rounded-lg border border-border bg-card overflow-hidden"
|
||||
style={{ borderLeftWidth: 3, borderLeftColor: d.color }}
|
||||
data-testid={'shadess-domain-' + d.key}
|
||||
>
|
||||
<div className="px-4 py-2 flex items-center gap-2 bg-muted/30">
|
||||
<span style={{ color: d.color }}>{d.icon}</span>
|
||||
<strong className="text-sm">{d.label}</strong>
|
||||
<span className="text-xs text-muted-foreground flex-1 ml-2">{d.intro}</span>
|
||||
{a.concern && (
|
||||
<span className="text-xs text-amber-700 bg-amber-100 px-2 py-0.5 rounded">⚠ Concern</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleConcern(d.key)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
title="Toggle concern flag"
|
||||
>🚩</button>
|
||||
<label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<input type="checkbox" checked={a.skipped} onChange={() => toggleSkip(d.key)} /> Skip
|
||||
</label>
|
||||
</div>
|
||||
<div className={'px-4 py-2 space-y-2 ' + (a.skipped ? 'opacity-30' : '')}>
|
||||
{d.questions.map((q) => {
|
||||
if (q.type === 'yn') {
|
||||
return (
|
||||
<div key={q.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="flex-1">
|
||||
{q.text}
|
||||
{q.concern_if !== undefined && (
|
||||
<span className="text-[10px] text-muted-foreground ml-1">
|
||||
(flag if {q.concern_if ? 'Yes' : 'No'})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<select
|
||||
className={input + ' text-xs py-1 w-24'}
|
||||
value={a.questions[q.id] || ''}
|
||||
onChange={(e) => setQ(d.key, q.id, e.target.value, q.concern_if)}
|
||||
disabled={a.skipped}
|
||||
data-testid={'shadess-q-' + q.id}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={q.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="flex-1">{q.text}</span>
|
||||
<input
|
||||
type="text"
|
||||
className={input + ' text-xs py-1 flex-1 max-w-[260px]'}
|
||||
placeholder={q.placeholder}
|
||||
value={a.questions[q.id] || ''}
|
||||
onChange={(e) => setQ(d.key, q.id, e.target.value)}
|
||||
disabled={a.skipped}
|
||||
data-testid={'shadess-q-' + q.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="flex-1 pt-1">Additional notes:</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
className={input + ' text-xs flex-1 min-w-[200px] resize-y'}
|
||||
placeholder="Free text comments for this domain…"
|
||||
value={a.comment}
|
||||
onChange={(e) => setComment(d.key, e.target.value)}
|
||||
disabled={a.skipped}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => generate.mutate()} disabled={generate.isPending} className={btnPrimary} data-testid="shadess-generate">
|
||||
{generate.isPending ? 'Generating…' : '✨ Generate SSHADESS Assessment'}
|
||||
</button>
|
||||
<button type="button" onClick={clearAll} className={btn}>↺ New patient</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={'text-sm ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<header className="px-4 py-2 border-b border-border bg-muted/40">
|
||||
<h3 className="text-sm font-semibold">SSHADESS Assessment</h3>
|
||||
</header>
|
||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
||||
<div className="px-4 pb-4">
|
||||
<OutputActions
|
||||
text={result}
|
||||
onUpdate={setResult}
|
||||
sourceContext={dictationText}
|
||||
exportLabel="sshadess"
|
||||
exportType="sshadess"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pb-3 text-xs text-muted-foreground">
|
||||
ℹ︎ Auto-saved to session for the Visit Note tab — switch tabs to incorporate.
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
client/src/pages/wellvisit/VisitNote.tsx
Normal file
242
client/src/pages/wellvisit/VisitNote.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// ============================================================
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
105
shared/clinical/visit-status.test.ts
Normal file
105
shared/clinical/visit-status.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Tests for shared/clinical/visit-status.ts. The mappings below mirror
|
||||
// the dictionaries baked into public/js/wellVisit.js (@be14578) — if the
|
||||
// vanilla schedule changes, both files update in the same commit.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
mapToGrowthKey,
|
||||
mapToReflexKey,
|
||||
reflexStatusColor,
|
||||
getStatusValue,
|
||||
isSshadessVisit,
|
||||
SSHADESS_VISITS,
|
||||
} from './visit-status';
|
||||
|
||||
const G_KEYS = {
|
||||
'newborn': {}, '1mo': {}, '2mo': {}, '4mo': {}, '6mo': {}, '9mo': {},
|
||||
'12mo': {}, '15mo': {}, '18mo': {}, '24mo': {}, '30mo': {},
|
||||
'3y': {}, '4y': {}, '5y': {},
|
||||
'6y_to_10y': {}, '11y_to_14y': {}, '15y_to_21y': {},
|
||||
};
|
||||
const R_KEYS = {
|
||||
'newborn': {}, '1mo': {}, '2mo': {}, '4mo': {}, '6mo': {}, '9mo': {},
|
||||
'12mo': {}, '15mo': {}, '18mo': {}, '24mo': {}, '30mo': {},
|
||||
'3y_to_5y': {}, '6y_to_10y': {}, '11y_to_14y': {}, '15y_to_21y': {},
|
||||
};
|
||||
|
||||
describe('mapToGrowthKey', () => {
|
||||
it('returns the visitId itself when it is a direct key', () => {
|
||||
expect(mapToGrowthKey('newborn', G_KEYS)).toBe('newborn');
|
||||
expect(mapToGrowthKey('6mo', G_KEYS)).toBe('6mo');
|
||||
expect(mapToGrowthKey('3y', G_KEYS)).toBe('3y');
|
||||
});
|
||||
it('aliases 3-5d to newborn (vanilla parity)', () => {
|
||||
expect(mapToGrowthKey('3-5d', G_KEYS)).toBe('newborn');
|
||||
});
|
||||
it('collapses 6y..10y onto a single growth window', () => {
|
||||
for (const v of ['6y','7y','8y','9y','10y']) {
|
||||
expect(mapToGrowthKey(v, G_KEYS)).toBe('6y_to_10y');
|
||||
}
|
||||
});
|
||||
it('collapses adolescence into 11y_to_14y / 15y_to_21y', () => {
|
||||
for (const v of ['11y','12y','13y','14y']) {
|
||||
expect(mapToGrowthKey(v, G_KEYS)).toBe('11y_to_14y');
|
||||
}
|
||||
for (const v of ['15y','16y','17y','18y','19y','20y','21y']) {
|
||||
expect(mapToGrowthKey(v, G_KEYS)).toBe('15y_to_21y');
|
||||
}
|
||||
});
|
||||
it('returns null when nothing matches', () => {
|
||||
expect(mapToGrowthKey('totally-bogus', G_KEYS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapToReflexKey', () => {
|
||||
it('groups 3y/4y/5y onto 3y_to_5y (different from growth)', () => {
|
||||
for (const v of ['3y','4y','5y']) {
|
||||
expect(mapToReflexKey(v, R_KEYS)).toBe('3y_to_5y');
|
||||
}
|
||||
});
|
||||
it('keeps the same 11y_to_14y / 15y_to_21y windows as growth', () => {
|
||||
expect(mapToReflexKey('13y', R_KEYS)).toBe('11y_to_14y');
|
||||
expect(mapToReflexKey('18y', R_KEYS)).toBe('15y_to_21y');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reflexStatusColor', () => {
|
||||
it('greens out resolved / integrated reflexes', () => {
|
||||
expect(reflexStatusColor('Absent / integrated')).toBe('#059669');
|
||||
expect(reflexStatusColor('Present (lifelong)')).toBe('#059669');
|
||||
expect(reflexStatusColor('Down-going')).toBe('#059669');
|
||||
});
|
||||
it('blues out present-and-expected', () => {
|
||||
expect(reflexStatusColor('Present')).toBe('#2563eb');
|
||||
expect(reflexStatusColor('Peak')).toBe('#2563eb');
|
||||
expect(reflexStatusColor('2+ symmetric')).toBe('#2563eb');
|
||||
});
|
||||
it('purples emerging, ambers transitional', () => {
|
||||
expect(reflexStatusColor('Emerging')).toBe('#7c3aed');
|
||||
expect(reflexStatusColor('Fading')).toBe('#d97706');
|
||||
expect(reflexStatusColor('In transition')).toBe('#d97706');
|
||||
expect(reflexStatusColor('Up-going')).toBe('#d97706');
|
||||
});
|
||||
it('falls back to slate for unknowns', () => {
|
||||
expect(reflexStatusColor('whatever')).toBe('#475569');
|
||||
expect(reflexStatusColor('')).toBe('#475569');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusValue', () => {
|
||||
it('handles undefined, bare strings, and object form', () => {
|
||||
expect(getStatusValue(undefined)).toBe('');
|
||||
expect(getStatusValue('Given')).toBe('Given');
|
||||
expect(getStatusValue({ status: 'Refused', note: '' })).toBe('Refused');
|
||||
expect(getStatusValue({ status: '', note: 'context' })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSshadessVisit', () => {
|
||||
it('matches all 12+ visits exactly', () => {
|
||||
for (const v of SSHADESS_VISITS) expect(isSshadessVisit(v)).toBe(true);
|
||||
});
|
||||
it('rejects under-12 visits', () => {
|
||||
for (const v of ['newborn','6mo','11y','5y']) expect(isSshadessVisit(v)).toBe(false);
|
||||
});
|
||||
});
|
||||
80
shared/clinical/visit-status.ts
Normal file
80
shared/clinical/visit-status.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// ============================================================
|
||||
// Visit-status helpers — pure functions used by the WellVisit
|
||||
// "By Visit Age" pane to map AAP visit IDs onto growth/reflex
|
||||
// reference keys, color-code reflex chips, and read tri-state
|
||||
// status values out of the localStorage cache.
|
||||
//
|
||||
// Mirrors public/js/wellVisit.js (@be14578) getGrowthDataForVisit /
|
||||
// getReflexDataForVisit / reflexStatusColor. Lives in shared/ so
|
||||
// the root vitest config can pick the tests up.
|
||||
// ============================================================
|
||||
|
||||
export type VisitStatusVal = string | { status: string; note: string };
|
||||
|
||||
// Map a visit ID (e.g. "7y") to its GROWTH_REFERENCE key (e.g. "6y_to_10y").
|
||||
// Returns null when no match — caller should hide the growth section.
|
||||
export function mapToGrowthKey<T>(visitId: string, refs: Record<string, T>): string | null {
|
||||
if (refs[visitId]) return visitId;
|
||||
const m: Record<string, string> = {
|
||||
'newborn': 'newborn', '3-5d': 'newborn',
|
||||
'1mo': '1mo', '2mo': '2mo', '4mo': '4mo', '6mo': '6mo', '9mo': '9mo',
|
||||
'12mo': '12mo', '15mo': '15mo', '18mo': '18mo',
|
||||
'24mo': '24mo', '30mo': '30mo',
|
||||
'3y': '3y', '4y': '4y', '5y': '5y',
|
||||
'6y': '6y_to_10y', '7y': '6y_to_10y', '8y': '6y_to_10y', '9y': '6y_to_10y', '10y': '6y_to_10y',
|
||||
'11y': '11y_to_14y', '12y': '11y_to_14y', '13y': '11y_to_14y', '14y': '11y_to_14y',
|
||||
'15y': '15y_to_21y', '16y': '15y_to_21y', '17y': '15y_to_21y', '18y': '15y_to_21y',
|
||||
'19y': '15y_to_21y', '20y': '15y_to_21y', '21y': '15y_to_21y',
|
||||
};
|
||||
const k = m[visitId];
|
||||
return k && refs[k] ? k : null;
|
||||
}
|
||||
|
||||
// REFLEXES_REFERENCE shares 3y..5y, not split by year — see vanilla.
|
||||
export function mapToReflexKey<T>(visitId: string, refs: Record<string, T>): string | null {
|
||||
if (refs[visitId]) return visitId;
|
||||
const m: Record<string, string> = {
|
||||
'newborn': 'newborn', '3-5d': 'newborn',
|
||||
'1mo': '1mo', '2mo': '2mo', '4mo': '4mo', '6mo': '6mo', '9mo': '9mo',
|
||||
'12mo': '12mo', '15mo': '15mo', '18mo': '18mo',
|
||||
'24mo': '24mo', '30mo': '30mo',
|
||||
'3y': '3y_to_5y', '4y': '3y_to_5y', '5y': '3y_to_5y',
|
||||
'6y': '6y_to_10y', '7y': '6y_to_10y', '8y': '6y_to_10y', '9y': '6y_to_10y', '10y': '6y_to_10y',
|
||||
'11y': '11y_to_14y', '12y': '11y_to_14y', '13y': '11y_to_14y', '14y': '11y_to_14y',
|
||||
'15y': '15y_to_21y', '16y': '15y_to_21y', '17y': '15y_to_21y', '18y': '15y_to_21y',
|
||||
'19y': '15y_to_21y', '20y': '15y_to_21y', '21y': '15y_to_21y',
|
||||
};
|
||||
const k = m[visitId];
|
||||
return k && refs[k] ? k : null;
|
||||
}
|
||||
|
||||
// Color a reflex status chip. Greens = expected resolved/lifelong state,
|
||||
// blues = present and expected, purple = emerging, ambers = transitional,
|
||||
// slate = unspecified. Mirrors reflexStatusColor() exactly.
|
||||
export function reflexStatusColor(status: string): string {
|
||||
const s = (status || '').toLowerCase();
|
||||
if (s.includes('absent / integrated') || s === 'absent' || s.includes('integrated')) return '#059669';
|
||||
if (s.includes('present') && s.includes('lifelong')) return '#059669';
|
||||
if (s.includes('present')) return '#2563eb';
|
||||
if (s.includes('peak')) return '#2563eb';
|
||||
if (s.includes('emerging')) return '#7c3aed';
|
||||
if (s.includes('fading') || s.includes('transition')) return '#d97706';
|
||||
if (s.includes('down-going')) return '#059669';
|
||||
if (s.includes('up-going')) return '#d97706';
|
||||
if (s.includes('2+')) return '#2563eb';
|
||||
return '#475569';
|
||||
}
|
||||
|
||||
// A visit-status value can be a bare string (legacy) or { status, note }.
|
||||
export function getStatusValue(s: VisitStatusVal | undefined): string {
|
||||
if (!s) return '';
|
||||
return typeof s === 'object' ? s.status : s;
|
||||
}
|
||||
|
||||
// SSHADESS sub-tab visibility — vanilla shows it only for these visits.
|
||||
export const SSHADESS_VISITS: ReadonlyArray<string> = [
|
||||
'12y','13y','14y','15y','16y','17y','18y','19y','20y','21y',
|
||||
];
|
||||
export function isSshadessVisit(visitId: string): boolean {
|
||||
return SSHADESS_VISITS.includes(visitId);
|
||||
}
|
||||
|
|
@ -54,6 +54,10 @@ router.get('/schedule-data', authMiddleware, function (_req: Request, res: Respo
|
|||
visitAges: scheduleData.VISIT_AGES || [],
|
||||
periodicity: scheduleData.PERIODICITY || {},
|
||||
catchUpSchedule: scheduleData.CATCH_UP_SCHEDULE || {},
|
||||
wellVisitCodes: scheduleData.WELL_VISIT_CODES || {},
|
||||
growthReference: scheduleData.GROWTH_REFERENCE || {},
|
||||
reflexesReference: scheduleData.REFLEXES_REFERENCE || {},
|
||||
bmiClassification: scheduleData.BMI_CLASSIFICATION || {},
|
||||
vaccineFullNames: VACCINE_FULL_NAMES,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue