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:
parent
d1f45b3dcb
commit
bdd1b6ec3e
5 changed files with 481 additions and 114 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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&P → hospital course summary (prose, day-by-day, or organ-system format).
|
||||
Upload ED note, H&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&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>
|
||||
);
|
||||
|
|
|
|||
54
client/src/pages/hospital/ClarifyButton.tsx
Normal file
54
client/src/pages/hospital/ClarifyButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
client/src/pages/hospital/DictatableCard.tsx
Normal file
73
client/src/pages/hospital/DictatableCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
client/src/pages/hospital/LabsList.tsx
Normal file
55
client/src/pages/hospital/LabsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue