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';
@ -8,30 +16,49 @@ import { api, ApiError } from '@/lib/api';
import type { ChartReviewOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult';
import LabsList, { type LabRow, emptyLab } from './hospital/LabsList';
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
type VisitType = 'outpatient' | 'subspecialty' | 'ed';
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() {
const [label, setLabel] = useState('');
const [type, setType] = useState<ReviewType>('outpatient');
const [reviewType, setReviewType] = useState<ReviewType>('outpatient');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = 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 [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),
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)));
}
@ -39,41 +66,52 @@ export default function ChartReview() {
e.preventDefault();
setResult(null);
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({
type,
type: reviewType,
patientAge, patientGender, pmh,
visits: type === 'outpatient' ? filled : undefined,
subspecialty: type === 'subspecialty' ? filled : undefined,
edVisits: type === 'ed' ? filled : undefined,
visits: filled.filter((v) => v.visitType === 'outpatient').map(toOut),
subspecialty: filled.filter((v) => v.visitType === 'subspecialty').map(toSub),
edVisits: filled.filter((v) => v.visitType === 'ed').map(toOut),
labs: extraLabs.filter((l) => l.values.trim()),
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.
Past visits + labs summary for pre-charting. Mix of outpatient, subspecialty, and ED visits supported.
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={JSON.stringify(visits)} generatedNote={result || ''}
partialData={{ type, age: patientAge, gender: patientGender, pmh, additionalInstructions }}
transcript={JSON.stringify({ visits, extraLabs })} generatedNote={result || ''}
partialData={{ reviewType, age: patientAge, gender: patientGender, pmh, additionalInstructions }}
onLoad={(enc) => {
try {
const parsedVisits = enc.transcript ? JSON.parse(enc.transcript) as VisitInput[] : [emptyVisit()];
setVisits(parsedVisits.length ? parsedVisits : [emptyVisit()]);
} catch { setVisits([emptyVisit()]); }
const parsed = enc.transcript ? JSON.parse(enc.transcript) : null;
if (Array.isArray(parsed?.visits) && parsed.visits.length) setVisits(parsed.visits);
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);
try {
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?.gender) setPatientGender(pd.gender);
if (pd?.pmh) setPmh(pd.pmh);
@ -82,8 +120,8 @@ export default function ChartReview() {
setLabel(enc.label || '');
}}
onClear={() => {
setVisits([emptyVisit()]); setResult(null);
setType('outpatient'); setPatientAge(''); setPatientGender('');
setVisits([emptyVisit()]); setExtraLabs([emptyLab()]); setResult(null);
setReviewType('outpatient'); setPatientAge(''); setPatientGender('');
setPmh(''); setAdditionalInstructions('');
}}
/>
@ -92,7 +130,7 @@ export default function ChartReview() {
<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)}>
<select className={input} value={reviewType} onChange={(e) => setReviewType(e.target.value as ReviewType)}>
<option value="outpatient">Outpatient</option>
<option value="subspecialty">Subspecialty</option>
<option value="ed">ED</option>
@ -116,37 +154,54 @@ export default function ChartReview() {
<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>
<span className="text-sm font-semibold">Visits / Notes</span>
<button type="button" onClick={() => setVisits((v) => [...v, emptyVisit()])} className={btn}>+ Add visit / note</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">
<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 flex-wrap">
<strong className="text-sm">Visit / Note #{i + 1}</strong>
<input
type="date"
className={input + ' max-w-xs'}
className={inputSm + ' w-40'}
value={v.date}
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 && (
<button
type="button"
onClick={() => setVisits(vs => vs.filter((_, idx) => idx !== i))}
className="text-xs text-destructive"
>
Remove
</button>
<button type="button" onClick={() => setVisits((vs) => vs.filter((_, idx) => idx !== i))} className="ml-auto text-xs text-destructive" title="Remove">🗑</button>
)}
</div>
<textarea
className={input + ' min-h-[100px] font-mono text-sm'}
placeholder="Visit note content — paste here."
className={input + ' min-h-[120px] font-mono text-sm'}
placeholder="Paste note here…"
value={v.content}
onChange={(e) => updateVisit(i, { content: e.target.value })}
/>
@ -160,8 +215,19 @@ export default function ChartReview() {
))}
</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">
<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
className={input + ' min-h-[60px] text-sm'}
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())}
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>
</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 { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { HospitalCourseOk } from '@/shared/types';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
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 FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
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() {
const [label, setLabel] = useState('');
const [recError, setRecError] = useState<string | null>(null);
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 [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 [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),
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) {
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 }));
const notes = progressNotes.filter((n) => n.content.trim());
if (notes.length === 0) return; // server requires at least one note
generate.mutate({
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,
los: los ? parseInt(los) : undefined,
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 (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<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">
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>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={notesText} generatedNote={result?.hospitalCourse || ''}
partialData={{ age: patientAge, gender: patientGender, pmh, setting, los, format, hAndPContent, additionalInstructions }}
transcript={serializedTranscript} generatedNote={result?.hospitalCourse || ''}
partialData={{ age: patientAge, gender: patientGender, pmh, setting, los, format, additionalInstructions }}
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);
try {
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?.los) setLos(pd.los);
if (pd?.format) setFormat(pd.format);
if (pd?.hAndPContent) setHAndPContent(pd.hAndPContent);
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setNotesText(''); setResult(null);
setPatientAge(''); setPatientGender(''); setPmh('');
setSetting('floor'); setLos(''); setFormat('auto');
setHAndPContent(''); setAdditionalInstructions('');
}}
onClear={clearAll}
/>
<form onSubmit={submit} className="space-y-4">
@ -109,12 +167,12 @@ export default function HospitalCourse() {
</select>
</label>
<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)}>
<option value="floor">Floor</option>
<option value="floor">General Floor</option>
<option value="picu">PICU</option>
<option value="nicu">NICU</option>
<option value="psych">Psych</option>
<option value="psych">Psych/Behavioral</option>
</select>
</label>
</div>
@ -126,7 +184,7 @@ export default function HospitalCourse() {
</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)} />
<input className={input} type="number" min={1} value={los} onChange={(e) => setLos(e.target.value)} />
</label>
</div>
@ -140,58 +198,119 @@ export default function HospitalCourse() {
</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>
<DictatableCard
title="🚑 ED Note (before admission)"
date={edDate}
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
module="hospital"
onTranscript={(text, meta) => {
if (meta.appended) {
setNotesText((prev) => prev ? prev + '\n\n' + text : text);
} else {
setNotesText((prev) => prev ? prev + '\n\n' + text : text);
}
setRecError(null);
}}
onError={(msg) => setRecError(msg)}
<DictatableCard
title="📋 H&P (Admission Note)"
date={hpDate}
onDateChange={setHpDate}
content={hpContent}
onContentChange={setHpContent}
placeholder="Paste or type H&P here…"
moduleName="hospital-hp"
testId="hc-hp-card"
/>
{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">
<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)} />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Additional instructions (optional)</span>
<textarea
className={input + ' min-h-[60px] text-sm'}
value={additionalInstructions}
onChange={(e) => setAdditionalInstructions(e.target.value)}
placeholder="e.g. 'Focus on respiratory course', 'Patient was transferred from outside hospital'"
/>
</label>
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
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"
>
{generate.isPending ? 'Generating…' : 'Generate Hospital Course'}
{generate.isPending ? 'Generating…' : 'Generate Hospital Course'}
</button>
</form>
{result && (
<EditableResult
text={result.hospitalCourse}
onChange={(t) => setResult({ hospitalCourse: t, format: result.format })}
section={null}
title={'Hospital Course (' + result.format + ')'}
exportLabel="hospital-course"
exportType="hospital-course"
sourceContext={notesText}
/>
<>
<EditableResult
text={result.hospitalCourse}
onChange={(t) => setResult({ hospitalCourse: t, format: result.format })}
section={null}
title={'Hospital Course (' + result.format + ')'}
exportLabel="hospital-course"
exportType="hospital-course"
sourceContext={JSON.stringify(progressNotes.map((n) => n.content))}
/>
<ClarifyButton
currentDraft={result.hospitalCourse}
notes={progressNotes.filter((n) => n.content.trim())}
/>
</>
)}
</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>
);
}