feat(client): port Hospital Course, Chart Review, Well Visit

Three more Notes tabs:

  /hospital   → HospitalCourse.tsx
    Textarea with blank-line-separated progress notes (one block =
    one note) + H&P + format selector (auto / prose / day-by-day /
    organ-system). Calls /api/generate-hospital-course.

  /chart      → ChartReview.tsx
    Type selector (outpatient / subspecialty / ED) with a dynamic
    array of visit entries — user can add/remove visits. Each
    visit has date / content / labs. Calls /api/generate-chart-review.

  /wellvisit  → WellVisit.tsx
    Vitals, measurements, parent concerns, transcript, screenings,
    immunizations, note style. Minimum-viable Visit Note port —
    the vanilla tab's milestone / SSHADESS / by-visit sub-panes
    become their own sub-routes in a follow-up. Calls /api/well-visit/note.

All three reuse the established pattern: form state → Zod (where a
schema exists in shared/schemas.ts) → useMutation → display pane
with copy-to-clipboard.

Every Notes group item is now marked available in the Layout sidebar.

Build: 377 kB / 110 kB gzipped (+16 kB over previous).
This commit is contained in:
Daniel 2026-04-23 22:21:28 +02:00
parent d976a5928d
commit 0f28f9212b
10 changed files with 507 additions and 56 deletions

View file

@ -7,6 +7,9 @@ import Dictation from '@/pages/Dictation';
import Encounter from '@/pages/Encounter';
import Soap from '@/pages/Soap';
import SickVisit from '@/pages/SickVisit';
import HospitalCourse from '@/pages/HospitalCourse';
import ChartReview from '@/pages/ChartReview';
import WellVisit from '@/pages/WellVisit';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
@ -44,6 +47,9 @@ export default function App() {
<Route path="/dictation" element={<Dictation />} />
<Route path="/soap" element={<Soap />} />
<Route path="/sickvisit" element={<SickVisit />} />
<Route path="/hospital" element={<HospitalCourse />} />
<Route path="/chart" element={<ChartReview />} />
<Route path="/wellvisit" element={<WellVisit />} />
<Route path="/extensions" element={<Extensions />} />
<Route path="/faq" element={<Faq />} />
{/* catch-all falls back to home while more tabs port over */}

View file

@ -29,10 +29,10 @@ const NAV: NavGroup[] = [
{
label: 'Notes',
items: [
{ to: '/hospital', label: 'Hospital Course', available: false },
{ to: '/chart', label: 'Chart Review', available: false },
{ to: '/hospital', label: 'Hospital Course', available: true },
{ to: '/chart', label: 'Chart Review', available: true },
{ to: '/soap', label: 'SOAP Note', available: true },
{ to: '/wellvisit', label: 'Well Visit', available: false },
{ to: '/wellvisit', label: 'Well Visit', available: true },
{ to: '/sickvisit', label: 'Sick Visit', available: true },
],
},

View file

@ -0,0 +1,163 @@
// ============================================================
// CHART REVIEW — /api/generate-chart-review
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { ChartReviewOk } from '@/shared/types';
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
interface VisitInput { date: string; content: string; labs: string }
function emptyVisit(): VisitInput { return { date: '', content: '', labs: '' }; }
export default function ChartReview() {
const [type, setType] = useState<ReviewType>('outpatient');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [pmh, setPmh] = useState('');
const [visits, setVisits] = useState<VisitInput[]>([emptyVisit()]);
const [additionalInstructions, setAdditionalInstructions] = useState('');
const [result, setResult] = useState<string | null>(null);
const generate = useMutation<ChartReviewOk, Error, any>({
mutationFn: (body) => api.post<ChartReviewOk>('/api/generate-chart-review', body),
onSuccess: (data) => setResult(data.review),
});
function updateVisit(i: number, patch: Partial<VisitInput>) {
setVisits((vs) => vs.map((v, idx) => (idx === i ? { ...v, ...patch } : v)));
}
function submit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
const filled = visits.filter((v) => v.content.trim());
generate.mutate({
type,
patientAge, patientGender, pmh,
visits: type === 'outpatient' ? filled : undefined,
subspecialty: type === 'subspecialty' ? filled : undefined,
edVisits: type === 'ed' ? filled : undefined,
additionalInstructions: additionalInstructions || undefined,
});
}
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">
<header>
<h1 className="text-2xl font-semibold">Chart Review</h1>
<p className="text-sm text-muted-foreground">
Past visits summary for pre-charting.
</p>
</header>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-4 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Review type</span>
<select className={input} value={type} onChange={(e) => setType(e.target.value as ReviewType)}>
<option value="outpatient">Outpatient</option>
<option value="subspecialty">Subspecialty</option>
<option value="ed">ED</option>
</select>
</label>
<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">PMH</span>
<input className={input} value={pmh} onChange={(e) => setPmh(e.target.value)} />
</label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Visits</span>
<button
type="button"
onClick={() => setVisits((v) => [...v, emptyVisit()])}
className="text-xs rounded-md border border-border px-2 py-1"
>
+ Add visit
</button>
</div>
{visits.map((v, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-2 bg-card">
<div className="flex items-center gap-2">
<input
type="date"
className={input + ' max-w-xs'}
value={v.date}
onChange={(e) => updateVisit(i, { date: e.target.value })}
/>
{visits.length > 1 && (
<button
type="button"
onClick={() => setVisits(vs => vs.filter((_, idx) => idx !== i))}
className="text-xs text-destructive"
>
Remove
</button>
)}
</div>
<textarea
className={input + ' min-h-[100px] font-mono text-sm'}
placeholder="Visit note content — paste here."
value={v.content}
onChange={(e) => updateVisit(i, { content: e.target.value })}
/>
<textarea
className={input + ' min-h-[60px] font-mono text-xs'}
placeholder="Labs from this visit (optional)"
value={v.labs}
onChange={(e) => updateVisit(i, { labs: e.target.value })}
/>
</div>
))}
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Additional instructions</span>
<textarea
className={input + ' min-h-[60px] text-sm'}
placeholder="e.g. 'Focus on thyroid management', 'Highlight medication changes'"
value={additionalInstructions}
onChange={(e) => setAdditionalInstructions(e.target.value)}
/>
</label>
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || !visits.some((v) => v.content.trim())}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate Chart Review'}
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<h2 className="text-sm font-semibold">Chart Review</h2>
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
</section>
)}
</div>
);
}

View file

@ -0,0 +1,149 @@
// ============================================================
// HOSPITAL COURSE — /api/generate-hospital-course
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { HospitalCourseOk } from '@/shared/types';
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
interface NoteEntry { date: string; type: string; content: string }
export default function HospitalCourse() {
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [pmh, setPmh] = useState('');
const [setting, setSetting] = useState<SettingKind>('floor');
const [los, setLos] = useState('');
const [format, setFormat] = useState<FormatKind>('auto');
const [hAndPContent, setHAndPContent] = useState('');
const [notesText, setNotesText] = useState('');
const [additionalInstructions, setAdditionalInstructions] = useState('');
const [result, setResult] = useState<{ hospitalCourse: string; format: string } | null>(null);
const generate = useMutation<HospitalCourseOk, Error, any>({
mutationFn: (body) => api.post<HospitalCourseOk>('/api/generate-hospital-course', body),
onSuccess: (data) => setResult({ hospitalCourse: data.hospitalCourse, format: data.format || 'auto' }),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
// Notes textarea: one blank-line-separated note per block. First
// line of each block is used as the date if it looks like one,
// rest becomes content.
const notes: NoteEntry[] = notesText
.split(/\n\s*\n/)
.map((block) => block.trim())
.filter(Boolean)
.map((block, i) => ({ date: `Day ${i + 1}`, type: 'Progress Note', content: block }));
generate.mutate({
notes,
hAndP: hAndPContent ? { date: 'Admission', content: hAndPContent } : undefined,
patientAge, patientGender, pmh, setting,
los: los ? parseInt(los) : undefined,
formatPreference: format,
additionalInstructions: additionalInstructions || undefined,
});
}
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">
<header>
<h1 className="text-2xl font-semibold">Hospital Course</h1>
<p className="text-sm text-muted-foreground">
Progress notes + H&amp;P hospital course summary (prose, day-by-day, or organ-system format).
</p>
</header>
<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">Setting</span>
<select className={input} value={setting} onChange={(e) => setSetting(e.target.value as SettingKind)}>
<option value="floor">Floor</option>
<option value="picu">PICU</option>
<option value="nicu">NICU</option>
<option value="psych">Psych</option>
</select>
</label>
</div>
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1 col-span-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">PMH</span>
<input className={input} placeholder="e.g. Asthma, hypothyroidism" value={pmh} onChange={(e) => setPmh(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">LOS (days)</span>
<input className={input} type="number" value={los} onChange={(e) => setLos(e.target.value)} />
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Format</span>
<select className={input} value={format} onChange={(e) => setFormat(e.target.value as FormatKind)}>
<option value="auto">Auto (infer from setting + LOS)</option>
<option value="prose">Prose summary</option>
<option value="dayByDay">Day-by-day</option>
<option value="organSystem">Organ-system (ICU)</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">H&amp;P</span>
<textarea className={input + ' min-h-[120px] font-mono text-sm'} value={hAndPContent} onChange={(e) => setHAndPContent(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Progress notes <span className="normal-case font-normal text-muted-foreground">(separate each note with a blank line)</span>
</span>
<textarea className={input + ' min-h-[200px] font-mono text-sm'} value={notesText} onChange={(e) => setNotesText(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Additional instructions</span>
<textarea className={input + ' min-h-[60px] text-sm'} value={additionalInstructions} onChange={(e) => setAdditionalInstructions(e.target.value)} />
</label>
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || !notesText.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate Hospital Course'}
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<h2 className="text-sm font-semibold">
Hospital Course <span className="text-xs font-normal text-muted-foreground">({result.format})</span>
</h2>
<button onClick={() => navigator.clipboard.writeText(result.hospitalCourse)} className="text-xs text-muted-foreground underline">Copy</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result.hospitalCourse}</div>
</section>
)}
</div>
);
}

View file

@ -0,0 +1,133 @@
// ============================================================
// 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).
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
export default function WellVisit() {
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 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,
});
}
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">
<header>
<h1 className="text-2xl font-semibold">Well Visit</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.
</p>
</header>
<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 flex items-center justify-between bg-muted/40">
<h2 className="text-sm font-semibold">Well Visit Note</h2>
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
</section>
)}
</div>
);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script type="module" crossorigin src="/app/assets/index-CJTSwzwK.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-Cge-0uqv.css">
<script type="module" crossorigin src="/app/assets/index-lT69opLd.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-DyJ0YyCY.css">
</head>
<body>
<div id="root"></div>