feat(client): port Encounter HPI, SOAP, Sick Visit

Three more tabs behind /app/*:

  /encounter  → Encounter.tsx   — POST /api/generate-hpi-encounter
  /soap       → Soap.tsx        — POST /api/generate-soap (full or
                                  subjective-only output type)
  /sickvisit  → SickVisit.tsx   — POST /api/sick-visit/note (chief
                                  complaint is the only required
                                  field; transcript optional)

All three share the same skeleton: demographic triad + textarea +
Zod-validated submit + copy-to-clipboard output pane. Follow-up work
per tab (save/load, refine, audio capture) lands in subsequent
commits — this first pass proves the AI generate path for each.

Layout sidebar updated: Encounter HPI / SOAP Note / Sick Visit now
marked available. Pending stubs remain for Hospital Course, Chart
Review, Well Visit, and all Clinical Tools + Settings.

Build: 361 kB / 109 kB gzipped (+11 kB over previous, as expected
for three small pages using the existing lib/api + shared schemas).

Typecheck + vite build both green on the client side.
This commit is contained in:
Daniel 2026-04-23 22:19:06 +02:00
parent f8abf6e171
commit d9fd4fcd69
9 changed files with 356 additions and 15 deletions

View file

@ -4,6 +4,9 @@ import Layout from '@/components/Layout';
import Extensions from '@/pages/Extensions';
import Faq from '@/pages/Faq';
import Dictation from '@/pages/Dictation';
import Encounter from '@/pages/Encounter';
import Soap from '@/pages/Soap';
import SickVisit from '@/pages/SickVisit';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
@ -19,8 +22,11 @@ function Home() {
</p>
<p className="text-sm text-muted-foreground">Ported tabs so far:</p>
<ul className="list-disc pl-6 text-sm space-y-1">
<li><Link to="/extensions" className="underline">Extensions & Pagers</Link></li>
<li><Link to="/encounter" className="underline">Encounter HPI</Link></li>
<li><Link to="/dictation" className="underline">Dictation HPI</Link></li>
<li><Link to="/soap" className="underline">SOAP Note</Link></li>
<li><Link to="/sickvisit" className="underline">Sick Visit</Link></li>
<li><Link to="/extensions" className="underline">Extensions & Pagers</Link></li>
<li><Link to="/faq" className="underline">FAQ</Link></li>
</ul>
</div>
@ -34,8 +40,11 @@ export default function App() {
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
<Route path="/extensions" element={<Extensions />} />
<Route path="/encounter" element={<Encounter />} />
<Route path="/dictation" element={<Dictation />} />
<Route path="/soap" element={<Soap />} />
<Route path="/sickvisit" element={<SickVisit />} />
<Route path="/extensions" element={<Extensions />} />
<Route path="/faq" element={<Faq />} />
{/* catch-all falls back to home while more tabs port over */}
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -22,7 +22,7 @@ const NAV: NavGroup[] = [
{
label: 'Encounters',
items: [
{ to: '/encounter', label: 'Encounter HPI', available: false },
{ to: '/encounter', label: 'Encounter HPI', available: true },
{ to: '/dictation', label: 'Dictation HPI', available: true },
],
},
@ -31,9 +31,9 @@ const NAV: NavGroup[] = [
items: [
{ to: '/hospital', label: 'Hospital Course', available: false },
{ to: '/chart', label: 'Chart Review', available: false },
{ to: '/soap', label: 'SOAP Note', available: false },
{ to: '/soap', label: 'SOAP Note', available: true },
{ to: '/wellvisit', label: 'Well Visit', available: false },
{ to: '/sickvisit', label: 'Sick Visit', available: false },
{ to: '/sickvisit', label: 'Sick Visit', available: true },
],
},
{

View file

@ -0,0 +1,112 @@
// ============================================================
// ENCOUNTER — live encounter → HPI via /api/generate-hpi-encounter
// Minimum-viable port: same shape as Dictation (same endpoint family).
// Audio capture + save/load + refine deferred to follow-up commits.
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { HpiOk } from '@/shared/types';
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
type Setting = 'outpatient' | 'inpatient';
export default function Encounter() {
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [setting, setSetting] = useState<Setting>('outpatient');
const [transcript, setTranscript] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const generate = useMutation<HpiOk, Error, HpiEncounterRequest>({
mutationFn: (body) => api.post<HpiOk>('/api/generate-hpi-encounter', body),
onSuccess: (data) => setResult(data.hpi),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: HpiEncounterRequest = { transcript, patientAge, patientGender, setting };
const parsed = HpiEncounterRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
return;
}
setResult(null);
generate.mutate(parsed.data);
}
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">Live Encounter HPI</h1>
<p className="text-sm text-muted-foreground">
Record or paste an encounter transcript; generate a structured HPI.
</p>
</header>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} placeholder="e.g. 5 years" value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option>
<option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Setting</span>
<select className={input} value={setting} onChange={(e) => setSetting(e.target.value as Setting)}>
<option value="outpatient">Outpatient</option>
<option value="inpatient">Inpatient / Floors</option>
</select>
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Transcript
</span>
<textarea
className={input + ' min-h-[220px] font-mono text-sm'}
placeholder="Type or paste an encounter transcript, then click Generate."
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
/>
</label>
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || !transcript.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate HPI'}
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<h2 className="text-sm font-semibold">Generated HPI</h2>
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">
Copy
</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
</section>
)}
</div>
);
}

View file

@ -0,0 +1,101 @@
// ============================================================
// SICK VISIT — /api/sick-visit/note
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas';
export default function SickVisit() {
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [chiefComplaint, setChiefComplaint] = useState('');
const [transcript, setTranscript] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const generate = useMutation<VisitNoteOk, Error, SickVisitRequest>({
mutationFn: (body) => api.post<VisitNoteOk>('/api/sick-visit/note', body),
onSuccess: (data) => setResult(data.note),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: SickVisitRequest = { patientAge, patientGender, chiefComplaint, transcript };
const parsed = SickVisitRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
return;
}
setResult(null);
generate.mutate(parsed.data);
}
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">Sick Visit</h1>
<p className="text-sm text-muted-foreground">
Chief complaint + transcript structured sick-visit note.
</p>
</header>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} placeholder="e.g. 4 years" value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option>
<option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1 col-span-3 md:col-span-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Chief complaint</span>
<input className={input} placeholder="e.g. Fever x 2 days" value={chiefComplaint} onChange={(e) => setChiefComplaint(e.target.value)} />
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript / dictation</span>
<textarea
className={input + ' min-h-[200px] font-mono text-sm'}
placeholder="Encounter narrative — transcribed or dictated."
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
/>
</label>
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || !chiefComplaint.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate Note'}
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<h2 className="text-sm font-semibold">Sick Visit Note</h2>
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
</section>
)}
</div>
);
}

119
client/src/pages/Soap.tsx Normal file
View file

@ -0,0 +1,119 @@
// ============================================================
// SOAP — transcript → SOAP note via /api/generate-soap
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { SoapOk } from '@/shared/types';
import { SoapRequestSchema, type SoapRequest } from '@/shared/schemas';
type SoapType = 'full' | 'subjective';
export default function Soap() {
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [type, setType] = useState<SoapType>('full');
const [transcript, setTranscript] = useState('');
const [additionalInstructions, setAdditionalInstructions] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const generate = useMutation<SoapOk, Error, SoapRequest>({
mutationFn: (body) => api.post<SoapOk>('/api/generate-soap', body),
onSuccess: (data) => setResult(data.soap),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: SoapRequest = { transcript, patientAge, patientGender, type, additionalInstructions };
const parsed = SoapRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
return;
}
setResult(null);
generate.mutate(parsed.data);
}
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">SOAP Note</h1>
<p className="text-sm text-muted-foreground">
Encounter transcript full SOAP or subjective-only narrative.
</p>
</header>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} placeholder="e.g. 3 years" value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option>
<option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Output type</span>
<select className={input} value={type} onChange={(e) => setType(e.target.value as SoapType)}>
<option value="full">Full SOAP</option>
<option value="subjective">Subjective only</option>
</select>
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript</span>
<textarea
className={input + ' min-h-[200px] font-mono text-sm'}
placeholder="Type or paste the encounter transcript."
value={transcript}
onChange={(e) => setTranscript(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 className="text-muted-foreground normal-case font-normal">(optional)</span>
</span>
<textarea
className={input + ' min-h-[60px] text-sm'}
placeholder="e.g., 'Include return precautions', 'Add differential for otitis media'"
value={additionalInstructions}
onChange={(e) => setAdditionalInstructions(e.target.value)}
/>
</label>
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || !transcript.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate SOAP'}
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<h2 className="text-sm font-semibold">Generated SOAP</h2>
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
</section>
)}
</div>
);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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