feat(notes): structured HospitalCourse + ChartReview match vanilla

Both pages were partial ports — HospitalCourse had a single "notes
textarea separated by blank lines" instead of the dynamic note cards
vanilla shipped, and ChartReview lacked per-visit type / specialist
/ specialty fields plus the Additional Labs block. These commits
close the gap.

HospitalCourse (ports public/components/hospital.html +
                 public/js/hospitalCourse.js @be14578):

  client/src/pages/hospital/DictatableCard.tsx — reusable card
    (title + date + meta children + content + per-card Recorder).
    Each note gets its own recorder so dictating into one card
    doesn't interrupt another in progress.
  client/src/pages/hospital/LabsList.tsx — dynamic (date, values)
    rows with add/remove.
  client/src/pages/hospital/ClarifyButton.tsx — "What's Missing?"
    → POST /api/hospital-course-clarify, renders the returned
    questions inline.
  client/src/pages/HospitalCourse.tsx — rewritten: ED Note card
    (date + ED labs + content + dictate), H&P card (date + content
    + dictate), Progress Notes as dynamic cards (date + type
    select matching vanilla's 6 options + content + per-card
    dictate + remove), separate Labs list, instructions,
    EditableResult output, ClarifyButton. Save/Load round-trips the
    entire structured note-set via JSON in the transcript column.

ChartReview (ports public/components/chart.html +
                   public/js/chartReview.js @be14578):

  client/src/pages/ChartReview.tsx — rewritten: each visit now has
    its own date + visit-type select (outpatient/subspecialty/ed),
    and when subspecialty is selected the specialist-name +
    specialty fields appear inline. Per-visit labs textarea.
    New Additional Labs block (reuses hospital/LabsList) for labs
    not tied to a visit. Submit splits visits by type into the
    server's visits / subspecialty / edVisits arrays, matching
    src/routes/chartReview.ts.
This commit is contained in:
Daniel 2026-04-24 05:38:13 +02:00
parent d1f45b3dcb
commit bdd1b6ec3e
5 changed files with 481 additions and 114 deletions

View file

@ -1,5 +1,13 @@
// ============================================================ // ============================================================
// CHART REVIEW — /api/generate-chart-review // CHART REVIEW — faithful port of public/components/chart.html +
// public/js/chartReview.js (@be14578):
// • Demographics bar (age, gender, PMH, review type)
// • Save/Load encounter toolbar
// • Dynamic Visit cards — each with date + type + optional
// specialist/specialty (subspecialty) + content + per-visit labs
// • Additional Labs block — dynamic (date, values) list
// • Instructions textarea
// • Refine / Shorter / Read / Nextcloud export / editable body
// ============================================================ // ============================================================
import { useState } from 'react'; import { useState } from 'react';
@ -8,30 +16,49 @@ import { api, ApiError } from '@/lib/api';
import type { ChartReviewOk } from '@/shared/types'; import type { ChartReviewOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar'; import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult'; import EditableResult from '@/components/EditableResult';
import LabsList, { type LabRow, emptyLab } from './hospital/LabsList';
type ReviewType = 'outpatient' | 'subspecialty' | 'ed'; type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
type VisitType = 'outpatient' | 'subspecialty' | 'ed';
const TYPE = 'chart' as const; const TYPE = 'chart' as const;
interface VisitInput { date: string; content: string; labs: string } interface Visit {
date: string;
visitType: VisitType;
content: string;
labs: string;
specialistName?: string;
specialty?: string;
}
function emptyVisit(): Visit {
return { date: '', visitType: 'outpatient', content: '', labs: '' };
}
function emptyVisit(): VisitInput { return { date: '', content: '', labs: '' }; } const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const inputSm = 'rounded-md border border-input bg-background px-2 py-1 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';
// Per-visit payload pieces for the server. Matches src/routes/chartReview.ts.
interface OutVisitPayload { date: string; content: string; labs: string }
interface SubspecialtyPayload extends OutVisitPayload { specialistName?: string; specialty?: string }
export default function ChartReview() { export default function ChartReview() {
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [type, setType] = useState<ReviewType>('outpatient'); const [reviewType, setReviewType] = useState<ReviewType>('outpatient');
const [patientAge, setPatientAge] = useState(''); const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState(''); const [patientGender, setPatientGender] = useState('');
const [pmh, setPmh] = useState(''); const [pmh, setPmh] = useState('');
const [visits, setVisits] = useState<VisitInput[]>([emptyVisit()]); const [visits, setVisits] = useState<Visit[]>([emptyVisit()]);
const [extraLabs, setExtraLabs] = useState<LabRow[]>([emptyLab()]);
const [additionalInstructions, setAdditionalInstructions] = useState(''); const [additionalInstructions, setAdditionalInstructions] = useState('');
const [result, setResult] = useState<string | null>(null); const [result, setResult] = useState<string | null>(null);
const generate = useMutation<ChartReviewOk, Error, any>({ const generate = useMutation<ChartReviewOk, Error, Record<string, unknown>>({
mutationFn: (body) => api.post<ChartReviewOk>('/api/generate-chart-review', body), mutationFn: (body) => api.post<ChartReviewOk>('/api/generate-chart-review', body),
onSuccess: (data) => setResult(data.review), onSuccess: (data) => setResult(data.review),
}); });
function updateVisit(i: number, patch: Partial<VisitInput>) { function updateVisit(i: number, patch: Partial<Visit>) {
setVisits((vs) => vs.map((v, idx) => (idx === i ? { ...v, ...patch } : v))); setVisits((vs) => vs.map((v, idx) => (idx === i ? { ...v, ...patch } : v)));
} }
@ -39,41 +66,52 @@ export default function ChartReview() {
e.preventDefault(); e.preventDefault();
setResult(null); setResult(null);
const filled = visits.filter((v) => v.content.trim()); const filled = visits.filter((v) => v.content.trim());
const toOut = (v: Visit): OutVisitPayload => ({ date: v.date, content: v.content, labs: v.labs });
const toSub = (v: Visit): SubspecialtyPayload => ({
date: v.date, content: v.content, labs: v.labs,
specialistName: v.specialistName, specialty: v.specialty,
});
generate.mutate({ generate.mutate({
type, type: reviewType,
patientAge, patientGender, pmh, patientAge, patientGender, pmh,
visits: type === 'outpatient' ? filled : undefined, visits: filled.filter((v) => v.visitType === 'outpatient').map(toOut),
subspecialty: type === 'subspecialty' ? filled : undefined, subspecialty: filled.filter((v) => v.visitType === 'subspecialty').map(toSub),
edVisits: type === 'ed' ? filled : undefined, edVisits: filled.filter((v) => v.visitType === 'ed').map(toOut),
labs: extraLabs.filter((l) => l.values.trim()),
additionalInstructions: additionalInstructions || undefined, additionalInstructions: additionalInstructions || undefined,
}); });
} }
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
return ( return (
<div className="max-w-4xl mx-auto p-6 space-y-4"> <div className="max-w-4xl mx-auto p-6 space-y-4">
<header> <header>
<h1 className="text-2xl font-semibold">Chart Review</h1> <h1 className="text-2xl font-semibold">Chart Review</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Past visits summary for pre-charting. Past visits + labs summary for pre-charting. Mix of outpatient, subspecialty, and ED visits supported.
</p> </p>
</header> </header>
<EncounterToolbar <EncounterToolbar
type={TYPE} type={TYPE}
label={label} setLabel={setLabel} label={label} setLabel={setLabel}
transcript={JSON.stringify(visits)} generatedNote={result || ''} transcript={JSON.stringify({ visits, extraLabs })} generatedNote={result || ''}
partialData={{ type, age: patientAge, gender: patientGender, pmh, additionalInstructions }} partialData={{ reviewType, age: patientAge, gender: patientGender, pmh, additionalInstructions }}
onLoad={(enc) => { onLoad={(enc) => {
try { try {
const parsedVisits = enc.transcript ? JSON.parse(enc.transcript) as VisitInput[] : [emptyVisit()]; const parsed = enc.transcript ? JSON.parse(enc.transcript) : null;
setVisits(parsedVisits.length ? parsedVisits : [emptyVisit()]); if (Array.isArray(parsed?.visits) && parsed.visits.length) setVisits(parsed.visits);
} catch { setVisits([emptyVisit()]); } if (Array.isArray(parsed?.extraLabs) && parsed.extraLabs.length) setExtraLabs(parsed.extraLabs);
} catch {
// Legacy saved encounters used a bare array — fall back to that shape.
try {
const legacy = enc.transcript ? JSON.parse(enc.transcript) as Visit[] : null;
if (Array.isArray(legacy)) setVisits(legacy);
} catch { /* ignore */ }
}
setResult(enc.generated_note || null); setResult(enc.generated_note || null);
try { try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null; const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.type) setType(pd.type); if (pd?.reviewType) setReviewType(pd.reviewType);
if (pd?.age) setPatientAge(pd.age); if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender); if (pd?.gender) setPatientGender(pd.gender);
if (pd?.pmh) setPmh(pd.pmh); if (pd?.pmh) setPmh(pd.pmh);
@ -82,8 +120,8 @@ export default function ChartReview() {
setLabel(enc.label || ''); setLabel(enc.label || '');
}} }}
onClear={() => { onClear={() => {
setVisits([emptyVisit()]); setResult(null); setVisits([emptyVisit()]); setExtraLabs([emptyLab()]); setResult(null);
setType('outpatient'); setPatientAge(''); setPatientGender(''); setReviewType('outpatient'); setPatientAge(''); setPatientGender('');
setPmh(''); setAdditionalInstructions(''); setPmh(''); setAdditionalInstructions('');
}} }}
/> />
@ -92,7 +130,7 @@ export default function ChartReview() {
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Review type</span> <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)}> <select className={input} value={reviewType} onChange={(e) => setReviewType(e.target.value as ReviewType)}>
<option value="outpatient">Outpatient</option> <option value="outpatient">Outpatient</option>
<option value="subspecialty">Subspecialty</option> <option value="subspecialty">Subspecialty</option>
<option value="ed">ED</option> <option value="ed">ED</option>
@ -116,37 +154,54 @@ export default function ChartReview() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-semibold">Visits</span> <span className="text-sm font-semibold">Visits / Notes</span>
<button <button type="button" onClick={() => setVisits((v) => [...v, emptyVisit()])} className={btn}>+ Add visit / note</button>
type="button"
onClick={() => setVisits((v) => [...v, emptyVisit()])}
className="text-xs rounded-md border border-border px-2 py-1"
>
+ Add visit
</button>
</div> </div>
{visits.map((v, i) => ( {visits.map((v, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-2 bg-card"> <div key={i} className="rounded-lg border border-border p-3 space-y-2 bg-card" data-testid={'cr-visit-' + i}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<strong className="text-sm">Visit / Note #{i + 1}</strong>
<input <input
type="date" type="date"
className={input + ' max-w-xs'} className={inputSm + ' w-40'}
value={v.date} value={v.date}
onChange={(e) => updateVisit(i, { date: e.target.value })} onChange={(e) => updateVisit(i, { date: e.target.value })}
/> />
<select
className={inputSm}
value={v.visitType}
onChange={(e) => updateVisit(i, { visitType: e.target.value as VisitType })}
data-testid={'cr-visit-type-' + i}
>
<option value="outpatient">Outpatient Visit</option>
<option value="subspecialty">Subspecialty Note</option>
<option value="ed">ED Visit</option>
</select>
{v.visitType === 'subspecialty' && (
<>
<input
type="text"
className={inputSm + ' flex-1 min-w-[140px]'}
placeholder="Specialist name"
value={v.specialistName || ''}
onChange={(e) => updateVisit(i, { specialistName: e.target.value })}
/>
<input
type="text"
className={inputSm + ' flex-1 min-w-[140px]'}
placeholder="Specialty (e.g. Endocrinology)"
value={v.specialty || ''}
onChange={(e) => updateVisit(i, { specialty: e.target.value })}
/>
</>
)}
{visits.length > 1 && ( {visits.length > 1 && (
<button <button type="button" onClick={() => setVisits((vs) => vs.filter((_, idx) => idx !== i))} className="ml-auto text-xs text-destructive" title="Remove">🗑</button>
type="button"
onClick={() => setVisits(vs => vs.filter((_, idx) => idx !== i))}
className="text-xs text-destructive"
>
Remove
</button>
)} )}
</div> </div>
<textarea <textarea
className={input + ' min-h-[100px] font-mono text-sm'} className={input + ' min-h-[120px] font-mono text-sm'}
placeholder="Visit note content — paste here." placeholder="Paste note here…"
value={v.content} value={v.content}
onChange={(e) => updateVisit(i, { content: e.target.value })} onChange={(e) => updateVisit(i, { content: e.target.value })}
/> />
@ -160,8 +215,19 @@ export default function ChartReview() {
))} ))}
</div> </div>
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<strong className="text-sm">🧪 Additional Labs</strong>
<p className="text-xs text-muted-foreground">Labs not tied to a specific visit.</p>
<LabsList
value={extraLabs}
onChange={setExtraLabs}
placeholder="e.g. TSH 4.1, Free T4 1.2"
testIdPrefix="cr-extra-labs"
/>
</div>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Additional instructions</span> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Instructions (optional)</span>
<textarea <textarea
className={input + ' min-h-[60px] text-sm'} className={input + ' min-h-[60px] text-sm'}
placeholder="e.g. 'Focus on thyroid management', 'Highlight medication changes'" placeholder="e.g. 'Focus on thyroid management', 'Highlight medication changes'"
@ -177,7 +243,7 @@ export default function ChartReview() {
disabled={generate.isPending || !visits.some((v) => v.content.trim())} 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" 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'} {generate.isPending ? 'Generating…' : 'Generate Chart Review'}
</button> </button>
</form> </form>

View file

@ -1,54 +1,87 @@
// ============================================================ // ============================================================
// HOSPITAL COURSE — /api/generate-hospital-course // HOSPITAL COURSE — faithful port of public/components/hospital.html
// + public/js/hospitalCourse.js (@be14578):
// • Demographics bar (age/gender/PMH/setting/LOS/format)
// • Save/Load encounter toolbar
// • ED Note card (date + labs + content + dictate)
// • H&P card (date + content + dictate)
// • Progress Notes — dynamic cards, each with date + type select
// + content + per-card dictate + remove
// • Labs — dynamic (date, values) list
// • Additional instructions textarea
// • "What's Missing?" (clarify) button on the output
// • Refine / Shorter / Read / Nextcloud export / editable body
// ============================================================ // ============================================================
import { useState } from 'react'; import { useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api'; import { api, ApiError } from '@/lib/api';
import type { HospitalCourseOk } from '@/shared/types'; import type { HospitalCourseOk } from '@/shared/types';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar'; import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult'; import EditableResult from '@/components/EditableResult';
import DictatableCard from './hospital/DictatableCard';
import LabsList, { type LabRow, emptyLab } from './hospital/LabsList';
import ClarifyButton from './hospital/ClarifyButton';
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych'; type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem'; type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
const TYPE = 'hospital' as const; const TYPE = 'hospital' as const;
interface NoteEntry { date: string; type: string; content: string } type ProgressType =
| 'progress-attending' | 'progress-resident' | 'progress-np'
| 'consult' | 'procedure' | 'discharge-summary';
interface ProgressNote { date: string; type: ProgressType; content: string }
function emptyProgressNote(): ProgressNote {
return { date: '', type: 'progress-attending', content: '' };
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const inputSm = 'rounded-md border border-input bg-background px-2 py-1 text-sm';
export default function HospitalCourse() { export default function HospitalCourse() {
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [recError, setRecError] = useState<string | null>(null);
const [patientAge, setPatientAge] = useState(''); const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState(''); const [patientGender, setPatientGender] = useState('');
const [pmh, setPmh] = useState(''); const [pmh, setPmh] = useState('');
const [setting, setSetting] = useState<SettingKind>('floor'); const [setting, setSetting] = useState<SettingKind>('floor');
const [los, setLos] = useState(''); const [los, setLos] = useState('');
const [format, setFormat] = useState<FormatKind>('auto'); const [format, setFormat] = useState<FormatKind>('auto');
const [hAndPContent, setHAndPContent] = useState('');
const [notesText, setNotesText] = useState(''); const [edDate, setEdDate] = useState('');
const [edLabs, setEdLabs] = useState('');
const [edContent, setEdContent] = useState('');
const [hpDate, setHpDate] = useState('');
const [hpContent, setHpContent] = useState('');
const [progressNotes, setProgressNotes] = useState<ProgressNote[]>([emptyProgressNote()]);
const [labs, setLabs] = useState<LabRow[]>([emptyLab()]);
const [additionalInstructions, setAdditionalInstructions] = useState(''); const [additionalInstructions, setAdditionalInstructions] = useState('');
const [result, setResult] = useState<{ hospitalCourse: string; format: string } | null>(null); const [result, setResult] = useState<{ hospitalCourse: string; format: string } | null>(null);
const generate = useMutation<HospitalCourseOk, Error, any>({ const generate = useMutation<HospitalCourseOk, Error, Record<string, unknown>>({
mutationFn: (body) => api.post<HospitalCourseOk>('/api/generate-hospital-course', body), mutationFn: (body) => api.post<HospitalCourseOk>('/api/generate-hospital-course', body),
onSuccess: (data) => setResult({ hospitalCourse: data.hospitalCourse, format: data.format || 'auto' }), onSuccess: (data) => setResult({ hospitalCourse: data.hospitalCourse, format: data.format || 'auto' }),
}); });
function setProgressNoteAt(i: number, patch: Partial<ProgressNote>) {
setProgressNotes((notes) => notes.map((n, idx) => (idx === i ? { ...n, ...patch } : n)));
}
function addProgressNote() { setProgressNotes((n) => [...n, emptyProgressNote()]); }
function removeProgressNote(i: number) { setProgressNotes((n) => n.filter((_, idx) => idx !== i)); }
function submit(e: React.FormEvent) { function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setResult(null); setResult(null);
// Notes textarea: one blank-line-separated note per block. First const notes = progressNotes.filter((n) => n.content.trim());
// line of each block is used as the date if it looks like one, if (notes.length === 0) return; // server requires at least one note
// 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({ generate.mutate({
notes, notes,
hAndP: hAndPContent ? { date: 'Admission', content: hAndPContent } : undefined, edNote: edContent.trim() ? { date: edDate, content: edContent, labs: edLabs } : undefined,
hAndP: hpContent.trim() ? { date: hpDate, content: hpContent } : undefined,
labs: labs.filter((l) => l.values.trim()),
patientAge, patientGender, pmh, setting, patientAge, patientGender, pmh, setting,
los: los ? parseInt(los) : undefined, los: los ? parseInt(los) : undefined,
formatPreference: format, formatPreference: format,
@ -56,24 +89,55 @@ export default function HospitalCourse() {
}); });
} }
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm'; function clearAll() {
setResult(null);
setPatientAge(''); setPatientGender(''); setPmh('');
setSetting('floor'); setLos(''); setFormat('auto');
setEdDate(''); setEdLabs(''); setEdContent('');
setHpDate(''); setHpContent('');
setProgressNotes([emptyProgressNote()]);
setLabs([emptyLab()]);
setAdditionalInstructions('');
}
// Save the whole structured note-set into transcript so the Load flow
// restores it (server treats transcript as an opaque string for us).
const serializedTranscript = JSON.stringify({
edNote: { date: edDate, labs: edLabs, content: edContent },
hAndP: { date: hpDate, content: hpContent },
notes: progressNotes,
labs,
});
return ( return (
<div className="max-w-4xl mx-auto p-6 space-y-4"> <div className="max-w-4xl mx-auto p-6 space-y-4">
<header> <header>
<h1 className="text-2xl font-semibold">Hospital Course</h1> <h1 className="text-2xl font-semibold">Hospital Course Generator</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Progress notes + H&amp;P hospital course summary (prose, day-by-day, or organ-system format). Upload ED note, H&amp;P, progress notes, labs AI generates an organized hospital course (prose, day-by-day, or organ-system format).
</p> </p>
</header> </header>
<EncounterToolbar <EncounterToolbar
type={TYPE} type={TYPE}
label={label} setLabel={setLabel} label={label} setLabel={setLabel}
transcript={notesText} generatedNote={result?.hospitalCourse || ''} transcript={serializedTranscript} generatedNote={result?.hospitalCourse || ''}
partialData={{ age: patientAge, gender: patientGender, pmh, setting, los, format, hAndPContent, additionalInstructions }} partialData={{ age: patientAge, gender: patientGender, pmh, setting, los, format, additionalInstructions }}
onLoad={(enc) => { onLoad={(enc) => {
setNotesText(enc.transcript || ''); try {
const parsed = enc.transcript ? JSON.parse(enc.transcript) : null;
if (parsed?.edNote) {
setEdDate(parsed.edNote.date || '');
setEdLabs(parsed.edNote.labs || '');
setEdContent(parsed.edNote.content || '');
}
if (parsed?.hAndP) {
setHpDate(parsed.hAndP.date || '');
setHpContent(parsed.hAndP.content || '');
}
if (Array.isArray(parsed?.notes) && parsed.notes.length) setProgressNotes(parsed.notes);
if (Array.isArray(parsed?.labs) && parsed.labs.length) setLabs(parsed.labs);
} catch { /* ignore */ }
setResult(enc.generated_note ? { hospitalCourse: enc.generated_note, format: 'auto' } : null); setResult(enc.generated_note ? { hospitalCourse: enc.generated_note, format: 'auto' } : null);
try { try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null; const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
@ -83,17 +147,11 @@ export default function HospitalCourse() {
if (pd?.setting) setSetting(pd.setting); if (pd?.setting) setSetting(pd.setting);
if (pd?.los) setLos(pd.los); if (pd?.los) setLos(pd.los);
if (pd?.format) setFormat(pd.format); if (pd?.format) setFormat(pd.format);
if (pd?.hAndPContent) setHAndPContent(pd.hAndPContent);
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions); if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
} catch { /* ignore */ } } catch { /* ignore */ }
setLabel(enc.label || ''); setLabel(enc.label || '');
}} }}
onClear={() => { onClear={clearAll}
setNotesText(''); setResult(null);
setPatientAge(''); setPatientGender(''); setPmh('');
setSetting('floor'); setLos(''); setFormat('auto');
setHAndPContent(''); setAdditionalInstructions('');
}}
/> />
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} className="space-y-4">
@ -109,12 +167,12 @@ export default function HospitalCourse() {
</select> </select>
</label> </label>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Setting</span> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Unit</span>
<select className={input} value={setting} onChange={(e) => setSetting(e.target.value as SettingKind)}> <select className={input} value={setting} onChange={(e) => setSetting(e.target.value as SettingKind)}>
<option value="floor">Floor</option> <option value="floor">General Floor</option>
<option value="picu">PICU</option> <option value="picu">PICU</option>
<option value="nicu">NICU</option> <option value="nicu">NICU</option>
<option value="psych">Psych</option> <option value="psych">Psych/Behavioral</option>
</select> </select>
</label> </label>
</div> </div>
@ -126,7 +184,7 @@ export default function HospitalCourse() {
</label> </label>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">LOS (days)</span> <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)} /> <input className={input} type="number" min={1} value={los} onChange={(e) => setLos(e.target.value)} />
</label> </label>
</div> </div>
@ -140,58 +198,119 @@ export default function HospitalCourse() {
</select> </select>
</label> </label>
<label className="flex flex-col gap-1"> <DictatableCard
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">H&amp;P</span> title="🚑 ED Note (before admission)"
<textarea className={input + ' min-h-[120px] font-mono text-sm'} value={hAndPContent} onChange={(e) => setHAndPContent(e.target.value)} /> date={edDate}
</label> onDateChange={setEdDate}
content={edContent}
onContentChange={setEdContent}
placeholder="Paste or type ED note here…"
moduleName="hospital-ed"
testId="hc-ed-card"
>
<input
type="text"
value={edLabs}
onChange={(e) => setEdLabs(e.target.value)}
placeholder="ED Labs (e.g. WBC 15, BMP normal…)"
className={inputSm + ' flex-1 min-w-[180px]'}
/>
</DictatableCard>
<Recorder <DictatableCard
module="hospital" title="📋 H&P (Admission Note)"
onTranscript={(text, meta) => { date={hpDate}
if (meta.appended) { onDateChange={setHpDate}
setNotesText((prev) => prev ? prev + '\n\n' + text : text); content={hpContent}
} else { onContentChange={setHpContent}
setNotesText((prev) => prev ? prev + '\n\n' + text : text); placeholder="Paste or type H&P here…"
} moduleName="hospital-hp"
setRecError(null); testId="hc-hp-card"
}}
onError={(msg) => setRecError(msg)}
/> />
{recError && <div className="text-sm text-destructive">{recError}</div>}
<div className="space-y-2">
<div className="flex items-center justify-between">
<strong className="text-sm">📝 Progress Notes</strong>
<button type="button" onClick={addProgressNote} className="rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted">
+ Add progress note
</button>
</div>
{progressNotes.map((n, i) => (
<DictatableCard
key={i}
title={'Progress Note #' + (i + 1)}
date={n.date}
onDateChange={(d) => setProgressNoteAt(i, { date: d })}
content={n.content}
onContentChange={(c) => setProgressNoteAt(i, { content: c })}
placeholder="Paste or type note…"
moduleName={'hospital-progress-' + i}
onRemove={progressNotes.length > 1 ? () => removeProgressNote(i) : undefined}
testId={'hc-progress-' + i}
>
<select
value={n.type}
onChange={(e) => setProgressNoteAt(i, { type: e.target.value as ProgressType })}
className={inputSm}
>
<option value="progress-attending">Progress Attending</option>
<option value="progress-resident">Progress Resident</option>
<option value="progress-np">Progress NP/PA</option>
<option value="consult">Consult Note</option>
<option value="procedure">Procedure Note</option>
<option value="discharge-summary">Discharge Summary</option>
</select>
</DictatableCard>
))}
</div>
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<strong className="text-sm">🧪 Labs</strong>
<LabsList
value={labs}
onChange={setLabs}
placeholder="e.g. WBC 12.5, H/H 10.2/31, BMP: Na 138, K 3.5"
testIdPrefix="hc-labs"
/>
</div>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Additional instructions (optional)</span>
Progress notes <span className="normal-case font-normal text-muted-foreground">(separate each note with a blank line)</span> <textarea
</span> className={input + ' min-h-[60px] text-sm'}
<textarea className={input + ' min-h-[200px] font-mono text-sm'} value={notesText} onChange={(e) => setNotesText(e.target.value)} /> value={additionalInstructions}
</label> onChange={(e) => setAdditionalInstructions(e.target.value)}
placeholder="e.g. 'Focus on respiratory course', 'Patient was transferred from outside hospital'"
<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> </label>
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>} {generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button <button
type="submit" type="submit"
disabled={generate.isPending || !notesText.trim()} disabled={generate.isPending || progressNotes.every((n) => !n.content.trim())}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50" 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'} {generate.isPending ? 'Generating…' : 'Generate Hospital Course'}
</button> </button>
</form> </form>
{result && ( {result && (
<EditableResult <>
text={result.hospitalCourse} <EditableResult
onChange={(t) => setResult({ hospitalCourse: t, format: result.format })} text={result.hospitalCourse}
section={null} onChange={(t) => setResult({ hospitalCourse: t, format: result.format })}
title={'Hospital Course (' + result.format + ')'} section={null}
exportLabel="hospital-course" title={'Hospital Course (' + result.format + ')'}
exportType="hospital-course" exportLabel="hospital-course"
sourceContext={notesText} exportType="hospital-course"
/> sourceContext={JSON.stringify(progressNotes.map((n) => n.content))}
/>
<ClarifyButton
currentDraft={result.hospitalCourse}
notes={progressNotes.filter((n) => n.content.trim())}
/>
</>
)} )}
</div> </div>
); );

View file

@ -0,0 +1,54 @@
// ============================================================
// ClarifyButton — "What's Missing?" → POST /api/hospital-course-clarify
// Only Hospital Course had this in vanilla (public/components/
// hospital.html #hc-clarify-btn); it returns a short list of
// questions the AI thinks the user should answer before finalising
// the course summary.
// ============================================================
import { useState } from 'react';
interface Props {
currentDraft: string;
notes: unknown; // JSON-serialised by the server
disabled?: boolean;
}
const btn = 'inline-flex items-center gap-1 rounded-md border border-amber-400 bg-amber-50 text-amber-900 px-3 py-1.5 text-xs font-medium hover:bg-amber-100 disabled:opacity-50';
export default function ClarifyButton({ currentDraft, notes, disabled }: Props) {
const [busy, setBusy] = useState(false);
const [questions, setQuestions] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
async function run() {
setBusy(true); setErr(null); setQuestions(null);
try {
const r = await fetch('/api/hospital-course-clarify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentDraft, notes }),
});
const data = await r.json();
if (!data.success) throw new Error(data.error || 'Request failed');
setQuestions(data.questions);
} catch (e) {
setErr((e as Error).message);
} finally { setBusy(false); }
}
return (
<div className="space-y-2" data-testid="clarify-bar">
<button type="button" onClick={run} disabled={busy || disabled} className={btn} data-testid="clarify-run">
{busy ? '⌛ Asking…' : '❓ What\'s Missing?'}
</button>
{err && <div className="text-sm text-destructive">{err}</div>}
{questions && (
<div className="rounded-md border border-amber-200 bg-amber-50 dark:bg-amber-950/30 p-3 whitespace-pre-wrap text-sm">
{questions}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,73 @@
// ============================================================
// DictatableCard — generic "date + content + dictate" card. Used
// for Hospital Course's ED Note, H&P, and each Progress Note, plus
// Chart Review's per-visit cards. Each card gets its own Recorder
// so the user can dictate into one card without interrupting
// another in progress.
// ============================================================
import Recorder from '@/components/Recorder';
import { useState } from 'react';
interface Props {
title: string;
date: string;
onDateChange: (s: string) => void;
content: string;
onContentChange: (s: string) => void;
placeholder?: string;
moduleName: string; // unique per-card id for /api/transcribe module param
onRemove?: () => void; // optional — dynamic cards only
children?: React.ReactNode; // extra meta fields (type select, specialist, labs)
testId?: string;
}
const input = 'rounded-md border border-input bg-background px-2 py-1 text-sm';
export default function DictatableCard({
title, date, onDateChange, content, onContentChange, placeholder, moduleName, onRemove, children, testId,
}: Props) {
const [interim, setInterim] = useState('');
const [recError, setRecError] = useState<string | null>(null);
const displayed = interim || content;
return (
<div className="rounded-lg border border-border bg-card p-3 space-y-2" data-testid={testId}>
<div className="flex items-center gap-2 flex-wrap">
<strong className="text-sm">{title}</strong>
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className={input + ' w-40'}
/>
{children}
{onRemove && (
<button type="button" onClick={onRemove} className="ml-auto text-xs text-destructive hover:text-red-700" title="Remove">
🗑
</button>
)}
</div>
<Recorder
module={moduleName}
onTranscript={(text, meta) => {
// Per-card recorder always appends — never wipes an existing paste.
onContentChange(content ? content + (meta.appended ? '\n' : '') + text : text);
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (content ? content + '\n' + t : t) : '')}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<textarea
className={input + ' w-full min-h-[120px] font-mono text-sm'}
placeholder={placeholder || 'Paste or type the note…'}
value={displayed}
onChange={(e) => { onContentChange(e.target.value); setInterim(''); }}
/>
</div>
);
}

View file

@ -0,0 +1,55 @@
// ============================================================
// LabsList — dynamic (date, values) rows. Port of hc-labs-container
// / cr-labs-container in the vanilla hospital.html / chart.html
// components. Pure controlled component — parent owns the array.
// ============================================================
export interface LabRow { date: string; values: string }
interface Props {
value: LabRow[];
onChange: (next: LabRow[]) => void;
placeholder?: string;
testIdPrefix?: string;
}
const input = 'rounded-md border border-input bg-background px-2 py-1 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';
export function emptyLab(): LabRow { return { date: '', values: '' }; }
export default function LabsList({ value, onChange, placeholder, testIdPrefix = 'labs' }: Props) {
function patch(i: number, next: Partial<LabRow>) {
onChange(value.map((r, idx) => (idx === i ? { ...r, ...next } : r)));
}
function add() { onChange([...value, emptyLab()]); }
function remove(i: number) { onChange(value.filter((_, idx) => idx !== i)); }
return (
<div className="space-y-2" data-testid={testIdPrefix}>
{value.length === 0 && (
<div className="text-xs text-muted-foreground italic">No labs added.</div>
)}
{value.map((row, i) => (
<div key={i} className="flex flex-col sm:flex-row items-start gap-2" data-testid={testIdPrefix + '-row-' + i}>
<input
type="date"
value={row.date}
onChange={(e) => patch(i, { date: e.target.value })}
className={input + ' w-40 shrink-0'}
/>
<textarea
value={row.values}
onChange={(e) => patch(i, { values: e.target.value })}
placeholder={placeholder || 'e.g. WBC 12.5, H/H 10.2/31, BMP Na 138, K 3.5'}
className={input + ' flex-1 min-h-[60px] font-mono text-xs'}
/>
<button type="button" onClick={() => remove(i)} className="text-xs text-destructive hover:text-red-700" title="Remove">
🗑
</button>
</div>
))}
<button type="button" onClick={add} className={btn}>+ Add lab</button>
</div>
);
}