From 552ead0901e810f3b7616956fc4be28a229c801a Mon Sep 17 00:00:00 2001
From: Daniel
Date: Thu, 23 Apr 2026 22:16:47 +0200
Subject: [PATCH] feat(client): port FAQ + Dictation to React, add sidebar
Layout
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 . 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.
---
client/src/App.tsx | 22 ++++-
client/src/components/Layout.tsx | 115 +++++++++++++++++++++++
client/src/pages/Dictation.tsx | 134 +++++++++++++++++++++++++++
client/src/pages/Faq.tsx | 57 ++++++++++++
public/app/assets/index-B2vc_21R.css | 2 +
public/app/assets/index-B9E7ZEZn.js | 49 ----------
public/app/assets/index-BmpHzFRb.js | 49 ++++++++++
public/app/assets/index-He39n9ae.css | 2 -
public/app/index.html | 4 +-
9 files changed, 376 insertions(+), 58 deletions(-)
create mode 100644 client/src/components/Layout.tsx
create mode 100644 client/src/pages/Dictation.tsx
create mode 100644 client/src/pages/Faq.tsx
create mode 100644 public/app/assets/index-B2vc_21R.css
delete mode 100644 public/app/assets/index-B9E7ZEZn.js
create mode 100644 public/app/assets/index-BmpHzFRb.js
delete mode 100644 public/app/assets/index-He39n9ae.css
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 9a4ba0f..4502774 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -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{' '}
/.
-
-
Extensions & Pagers — first ported tab
+
Ported tabs so far:
+
+
Extensions & Pagers
+
Dictation HPI
+
FAQ
);
@@ -26,8 +32,14 @@ export default function App() {
- } />
- } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ {/* catch-all falls back to home while more tabs port over */}
+ } />
+
diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx
new file mode 100644
index 0000000..dfb4a3d
--- /dev/null
+++ b/client/src/components/Layout.tsx
@@ -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 (
+
+ );
+}
diff --git a/client/src/pages/Dictation.tsx b/client/src/pages/Dictation.tsx
new file mode 100644
index 0000000..a059cb3
--- /dev/null
+++ b/client/src/pages/Dictation.tsx
@@ -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('outpatient');
+ const [transcript, setTranscript] = useState('');
+ const [result, setResult] = useState(null);
+ const [validationError, setValidationError] = useState(null);
+
+ const generate = useMutation({
+ mutationFn: (body) => api.post('/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 (
+
+
+
Voice Dictation → HPI
+
+ Dictate your narrative → AI restructures into polished HPI.
+ Audio-capture UI is a follow-up; this minimal form supports typed/pasted transcripts.
+
+
+
+
+
+ {result && (
+
+
+
Generated HPI
+
+
+
{result}
+
+ )}
+
+ );
+}
diff --git a/client/src/pages/Faq.tsx b/client/src/pages/Faq.tsx
new file mode 100644
index 0000000..5d5589a
--- /dev/null
+++ b/client/src/pages/Faq.tsx
@@ -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 (
+