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:
parent
7e97b04811
commit
552ead0901
9 changed files with 376 additions and 58 deletions
|
|
@ -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>
|
||||
|
|
|
|||
115
client/src/components/Layout.tsx
Normal file
115
client/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
client/src/pages/Dictation.tsx
Normal file
134
client/src/pages/Dictation.tsx
Normal 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
57
client/src/pages/Faq.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
public/app/assets/index-B2vc_21R.css
Normal file
2
public/app/assets/index-B2vc_21R.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
49
public/app/assets/index-BmpHzFRb.js
Normal file
49
public/app/assets/index-BmpHzFRb.js
Normal file
File diff suppressed because one or more lines are too long
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-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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue