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
5c2729f785
commit
b0d549766b
9 changed files with 376 additions and 58 deletions
|
|
@ -1,6 +1,9 @@
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
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 Extensions from '@/pages/Extensions';
|
||||||
|
import Faq from '@/pages/Faq';
|
||||||
|
import Dictation from '@/pages/Dictation';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
|
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{' '}
|
This is the new React tree. The legacy vanilla-JS app still lives at{' '}
|
||||||
<a href="/" className="underline">/</a>.
|
<a href="/" className="underline">/</a>.
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 text-sm">
|
<p className="text-sm text-muted-foreground">Ported tabs so far:</p>
|
||||||
<li><Link to="/app/extensions" className="underline">Extensions & Pagers</Link> — first ported tab</li>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -26,8 +32,14 @@ export default function App() {
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter basename="/app">
|
<BrowserRouter basename="/app">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route element={<Layout />}>
|
||||||
<Route path="/extensions" element={<Extensions />} />
|
<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>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</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" />
|
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>client</title>
|
<title>client</title>
|
||||||
<script type="module" crossorigin src="/app/assets/index-B9E7ZEZn.js"></script>
|
<script type="module" crossorigin src="/app/assets/index-BmpHzFRb.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/app/assets/index-He39n9ae.css">
|
<link rel="stylesheet" crossorigin href="/app/assets/index-B2vc_21R.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue