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:
parent
f8abf6e171
commit
d9fd4fcd69
9 changed files with 356 additions and 15 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
112
client/src/pages/Encounter.tsx
Normal file
112
client/src/pages/Encounter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
client/src/pages/SickVisit.tsx
Normal file
101
client/src/pages/SickVisit.tsx
Normal 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
119
client/src/pages/Soap.tsx
Normal 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
2
public/app/assets/index-Cge-0uqv.css
Normal file
2
public/app/assets/index-Cge-0uqv.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue