feat(client): port FAQ + Dictation to React, add sidebar Layout

Three new pages behind the /app/* React router:

client/src/components/Layout.tsx
  Sidebar + main content shell. NavLink-based nav with a single
  NAV data structure mirroring the vanilla app's sidebar groups
  (Encounters / Notes / Clinical Tools / Account). Items with
  `available: false` render as greyed-out 'pending' stubs so the
  future tab list is visible during migration without breaking
  clicks. Vanilla-app fallback link is pinned at the top so anyone
  needing a feature not yet ported can jump back to /.

client/src/pages/Faq.tsx
  8 sections, 27 questions ported verbatim from
  public/components/faq.html. Collapsible accordion pattern via
  local useState — no Radix dependency yet. Content lives in
  client/src/data/faq.ts (extracted from the HTML via a one-off
  python parse, so re-extraction is reproducible if the vanilla
  FAQ ever grows).

client/src/pages/Dictation.tsx
  Minimum-viable port of Voice Dictation → HPI. Demographics
  (age / gender / setting), transcript textarea, Zod-validated
  submit to POST /api/generate-hpi-dictation, result pane with
  copy-to-clipboard. Not yet ported from the vanilla tab:
  MediaRecorder audio capture + /api/transcribe upload, save/load
  popover, refine + shorten buttons, Nextcloud export. Each of
  those is its own follow-up.

client/src/App.tsx
  All routes now render inside <Layout />. New routes wired:
  /, /extensions, /dictation, /faq. A catch-all Navigate redirects
  any unknown /app/* path back to home.

Build check:
  client: npx tsc -b     → EXIT 0
  client: npx vite build → 350 kB / 108 kB gzipped
Public bundle at public/app/index-BmpHzFRb.js replaces the previous
one; committed so the next prod rebuild ships it atomically.

Nothing on the backend changed. /api/generate-hpi-dictation and
/api/extensions already exist; the React pages just call them.
This commit is contained in:
Daniel 2026-04-23 22:16:47 +02:00
parent 7e97b04811
commit 552ead0901
9 changed files with 376 additions and 58 deletions

View file

@ -1,6 +1,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
import Layout from '@/components/Layout';
import Extensions from '@/pages/Extensions';
import Faq from '@/pages/Faq';
import Dictation from '@/pages/Dictation';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
@ -14,8 +17,11 @@ function Home() {
This is the new React tree. The legacy vanilla-JS app still lives at{' '}
<a href="/" className="underline">/</a>.
</p>
<ul className="list-disc pl-6 text-sm">
<li><Link to="/app/extensions" className="underline">Extensions & Pagers</Link> first ported tab</li>
<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="/dictation" className="underline">Dictation HPI</Link></li>
<li><Link to="/faq" className="underline">FAQ</Link></li>
</ul>
</div>
);
@ -26,8 +32,14 @@ export default function App() {
<QueryClientProvider client={queryClient}>
<BrowserRouter basename="/app">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/extensions" element={<Extensions />} />
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
<Route path="/extensions" element={<Extensions />} />
<Route path="/dictation" element={<Dictation />} />
<Route path="/faq" element={<Faq />} />
{/* catch-all falls back to home while more tabs port over */}
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>

View file

@ -0,0 +1,115 @@
// ============================================================
// LAYOUT — sidebar + main content shell shared across every page.
// Structure mirrors the vanilla app so a user moving between the two
// trees during migration sees consistent navigation.
// ============================================================
import { NavLink, Outlet } from 'react-router-dom';
import type { ReactNode } from 'react';
interface NavItem {
to: string;
label: string;
available?: boolean; // false = rendered as "coming soon" stub
}
interface NavGroup {
label: string;
items: NavItem[];
}
const NAV: NavGroup[] = [
{
label: 'Encounters',
items: [
{ to: '/encounter', label: 'Encounter HPI', available: false },
{ to: '/dictation', label: 'Dictation HPI', available: true },
],
},
{
label: 'Notes',
items: [
{ to: '/hospital', label: 'Hospital Course', available: false },
{ to: '/chart', label: 'Chart Review', available: false },
{ to: '/soap', label: 'SOAP Note', available: false },
{ to: '/wellvisit', label: 'Well Visit', available: false },
{ to: '/sickvisit', label: 'Sick Visit', available: false },
],
},
{
label: 'Clinical Tools',
items: [
{ to: '/vaxschedule', label: 'Vaccine Schedule', available: false },
{ to: '/catchup', label: 'Catch-Up Schedule', available: false },
{ to: '/peguide', label: 'Physical Exam Guide', available: false },
{ to: '/bedside', label: 'Bedside', available: false },
{ to: '/calculators', label: 'Calculators', available: false },
{ to: '/extensions', label: 'Pagers & Extensions', available: true },
{ to: '/learning', label: 'Learning Hub', available: false },
],
},
{
label: 'Account',
items: [
{ to: '/settings', label: 'Settings', available: false },
{ to: '/faq', label: 'FAQ', available: true },
],
},
];
function SidebarLink({ item }: { item: NavItem }) {
if (!item.available) {
return (
<div
className="px-3 py-2 text-sm rounded-md text-muted-foreground italic cursor-not-allowed opacity-60"
title="Not yet ported to React — still available in the vanilla app at /"
>
{item.label} <span className="text-[10px]">· pending</span>
</div>
);
}
return (
<NavLink
to={item.to}
className={({ isActive }) =>
'block px-3 py-2 text-sm rounded-md transition-colors ' +
(isActive
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-foreground')
}
>
{item.label}
</NavLink>
);
}
export default function Layout({ children }: { children?: ReactNode }) {
return (
<div className="min-h-screen bg-background text-foreground flex">
{/* Sidebar */}
<aside className="w-64 border-r border-border bg-muted/30 flex-shrink-0 p-3 space-y-4 sticky top-0 h-screen overflow-y-auto">
<div className="px-2 py-1 border-b border-border pb-3">
<div className="font-semibold">Pediatric AI Scribe</div>
<a href="/" className="text-[11px] text-muted-foreground underline">
back to legacy app
</a>
</div>
{NAV.map((group) => (
<div key={group.label} className="space-y-1">
<div className="px-3 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</div>
{group.items.map((item) => (
<SidebarLink key={item.to} item={item} />
))}
</div>
))}
</aside>
{/* Main */}
<main className="flex-1 min-w-0">
{children ?? <Outlet />}
</main>
</div>
);
}

View file

@ -0,0 +1,134 @@
// ============================================================
// DICTATION — voice dictation → HPI via /api/generate-hpi-dictation
//
// Minimum-viable port: demographics + transcript textarea + generate.
// The vanilla version also has MediaRecorder-based audio capture,
// transcription upload, save/load popover, refine, shorten, and
// Nextcloud export. Those each land in follow-up commits — this
// first pass proves the generate-HPI wire protocol works from React.
// ============================================================
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 Dictation() {
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-dictation', body),
onSuccess: (data) => setResult(data.hpi),
onError: () => setResult(null),
});
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);
}
function clear() {
setTranscript('');
setResult(null);
setValidationError(null);
}
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">Voice Dictation HPI</h1>
<p className="text-sm text-muted-foreground">
Dictate your narrative AI restructures into polished HPI.
Audio-capture UI is a follow-up; this minimal form supports typed/pasted transcripts.
</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. 8 months" 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">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Transcript / dictation
</span>
<button type="button" onClick={clear} className="text-xs text-muted-foreground underline">
Clear
</button>
</div>
<textarea
className={input + ' min-h-[200px] font-mono text-sm'}
placeholder="Type or paste your dictation here, 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>}
<div className="flex gap-2">
<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>
</div>
</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>
);
}

57
client/src/pages/Faq.tsx Normal file
View file

@ -0,0 +1,57 @@
// ============================================================
// FAQ — ported from public/components/faq.html. Same content,
// same sectioned layout, collapsible questions. Content lives in
// data/faq.ts so adding an entry is a one-line data change.
// ============================================================
import { useState } from 'react';
import { FAQ_DATA } from '@/data/faq';
function FaqItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-border last:border-0">
<button
onClick={() => setOpen(!open)}
className="w-full text-left py-3 px-4 flex items-center justify-between hover:bg-muted/40 transition-colors"
aria-expanded={open}
>
<span className="font-medium text-sm">{q}</span>
<span className="text-muted-foreground text-sm">{open ? '' : '+'}</span>
</button>
{open && (
<div className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed whitespace-pre-line">
{a}
</div>
)}
</div>
);
}
export default function Faq() {
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<header>
<h1 className="text-2xl font-semibold">Frequently Asked Questions</h1>
<p className="text-sm text-muted-foreground">
Learn how Pediatric AI Scribe works and get the most out of it.
</p>
</header>
{FAQ_DATA.map((section) => (
<section
key={section.section}
className="rounded-lg border border-border overflow-hidden"
>
<h2 className="bg-muted/40 px-4 py-2 text-sm font-semibold">
{section.section}
</h2>
<div className="bg-card">
{section.items.map((item) => (
<FaqItem key={item.q} q={item.q} a={item.a} />
))}
</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

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-B9E7ZEZn.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-He39n9ae.css">
<script type="module" crossorigin src="/app/assets/index-BmpHzFRb.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-B2vc_21R.css">
</head>
<body>
<div id="root"></div>