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';
|
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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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&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>
|
</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&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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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