pediatric-ai-scribe-v3/client/src/pages/Soap.tsx
Daniel 54f30cbacb 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.
2026-04-23 22:19:12 +02:00

119 lines
5.3 KiB
TypeScript

// ============================================================
// 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>
);
}