Compare commits
325 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1f45b3dcb | ||
|
|
9f7b87feb3 | ||
|
|
10dbabf83b | ||
|
|
61749a7881 | ||
|
|
e6d0e5ef8c | ||
|
|
1beabe40e4 | ||
|
|
83f3ac70d5 | ||
|
|
8d815ba09e | ||
|
|
cd1f762f34 | ||
|
|
88dd878492 | ||
|
|
990cbf97ac | ||
|
|
39d764e1ff | ||
|
|
1be74d453e | ||
|
|
7948cad6f1 | ||
|
|
47afe886e8 | ||
|
|
12eaf57ddb | ||
|
|
dc002360b0 | ||
|
|
8832659b16 | ||
|
|
9811e39cfe | ||
|
|
1037dc68b8 | ||
|
|
21facd4e1b | ||
|
|
8daa5c765d | ||
|
|
7371cbf13a | ||
|
|
57d6263daf | ||
|
|
941b73f9e5 | ||
|
|
f5c9202fe3 | ||
|
|
9aa3961459 | ||
|
|
1274391224 | ||
|
|
591257a0c7 | ||
|
|
d663526511 | ||
|
|
ec418da0d6 | ||
|
|
7acfefac1c | ||
|
|
95a5a5e40e | ||
|
|
fb51ba9cfe | ||
|
|
aafe7981dc | ||
|
|
8f612c60a9 | ||
|
|
18550e263f | ||
|
|
4250ea47fe | ||
|
|
22355e8cb2 | ||
|
|
c2a45b1d62 | ||
|
|
c5d2e7310f | ||
|
|
c1c2c3e717 | ||
|
|
43e8611b24 | ||
|
|
bdec116fa6 | ||
|
|
9ad34a7f35 | ||
|
|
ab7534b4f7 | ||
|
|
8d7bffb07a | ||
|
|
289db2bd6f | ||
|
|
719d0cb7f7 | ||
|
|
25fa628b83 | ||
|
|
520a1f8fb1 | ||
|
|
42d951b45e | ||
|
|
9dce93cf67 | ||
|
|
7c4ccd8ae6 | ||
|
|
efd3e32574 | ||
|
|
019622f5ec | ||
|
|
0f28f9212b | ||
|
|
d976a5928d | ||
|
|
d9fd4fcd69 | ||
|
|
f8abf6e171 | ||
|
|
552ead0901 | ||
|
|
7e97b04811 | ||
|
|
8d244a167a | ||
|
|
03cf4be075 | ||
|
|
3222dacc9a | ||
|
|
6d30edf88f | ||
|
|
6fa0d87da4 | ||
|
|
92a9a20a32 | ||
|
|
faaff64183 | ||
|
|
4434df89b2 | ||
|
|
87654b6005 | ||
|
|
004e36cd67 | ||
|
|
c1d61f5a72 | ||
|
|
a2fe1b38d1 | ||
|
|
b89d08e886 | ||
|
|
8e1046ab7a | ||
|
|
bc2580b148 | ||
|
|
3e3b4866be | ||
|
|
69dedbb635 | ||
|
|
5c1d1619c7 | ||
|
|
f4140d45c4 | ||
|
|
1431498fd6 | ||
|
|
8e1ab2fea3 | ||
|
|
c0cb66ae3e | ||
|
|
b9a3ed82b6 | ||
|
|
992bfac03b | ||
|
|
33826eb818 | ||
|
|
5ccce6e4d2 | ||
|
|
d20027f24f | ||
|
|
04762bded8 | ||
|
|
fe52ca0cf6 | ||
|
|
4f44a9e505 | ||
|
|
0148e567e3 | ||
|
|
3964147214 | ||
|
|
1011449f63 | ||
|
|
3ddd598d74 | ||
|
|
da9492a1ce | ||
|
|
12460d24ef | ||
|
|
2b403c9e46 | ||
|
|
4aa4ef0961 | ||
|
|
6ec9971621 | ||
|
|
466f02eb2e | ||
|
|
2392f4a089 | ||
|
|
132d321888 | ||
|
|
456b4d3232 | ||
|
|
b51cb1adb1 | ||
|
|
7f437b52a9 | ||
|
|
3a028e8f03 | ||
|
|
3fb1aa1e67 | ||
|
|
c159fe57b9 | ||
|
|
66ecc2246b | ||
|
|
27b1f6f089 | ||
|
|
46b3a2f678 | ||
|
|
958b35998e | ||
|
|
de5c75511b | ||
|
|
2456074481 | ||
|
|
71b717b533 | ||
|
|
601d35ef4c | ||
|
|
e1e5fbeabc | ||
|
|
da4dca06e1 | ||
|
|
0fa0c4846f | ||
|
|
09f8a1a3db | ||
|
|
ac0460b1fe | ||
|
|
9605262fe9 | ||
|
|
b5dbc98c75 | ||
|
|
86f16f914a | ||
|
|
9106644eb6 | ||
|
|
7a6758981f | ||
|
|
01b6c52e94 | ||
|
|
d26f8738eb | ||
|
|
359807b86e | ||
|
|
64731c21cd | ||
|
|
a63fd34edb | ||
|
|
031bfb995a | ||
|
|
2992ea1424 | ||
|
|
54285865d5 | ||
|
|
cd131e0b02 | ||
|
|
e54928f7f5 | ||
|
|
725e35bf96 | ||
|
|
201a51830c | ||
|
|
8097b0fe0b | ||
|
|
a76aead242 | ||
|
|
0ab48eeb98 | ||
|
|
d79d9eeded | ||
|
|
46b66a4507 | ||
|
|
30244276bf | ||
|
|
b64a39f8ea | ||
|
|
ce170f6fc1 | ||
|
|
5beb6cd562 | ||
|
|
4cb1080881 | ||
|
|
9a437c831c | ||
|
|
65a5dff9b4 | ||
|
|
0d6d91e8ef | ||
|
|
ed69fb0cc8 | ||
|
|
0b0bfc4a8a | ||
|
|
26857d52da | ||
|
|
ce466570ee | ||
|
|
c6d238c560 | ||
|
|
5439c1a742 | ||
|
|
6d6b4b90d2 | ||
|
|
0360685306 | ||
|
|
3b67d325fc | ||
|
|
2de10dc544 | ||
|
|
a125bf9e9c | ||
|
|
13eb968249 | ||
|
|
66ea127574 | ||
|
|
b03232c963 | ||
|
|
18811afbb5 | ||
|
|
7336e318be | ||
|
|
ab94239659 | ||
|
|
c98c571c66 | ||
|
|
907e131dc8 | ||
|
|
553449dbec | ||
|
|
d4546b7d02 | ||
|
|
f63d93807b | ||
|
|
ef6c90a889 | ||
|
|
09d07d7e0f | ||
|
|
87b2017919 | ||
|
|
c266ff2541 | ||
|
|
ec7e3d84b7 | ||
|
|
5888a9da0e | ||
|
|
ea03db3d45 | ||
|
|
63f77aa9cf | ||
|
|
8893e484fd | ||
|
|
dafbf44a32 | ||
|
|
a8992aee5a | ||
|
|
b5abbb69fc | ||
|
|
6febf6c914 | ||
|
|
37e58be5ec | ||
|
|
c7a04626a3 | ||
|
|
fc17032649 | ||
|
|
e161c221c4 | ||
|
|
6dffdf91e5 | ||
|
|
b294150781 | ||
|
|
cdf178b1c3 | ||
|
|
fa16cb13cb | ||
|
|
4a26abed10 | ||
|
|
d748dcc0d2 | ||
|
|
b23cb3300e | ||
|
|
9b407d1e18 | ||
|
|
e283bb8cda | ||
|
|
13e8937a00 | ||
|
|
5dde108e4a | ||
|
|
42984e355b | ||
|
|
3e05d8eec9 | ||
|
|
9bfadd7344 | ||
|
|
cb17a12172 | ||
|
|
93bc44b5e0 | ||
|
|
8409a49c74 | ||
|
|
942647871a | ||
|
|
369e440aa1 | ||
|
|
0c8a4db5c3 | ||
|
|
30300f169c | ||
|
|
5d988c397d | ||
|
|
e700ab1c8b | ||
|
|
6a690f6483 | ||
|
|
d29f55f8a6 | ||
|
|
04030b1ded | ||
|
|
bdf0916fe7 | ||
|
|
0630e460e8 | ||
|
|
64546a743d | ||
|
|
baa6362d29 | ||
|
|
7a957e856e | ||
|
|
f03ca5cb94 | ||
|
|
6978ed708c | ||
|
|
17a0371a0f | ||
|
|
7582e3563d | ||
|
|
d0d65446f6 | ||
|
|
aa33a55d0b | ||
|
|
d079c6d6c9 | ||
|
|
bf586daf4d | ||
|
|
ee88e51f14 | ||
|
|
4fbfc913d0 | ||
|
|
79994f4781 | ||
|
|
adf1365fa2 | ||
|
|
4fa2b58d75 | ||
|
|
04f3aa56cb | ||
|
|
020e831b3c | ||
|
|
a535ff6c15 | ||
|
|
a36235c646 | ||
|
|
f98b9b7b71 | ||
|
|
4fb038a745 | ||
|
|
215de4cac8 | ||
|
|
d86625c7e6 | ||
|
|
77eabbd4df | ||
|
|
970c946093 | ||
|
|
e459d34a13 | ||
|
|
b7adb4c3c7 | ||
|
|
a528bcc283 | ||
|
|
f95a03c13c | ||
|
|
0d33d3dce8 | ||
|
|
b8b9e8974b | ||
|
|
ca14094c0a | ||
|
|
b035f7d7b4 | ||
|
|
89daba420c | ||
|
|
7c27213451 | ||
|
|
cf4ba2a1e8 | ||
|
|
bbfe55f03b | ||
|
|
f18a87d0ff | ||
|
|
dd25d235d7 | ||
|
|
068cb258e9 | ||
|
|
d96a008dfe | ||
|
|
42daff2343 | ||
|
|
ef0f986c2f | ||
|
|
096d40f72d | ||
|
|
106e4baf17 | ||
|
|
0658b31df3 | ||
|
|
67c8638654 | ||
|
|
f126cf9fd7 | ||
|
|
2875e0cefd | ||
|
|
6db6a99eb2 | ||
|
|
ef80b75b6f | ||
|
|
f78f25e42f | ||
|
|
28fe1f520e | ||
|
|
f5ed67ccaf | ||
|
|
f2730bdc83 | ||
|
|
7e22902e47 | ||
|
|
d5d0ddcb95 | ||
|
|
6a7103a3f9 | ||
|
|
4808d08aa7 | ||
|
|
7a50bc061d | ||
|
|
63d8a881cb | ||
|
|
2423f4601e | ||
|
|
325575576c | ||
|
|
832fbc1283 | ||
|
|
d1138c8cc2 | ||
|
|
0ada98a13e | ||
|
|
38b1818148 | ||
|
|
1ab9878425 | ||
|
|
6f1bd97596 | ||
|
|
58c8f1c549 | ||
|
|
683afeea0b | ||
|
|
e4daa7590c | ||
|
|
9ec4cbf6b1 | ||
|
|
e9cab13c4f | ||
|
|
1371d705da | ||
|
|
364d564fca | ||
|
|
f609891d0d | ||
|
|
54c9aa1843 | ||
|
|
9e79a05676 | ||
|
|
268b6977cf | ||
|
|
72e91e940c | ||
|
|
35f03ac0ba | ||
|
|
28c3758eb6 | ||
|
|
cd3698f698 | ||
|
|
7d453094a0 | ||
|
|
88b8a5d418 | ||
|
|
ee60d269a5 | ||
|
|
007eef6887 | ||
|
|
044c809ff3 | ||
|
|
1191ba0d2d | ||
|
|
08a8fb26c4 | ||
|
|
5cad43d19a | ||
|
|
898036bfcd | ||
|
|
17646af5e3 | ||
|
|
25c462bfd6 | ||
|
|
6d1c2e5422 | ||
|
|
a53124a747 | ||
|
|
1f66b7c0e1 | ||
|
|
9758ecbea2 | ||
|
|
6e1b6ca3d7 | ||
|
|
eb63d9973d | ||
|
|
9eaec4f2de | ||
|
|
b997d6d388 | ||
|
|
7a2c569b63 |
136
.env.example
|
|
@ -1,3 +1,20 @@
|
|||
# ============================================================
|
||||
# OPENBAO (optional — recommended for production)
|
||||
# ============================================================
|
||||
# When these three are set, the container fetches everything else below
|
||||
# from OpenBao at kv/ped-ai/prod and ignores the equivalent .env values.
|
||||
# Leave them unset (or blank) to fall back to .env-only (local dev, e2e).
|
||||
#
|
||||
# OPENBAO_ADDR=https://app.danvics.com
|
||||
# OPENBAO_ROLE_ID=<from: bao read auth/approle/role/ped-ai/role-id>
|
||||
# OPENBAO_SECRET_ID=<from: bao write -f auth/approle/role/ped-ai/secret-id>
|
||||
# OPENBAO_KV_PATH=kv/ped-ai/prod # override path if needed
|
||||
|
||||
# ============================================================
|
||||
# Everything below is sourced from OpenBao when OPENBAO_ADDR is set.
|
||||
# Only fill these in for local dev / e2e / when running without vault.
|
||||
# ============================================================
|
||||
|
||||
# ============================================================
|
||||
# AI PROVIDER (choose one)
|
||||
# ============================================================
|
||||
|
|
@ -20,19 +37,91 @@ OPENROUTER_API_KEY=sk-or-v1-your-key
|
|||
# AZURE_DEPLOYMENT_NAME=gpt-4o-mini
|
||||
# AZURE_OPENAI_API_VERSION=2024-02-01
|
||||
|
||||
# Option 4: Google Vertex AI (HIPAA compliant with BAA)
|
||||
# AI_PROVIDER=vertex
|
||||
# GOOGLE_VERTEX_PROJECT=your-gcp-project-id
|
||||
# GOOGLE_VERTEX_LOCATION=us-central1
|
||||
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
|
||||
# (Or use default credentials if running on GCE/GKE/Cloud Run)
|
||||
#
|
||||
# Google STT — Gemini inline audio (auto-detected when GOOGLE_VERTEX_PROJECT set)
|
||||
# TRANSCRIBE_PROVIDER=google
|
||||
# GOOGLE_STT_MODEL=gemini-2.0-flash # or gemini-2.5-flash for better accuracy
|
||||
#
|
||||
# Google TTS — Google Cloud Text-to-Speech (auto-detected when GOOGLE_VERTEX_PROJECT set)
|
||||
# TTS_PROVIDER=google
|
||||
# GOOGLE_TTS_VOICE=en-US-Journey-F # female | en-US-Journey-D = male
|
||||
# Other options: en-US-Studio-O, en-US-Neural2-C, en-US-Neural2-J
|
||||
|
||||
# Option 5: LiteLLM Proxy (self-hosted, routes to any provider)
|
||||
# AI_PROVIDER=litellm
|
||||
# LITELLM_API_BASE=http://localhost:4000
|
||||
# LITELLM_API_KEY=sk-litellm-your-key
|
||||
# Admin can discover available models via the admin panel
|
||||
#
|
||||
# LiteLLM Speech-to-Text
|
||||
# TRANSCRIBE_PROVIDER=litellm
|
||||
# LITELLM_STT_MODEL=whisper-1 # Use the model name from your LiteLLM model_list
|
||||
# If your LiteLLM config uses full paths as model names, use the full path:
|
||||
# LITELLM_STT_MODEL=openai/whisper-1
|
||||
# NOTE: vertex_ai/chirp does NOT work via LiteLLM audio proxy.
|
||||
# For Vertex AI speech, use TRANSCRIBE_PROVIDER=google (Gemini inline audio).
|
||||
#
|
||||
# LiteLLM TTS
|
||||
# TTS_PROVIDER=litellm (auto-detected when LITELLM_API_BASE set)
|
||||
# LITELLM_TTS_MODEL=tts-1 # Use model name from your LiteLLM model_list
|
||||
# If your config uses full paths: LITELLM_TTS_MODEL=vertex_ai/google-tts
|
||||
# LITELLM_TTS_VOICE=en-US-Journey-F # Google Cloud voice name (or alloy/nova for OpenAI)
|
||||
|
||||
# ============================================================
|
||||
# Whisper (always OpenAI for now)
|
||||
# TRANSCRIPTION (speech-to-text)
|
||||
# ============================================================
|
||||
|
||||
# Option A: OpenAI Whisper (default if no AWS configured)
|
||||
OPENAI_API_KEY=sk-your-openai-key
|
||||
|
||||
# Option B: Amazon Transcribe (HIPAA eligible, no S3 needed)
|
||||
# Uses same AWS credentials as Bedrock above.
|
||||
# Set TRANSCRIBE_PROVIDER=aws to force AWS even if OPENAI_API_KEY is set.
|
||||
# Leave unset to auto-detect (uses AWS when AWS_BEDROCK_REGION is configured).
|
||||
# TRANSCRIBE_PROVIDER=aws
|
||||
|
||||
# Option C: Local Whisper (privacy-first, no cloud API needed)
|
||||
# Requires whisper.cpp or faster-whisper installed on the server.
|
||||
# TRANSCRIBE_PROVIDER=local
|
||||
# WHISPER_MODEL_SIZE=small # tiny, base, small, medium, large
|
||||
# WHISPER_BINARY=whisper-cpp # or: whisper, faster-whisper
|
||||
# WHISPER_MODEL_PATH= # custom path to .bin model file
|
||||
# WHISPER_LANGUAGE=en
|
||||
# WHISPER_THREADS=4 # defaults to CPU count - 1
|
||||
|
||||
# Amazon Transcribe Medical — better accuracy for clinical dictation
|
||||
# Knows drug names, diagnoses, procedures, SOAP terminology
|
||||
# HIPAA eligible (ensure your AWS account has a BAA)
|
||||
# AWS_TRANSCRIBE_MEDICAL=true
|
||||
# AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE
|
||||
# Other options: CARDIOLOGY, NEUROLOGY, ONCOLOGY, RADIOLOGY, UROLOGY
|
||||
|
||||
# Optional
|
||||
ELEVENLABS_API_KEY=
|
||||
|
||||
# Push Notifications (ntfy — self-hosted, optional)
|
||||
# NTFY_URL=https://ntfy.yourdomain.com
|
||||
# NTFY_TOKEN=tk_your_token_here
|
||||
|
||||
# App
|
||||
PORT=3000
|
||||
APP_URL=https://your-domain.com
|
||||
|
||||
# Cloudflare Turnstile (anti-bot on registration, optional)
|
||||
# TURNSTILE_SITE_KEY=your-site-key
|
||||
# TURNSTILE_SECRET_KEY=your-secret-key
|
||||
JWT_SECRET=generate-a-random-64-char-string-here
|
||||
SESSION_SECRET=generate-another-random-string-here
|
||||
|
||||
# Application-layer encryption key for PHI at rest (Nextcloud tokens, audio backups)
|
||||
# Generate with: openssl rand -hex 32
|
||||
# REQUIRED in production. Rotating invalidates existing encrypted data.
|
||||
DATA_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
|
||||
|
||||
# Email (for verification & password reset)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
|
|
@ -44,6 +133,49 @@ SMTP_FROM=noreply@yourdomain.com
|
|||
# Nextcloud (optional)
|
||||
NEXTCLOUD_URL=https://cloud.yourdomain.com
|
||||
|
||||
# S3 Document Storage (optional — works with AWS S3, Backblaze B2, MinIO)
|
||||
# S3_BUCKET=your-bucket-name
|
||||
# S3_REGION=us-east-1
|
||||
# S3_PREFIX=documents/
|
||||
#
|
||||
# For AWS S3: uses same AWS credentials as Bedrock above, or set S3-specific keys:
|
||||
# S3_ACCESS_KEY_ID=...
|
||||
# S3_SECRET_ACCESS_KEY=...
|
||||
#
|
||||
# For Backblaze B2:
|
||||
# S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com
|
||||
# S3_REGION=us-west-004
|
||||
# S3_ACCESS_KEY_ID=your-b2-application-key-id
|
||||
# S3_SECRET_ACCESS_KEY=your-b2-application-key
|
||||
#
|
||||
# For MinIO (self-hosted):
|
||||
# S3_ENDPOINT=http://minio:9000
|
||||
# S3_REGION=us-east-1
|
||||
# S3_ACCESS_KEY_ID=minio-access-key
|
||||
# S3_SECRET_ACCESS_KEY=minio-secret-key
|
||||
# S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# ============================================================
|
||||
# EMBEDDINGS (for Learning Hub semantic search)
|
||||
# ============================================================
|
||||
# Enables vector-based semantic search in Learning Hub
|
||||
# Requires pgvector extension: apt-get install postgresql-16-pgvector
|
||||
|
||||
# Default model (Vertex AI text-embedding-005, 768 dims, English + code optimized)
|
||||
EMBEDDING_MODEL=vertex_ai/text-embedding-005
|
||||
EMBEDDING_DIMENSIONS=768
|
||||
|
||||
# Other Vertex AI embedding models:
|
||||
# - vertex_ai/text-embedding-005 → 768 dims, English + code (recommended)
|
||||
# - vertex_ai/gemini-embedding-001 → up to 3072 dims, multilingual + code
|
||||
# - vertex_ai/text-multilingual-embedding-002 → 768 dims, multilingual focus
|
||||
#
|
||||
# LiteLLM usage (if using LiteLLM proxy):
|
||||
# EMBEDDING_MODEL=text-embedding-005 # LiteLLM will route to configured provider
|
||||
#
|
||||
# OpenAI fallback (NOT HIPAA-eligible):
|
||||
# Uses text-embedding-3-small if OPENAI_API_KEY is set and no Vertex/LiteLLM configured
|
||||
|
||||
# ============================================================
|
||||
# DATABASE
|
||||
# ============================================================
|
||||
|
|
|
|||
119
.github/workflows/android-release.yml
vendored
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
name: Build & release Android APK
|
||||
|
||||
# Fires whenever a semver tag is pushed (e.g. v6.1.1). Use
|
||||
# scripts/release.sh <version> --push from your laptop to mint the
|
||||
# tag; this workflow does everything downstream.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Manual tag to build (e.g. v6.1.1)'
|
||||
required: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||
|
||||
permissions:
|
||||
contents: write # needed to create GitHub releases from the runner
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build signed APK
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
if [[ -z "$TAG" || "$TAG" == "main" ]]; then
|
||||
TAG="${{ github.event.inputs.version }}"
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Set up Node 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: mobile/package-lock.json
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('mobile/android/**/*.gradle*', 'mobile/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Install Capacitor + sync
|
||||
working-directory: mobile
|
||||
run: |
|
||||
npm install --no-audit --no-fund
|
||||
npx cap sync android
|
||||
|
||||
- name: Restore keystore from secret
|
||||
env:
|
||||
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
echo "$KEYSTORE_B64" | base64 -d > $RUNNER_TEMP/pedscribe-release.jks
|
||||
ls -la $RUNNER_TEMP/pedscribe-release.jks
|
||||
|
||||
- name: Build signed release APK
|
||||
working-directory: mobile/android
|
||||
env:
|
||||
KS_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
./gradlew assembleRelease \
|
||||
-Pandroid.injected.signing.store.file=$RUNNER_TEMP/pedscribe-release.jks \
|
||||
-Pandroid.injected.signing.store.password="$KS_PASS" \
|
||||
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
|
||||
-Pandroid.injected.signing.key.password="$KEY_PASS" \
|
||||
--no-daemon --stacktrace
|
||||
|
||||
- name: Locate APK
|
||||
id: apk
|
||||
run: |
|
||||
APK=$(find mobile/android/app/build/outputs/apk/release -name '*.apk' | head -1)
|
||||
test -n "$APK" || { echo "no APK found"; exit 1; }
|
||||
echo "path=$APK" >> "$GITHUB_OUTPUT"
|
||||
echo "found: $APK ($(stat -c%s "$APK") bytes)"
|
||||
|
||||
- name: Rename APK with version
|
||||
id: rename
|
||||
run: |
|
||||
DST="pedscribe-${{ steps.tag.outputs.version }}.apk"
|
||||
cp "${{ steps.apk.outputs.path }}" "$DST"
|
||||
echo "path=$DST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
name: PedScribe ${{ steps.tag.outputs.version }}
|
||||
make_latest: 'true'
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
${{ steps.rename.outputs.path }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
148
.github/workflows/auto-version.yml
vendored
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
name: Auto version & release
|
||||
|
||||
# Fires on every push to main. Parses commit messages since the
|
||||
# last semver tag, decides patch/minor/major bump, creates the
|
||||
# tag, pushes. The tag push then triggers android-release.yml and
|
||||
# docker-publish.yml. Fully hands-off — you never pick a version
|
||||
# number; your commit messages do.
|
||||
#
|
||||
# Commit message grammar (Conventional Commits):
|
||||
# feat: → minor bump (new feature, backward-compatible)
|
||||
# fix: → patch bump (bug fix)
|
||||
# feat!: / BREAKING CHANGE in body → major bump
|
||||
# everything else (docs, refactor, chore, style, ci, test) → no bump
|
||||
#
|
||||
# Skip conditions (no new release created):
|
||||
# - No commits match the above patterns
|
||||
# - The most recent commit is itself a release commit ("Release v…")
|
||||
# - [skip ci] appears in any commit message since the last tag
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Opt in to Node 24 runtime early (deprecation of Node 20 begins 2026-06-02)
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, 'Release v') && !contains(github.event.head_commit.message, '[skip ci]')"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Use RELEASE_PAT (a Personal Access Token you add as a repo
|
||||
# secret) so the tag push this workflow performs actually
|
||||
# triggers the downstream tag-based workflows (android-release,
|
||||
# docker-publish). GITHUB_TOKEN pushes are deliberately
|
||||
# blocked from triggering other workflows by GitHub.
|
||||
# Fine-grained PAT with "Contents: Read and write" on this
|
||||
# repo is enough.
|
||||
token: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Find last semver tag
|
||||
id: last
|
||||
run: |
|
||||
LAST=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1)
|
||||
if [[ -z "$LAST" ]]; then
|
||||
LAST="v0.0.0"
|
||||
echo "no previous tag, starting from v0.0.0"
|
||||
fi
|
||||
echo "tag=$LAST"
|
||||
echo "tag=$LAST" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${LAST#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Decide bump type from commit messages
|
||||
id: decide
|
||||
env:
|
||||
LAST: ${{ steps.last.outputs.tag }}
|
||||
run: |
|
||||
# All commits from the last tag → HEAD (exclusive of tag commit)
|
||||
if [[ "$LAST" == "v0.0.0" ]]; then
|
||||
MSGS=$(git log --format='%s%n%b%n---')
|
||||
else
|
||||
MSGS=$(git log "${LAST}..HEAD" --format='%s%n%b%n---')
|
||||
fi
|
||||
|
||||
BUMP=none
|
||||
if echo "$MSGS" | grep -qE '(^|\n)(BREAKING CHANGE:|[a-z]+(\([^)]+\))?!:)'; then
|
||||
BUMP=major
|
||||
elif echo "$MSGS" | grep -qE '(^|\n)feat(\([^)]+\))?: '; then
|
||||
BUMP=minor
|
||||
elif echo "$MSGS" | grep -qE '(^|\n)fix(\([^)]+\))?: '; then
|
||||
BUMP=patch
|
||||
fi
|
||||
|
||||
echo "Bump type decided: $BUMP"
|
||||
echo "bump=$BUMP" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### Commits since $LAST"
|
||||
echo '```'
|
||||
if [[ "$LAST" == "v0.0.0" ]]; then
|
||||
git log --oneline | head -20
|
||||
else
|
||||
git log "${LAST}..HEAD" --oneline
|
||||
fi
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "**Bump decision**: \`$BUMP\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Stop if no release-worthy commits
|
||||
if: steps.decide.outputs.bump == 'none'
|
||||
run: |
|
||||
echo "No feat / fix / BREAKING commits since last tag — not cutting a release."
|
||||
echo "::notice::No release cut. Commit with 'feat:', 'fix:', or BREAKING CHANGE to trigger one."
|
||||
|
||||
- name: Compute next version
|
||||
id: next
|
||||
if: steps.decide.outputs.bump != 'none'
|
||||
env:
|
||||
CUR: ${{ steps.last.outputs.version }}
|
||||
BUMP: ${{ steps.decide.outputs.bump }}
|
||||
run: |
|
||||
IFS='.' read -r MAJ MIN PAT <<< "$CUR"
|
||||
case "$BUMP" in
|
||||
major) NEXT="$((MAJ+1)).0.0" ;;
|
||||
minor) NEXT="${MAJ}.$((MIN+1)).0" ;;
|
||||
patch) NEXT="${MAJ}.${MIN}.$((PAT+1))" ;;
|
||||
esac
|
||||
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
|
||||
echo "### Next version: v$NEXT" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Configure git
|
||||
if: steps.decide.outputs.bump != 'none'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Bump version strings + tag + push
|
||||
if: steps.decide.outputs.bump != 'none'
|
||||
env:
|
||||
V: ${{ steps.next.outputs.next }}
|
||||
run: |
|
||||
IFS='.' read -r MAJ MIN PAT <<< "$V"
|
||||
ANDROID_CODE=$(( MAJ * 100000 + MIN * 1000 + PAT ))
|
||||
|
||||
sed -i -E "0,/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]+(\")/ s//\1${V}\2/" package.json
|
||||
sed -i -E "0,/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]+(\")/ s//\1${V}\2/" mobile/package.json
|
||||
sed -i -E \
|
||||
-e "s/versionCode +[0-9]+/versionCode ${ANDROID_CODE}/" \
|
||||
-e "s/versionName +\"[^\"]+\"/versionName \"${V}\"/" \
|
||||
mobile/android/app/build.gradle
|
||||
|
||||
git add package.json mobile/package.json mobile/android/app/build.gradle
|
||||
git commit -m "Release v${V}"
|
||||
git tag -a "v${V}" -m "Release v${V}"
|
||||
|
||||
git push origin HEAD
|
||||
git push origin "v${V}"
|
||||
|
||||
echo "### Released v$V" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "android-release + docker-publish workflows will now run." >> "$GITHUB_STEP_SUMMARY"
|
||||
103
.github/workflows/build-apk.yml
vendored
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
name: Build TWA APK
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
app_url:
|
||||
description: 'App URL override (default: https://peds.danvics.com)'
|
||||
required: false
|
||||
|
||||
env:
|
||||
APP_URL: ${{ github.event.inputs.app_url || secrets.APP_URL || 'https://peds.danvics.com' }}
|
||||
|
||||
jobs:
|
||||
build-apk:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Generate Gradle wrapper
|
||||
working-directory: android
|
||||
run: |
|
||||
gradle wrapper --gradle-version=8.5
|
||||
|
||||
- name: Build APK
|
||||
working-directory: android
|
||||
run: |
|
||||
TWA_HOST=$(echo "${{ env.APP_URL }}" | sed 's|https://||;s|http://||;s|/.*||')
|
||||
./gradlew assembleRelease -PTWA_HOST="${TWA_HOST}"
|
||||
|
||||
- name: Sign APK
|
||||
if: success() && env.HAS_SIGNING_KEY == 'true'
|
||||
env:
|
||||
HAS_SIGNING_KEY: ${{ secrets.ANDROID_SIGNING_KEY != '' }}
|
||||
run: |
|
||||
# Decode signing key
|
||||
echo "${{ secrets.ANDROID_SIGNING_KEY }}" | base64 -d > /tmp/release.jks
|
||||
|
||||
# Find the latest build-tools version
|
||||
BUILD_TOOLS=$(ls -d $ANDROID_HOME/build-tools/*/ | sort -V | tail -1)
|
||||
echo "Using build-tools: $BUILD_TOOLS"
|
||||
|
||||
UNSIGNED=$(find android/app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||
echo "Signing: $UNSIGNED"
|
||||
|
||||
# Zipalign
|
||||
${BUILD_TOOLS}zipalign -v -p 4 "$UNSIGNED" /tmp/aligned.apk
|
||||
|
||||
# Sign with apksigner
|
||||
${BUILD_TOOLS}apksigner sign \
|
||||
--ks /tmp/release.jks \
|
||||
--ks-key-alias "${{ secrets.ANDROID_KEY_ALIAS }}" \
|
||||
--ks-pass "pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
|
||||
--key-pass "pass:${{ secrets.ANDROID_KEY_PASSWORD }}" \
|
||||
--out android/app/build/outputs/apk/release/PedScribe-v9-signed.apk \
|
||||
/tmp/aligned.apk
|
||||
|
||||
# Verify
|
||||
${BUILD_TOOLS}apksigner verify --print-certs android/app/build/outputs/apk/release/PedScribe-v9-signed.apk
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/release.jks /tmp/aligned.apk
|
||||
|
||||
- name: Upload APK to Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: android/app/build/outputs/apk/release/*.apk
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Upload artifact
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pediatric-scribe-apk
|
||||
path: android/app/build/outputs/apk/release/*.apk
|
||||
retention-days: 30
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### TWA APK Build" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Built for: ${{ env.APP_URL }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Install options:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Download from GitHub Releases" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Obtainium: add repo \`https://github.com/ifedan-ed/pediatric-ai-scribe-v3\`" >> $GITHUB_STEP_SUMMARY
|
||||
137
.github/workflows/docker-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
name: Build & Push Docker Image
|
||||
|
||||
# Multi-arch build using NATIVE runners for each platform, then a
|
||||
# manifest-list push. No QEMU emulation — amd64 builds on x86 runner,
|
||||
# arm64 builds on ubuntu-24.04-arm runner. argon2 and every other
|
||||
# native dep compile natively on their target arch.
|
||||
#
|
||||
# Result: `danielonyejesi/pediatric-ai-scribe-v3:X.Y.Z` (and :latest)
|
||||
# is one tag serving the correct variant to amd64 or arm64 hosts.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to publish (e.g. v6.2.0)'
|
||||
required: false
|
||||
default: 'latest'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||
IMAGE: danielonyejesi/pediatric-ai-scribe-v3
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Build one variant per matrix entry, push by digest only.
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker metadata (for labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build & push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
- name: Export digest for the merge job
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
DIG="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${DIG#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
# Combine the two single-platform digests into one multi-arch manifest
|
||||
# published under the real tags (vX.Y.Z and latest).
|
||||
name: Merge manifests
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ github.event.inputs.tag || 'latest' }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=raw,value=${{ steps.tag.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Create manifest list & push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.IMAGE }}@sha256:%s " *)
|
||||
|
||||
- name: Inspect final image
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### Multi-arch image published" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.IMAGE }}:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Platforms: linux/amd64, linux/arm64 (built on native runners)" >> $GITHUB_STEP_SUMMARY
|
||||
102
.github/workflows/version-bump.yml
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
name: Version bump & release
|
||||
|
||||
# Manual trigger — click "Run workflow" in the Actions tab, choose
|
||||
# patch / minor / major. The workflow computes the next semver,
|
||||
# updates package.json, mobile/package.json, and the Android
|
||||
# build.gradle, commits the change, tags it, and pushes — which
|
||||
# triggers the android-release and docker-publish workflows.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'Semver bump type'
|
||||
required: true
|
||||
type: choice
|
||||
default: patch
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
custom:
|
||||
description: 'Or exact version (e.g. 7.0.0) — overrides bump'
|
||||
required: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute next version
|
||||
id: v
|
||||
run: |
|
||||
CUR=$(grep -m1 '"version"' package.json | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
echo "current=$CUR"
|
||||
IFS='.' read -r MAJ MIN PAT <<< "$CUR"
|
||||
|
||||
if [[ -n "${{ github.event.inputs.custom }}" ]]; then
|
||||
NEXT="${{ github.event.inputs.custom }}"
|
||||
else
|
||||
case "${{ github.event.inputs.bump }}" in
|
||||
major) NEXT="$((MAJ+1)).0.0" ;;
|
||||
minor) NEXT="${MAJ}.$((MIN+1)).0" ;;
|
||||
patch) NEXT="${MAJ}.${MIN}.$((PAT+1))" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if ! [[ "$NEXT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::invalid version: $NEXT"; exit 1
|
||||
fi
|
||||
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
|
||||
echo "current=$CUR" >> "$GITHUB_OUTPUT"
|
||||
echo "### Version bump" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Current: $CUR" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Next: $NEXT" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Bump version strings
|
||||
env:
|
||||
V: ${{ steps.v.outputs.next }}
|
||||
run: |
|
||||
IFS='.' read -r MAJ MIN PAT <<< "$V"
|
||||
ANDROID_CODE=$(( MAJ * 100000 + MIN * 1000 + PAT ))
|
||||
|
||||
# package.json (top-level "version": "...")
|
||||
sed -i -E "0,/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]+(\")/ s//\1${V}\2/" package.json
|
||||
sed -i -E "0,/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]+(\")/ s//\1${V}\2/" mobile/package.json
|
||||
|
||||
# Android
|
||||
sed -i -E \
|
||||
-e "s/versionCode +[0-9]+/versionCode ${ANDROID_CODE}/" \
|
||||
-e "s/versionName +\"[^\"]+\"/versionName \"${V}\"/" \
|
||||
mobile/android/app/build.gradle
|
||||
|
||||
git diff --stat
|
||||
|
||||
- name: Commit, tag, push
|
||||
env:
|
||||
V: ${{ steps.v.outputs.next }}
|
||||
run: |
|
||||
git add package.json mobile/package.json mobile/android/app/build.gradle
|
||||
git commit -m "Release v${V}"
|
||||
git tag -a "v${V}" -m "Release v${V}"
|
||||
git push origin HEAD
|
||||
git push origin "v${V}"
|
||||
echo "### Pushed" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- tag: v${V}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- android-release + docker-publish workflows will now run" >> "$GITHUB_STEP_SUMMARY"
|
||||
26
.gitignore
vendored
|
|
@ -2,7 +2,8 @@ node_modules/
|
|||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
data/
|
||||
/data/
|
||||
!public/data/
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
|
|
@ -15,3 +16,26 @@ npm-debug.log*
|
|||
*.swp
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Android TWA
|
||||
android/.gradle/
|
||||
android/app/build/
|
||||
android/build/
|
||||
android/local.properties
|
||||
android/captures/
|
||||
android/.idea/
|
||||
*.apk
|
||||
*.aab
|
||||
*.keystore
|
||||
*.jks
|
||||
public/models/
|
||||
.env.backup-*
|
||||
*.env.backup*
|
||||
|
||||
# e2e test artifacts (keep config + specs, skip results + installed deps)
|
||||
e2e/node_modules/
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
|
||||
# Codex CLI marker
|
||||
.codex
|
||||
|
|
|
|||
22
.gitmessage
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# <type>: <short summary>
|
||||
#
|
||||
# Types that cut a release:
|
||||
# fix: → patch (6.1.1 → 6.1.2) bug fix
|
||||
# feat: → minor (6.1.1 → 6.2.0) new feature
|
||||
# feat!: → major (6.1.1 → 7.0.0) breaking change
|
||||
#
|
||||
# Types that commit but don't release:
|
||||
# docs: documentation
|
||||
# refactor: code reshape, no behavior change
|
||||
# chore: tooling, deps, housekeeping
|
||||
# test: tests only
|
||||
# style: formatting / whitespace
|
||||
# ci: CI/CD configuration
|
||||
# build: build system / external deps
|
||||
#
|
||||
# Full reference: https://www.conventionalcommits.org/
|
||||
# Or see CONTRIBUTING.md in this repo.
|
||||
#
|
||||
# ---- body below (optional) -------------------------------------------
|
||||
# Explain the WHY more than the what. Breaking changes must include a
|
||||
# line starting with "BREAKING CHANGE: <description>".
|
||||
8
.node-pg-migraterc.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"migrations-dir": "migrations",
|
||||
"migration-filename-format": "utc",
|
||||
"migration-file-language": "js",
|
||||
"migrations-table": "pgmigrations",
|
||||
"schema": "public",
|
||||
"verbose": true
|
||||
}
|
||||
174
BROWSER_WHISPER_SETUP.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# Browser Whisper Self-Hosted Setup
|
||||
|
||||
## Overview
|
||||
|
||||
As of v3, Browser Whisper is **fully self-hosted** with **zero CDN dependencies**. All models and libraries are bundled with the application and served from your own server.
|
||||
|
||||
## What Changed
|
||||
|
||||
**Before (v2 and earlier):**
|
||||
- Loaded transformers.js from `cdn.jsdelivr.net`
|
||||
- Downloaded models from `cdn-lfs.huggingface.co`
|
||||
- Failed in corporate/clinical networks with firewall restrictions
|
||||
|
||||
**Now (v3+):**
|
||||
- Transformers.js library (v2.6.2) bundled at `/models/transformers.min.js` (760KB)
|
||||
- Whisper model bundled at `/models/Xenova/whisper-tiny.en/` (42MB)
|
||||
- Everything served from your own server
|
||||
- **Works in any network environment** (firewalled, air-gapped, offline)
|
||||
|
||||
## Files Included
|
||||
|
||||
```
|
||||
public/models/
|
||||
├── transformers.min.js (760KB) - Transformers.js v2.6.2 (worker-compatible)
|
||||
└── Xenova/
|
||||
└── whisper-tiny.en/ (42MB total)
|
||||
├── config.json
|
||||
├── tokenizer.json
|
||||
├── preprocessor_config.json
|
||||
├── generation_config.json
|
||||
└── onnx/
|
||||
├── encoder_model_quantized.onnx
|
||||
└── decoder_model_merged_quantized.onnx
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Worker loads transformers.js locally:**
|
||||
```javascript
|
||||
importScripts('/models/transformers.min.js');
|
||||
```
|
||||
|
||||
2. **Transformers.js configured for local models:**
|
||||
```javascript
|
||||
T.env.localModelPath = '/models/';
|
||||
T.env.allowRemoteModels = false;
|
||||
```
|
||||
|
||||
3. **Models load from your server:**
|
||||
- Browser requests: `GET /models/Xenova/whisper-tiny.en/config.json`
|
||||
- Served by Express static middleware
|
||||
- No external network calls
|
||||
|
||||
## Docker Build
|
||||
|
||||
Models are downloaded **during Docker build** (not runtime):
|
||||
|
||||
```dockerfile
|
||||
RUN curl -sL -o onnx/encoder_model_quantized.onnx \
|
||||
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx
|
||||
```
|
||||
|
||||
This means:
|
||||
- Docker image is ~200MB larger (one-time cost)
|
||||
- Runtime has zero dependencies
|
||||
- Works in air-gapped environments (after image is pulled)
|
||||
|
||||
## Development Setup
|
||||
|
||||
If you're running locally (not Docker), download models:
|
||||
|
||||
```bash
|
||||
cd public/models
|
||||
mkdir -p Xenova/whisper-tiny.en/onnx
|
||||
|
||||
# Download transformers.js
|
||||
curl -L -o transformers.min.js \
|
||||
https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js
|
||||
|
||||
# Download model files
|
||||
cd Xenova/whisper-tiny.en
|
||||
curl -L -o config.json \
|
||||
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json
|
||||
curl -L -o tokenizer.json \
|
||||
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json
|
||||
curl -L -o preprocessor_config.json \
|
||||
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json
|
||||
curl -L -o generation_config.json \
|
||||
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json
|
||||
curl -L -o onnx/encoder_model_quantized.onnx \
|
||||
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx
|
||||
curl -L -o onnx/decoder_model_merged_quantized.onnx \
|
||||
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx
|
||||
```
|
||||
|
||||
Or use the helper script:
|
||||
|
||||
```bash
|
||||
./scripts/download-whisper-models.sh
|
||||
```
|
||||
|
||||
## Adding More Models
|
||||
|
||||
To add base or small models:
|
||||
|
||||
1. **Create directory:**
|
||||
```bash
|
||||
mkdir -p public/models/Xenova/whisper-base.en/onnx
|
||||
```
|
||||
|
||||
2. **Download from HuggingFace:**
|
||||
- https://huggingface.co/Xenova/whisper-base.en
|
||||
- https://huggingface.co/Xenova/whisper-small.en
|
||||
|
||||
3. **Update UI in `settings.html`:**
|
||||
```html
|
||||
<option value="Xenova/whisper-base.en">Base (~74MB, better quality)</option>
|
||||
```
|
||||
|
||||
4. **Update Dockerfile** to download during build
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Works everywhere** - No firewall/CDN issues
|
||||
✅ **Privacy-first** - Audio never leaves browser
|
||||
✅ **Offline capable** - After initial page load
|
||||
✅ **No API costs** - Zero transcription expenses
|
||||
✅ **Predictable** - Same model, same results
|
||||
✅ **Fast** - Local processing, no network latency
|
||||
|
||||
## Limitations
|
||||
|
||||
- Docker image is larger (~200MB vs ~150MB)
|
||||
- Only tiny model included by default (base/small optional)
|
||||
- Slower than cloud APIs for long recordings
|
||||
- Requires modern browser with WebAssembly support
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# 1. Start server
|
||||
docker-compose up -d
|
||||
|
||||
# 2. Open browser DevTools → Network tab
|
||||
# 3. Go to Settings → Browser Transcription
|
||||
# 4. Click "Pre-download model"
|
||||
# 5. Watch for requests to /models/* (should all be 200 OK from your server)
|
||||
# 6. NO requests to cdn.jsdelivr.net or huggingface.co
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Issue: "Failed to load transformers library"**
|
||||
- Check: `GET /models/transformers.min.js` returns 200 OK
|
||||
- Verify file exists: `ls public/models/transformers.min.js`
|
||||
|
||||
**Issue: "Model load failed"**
|
||||
- Check: `GET /models/Xenova/whisper-tiny.en/config.json` returns 200 OK
|
||||
- Verify files exist: `ls public/models/Xenova/whisper-tiny.en/`
|
||||
|
||||
**Issue: Still seeing CDN requests**
|
||||
- Clear browser cache (Ctrl+Shift+R)
|
||||
- Check you're running v18+ (`/api/health` should show version)
|
||||
|
||||
## Migration from v17
|
||||
|
||||
If upgrading from v17:
|
||||
|
||||
1. Pull new Docker image: `docker-compose pull`
|
||||
2. Restart: `docker-compose up -d`
|
||||
3. Clear browser cache
|
||||
4. Test: Settings → Browser Transcription → Pre-download
|
||||
|
||||
No configuration changes needed - it just works!
|
||||
240
BROWSER_WHISPER_TROUBLESHOOTING.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Browser Whisper Troubleshooting
|
||||
|
||||
## 🎙️ What is Browser Whisper?
|
||||
|
||||
Browser Whisper is an **optional** client-side transcription feature that runs entirely in your browser using WebAssembly. It provides:
|
||||
- ✅ Zero network transmission (HIPAA-safe)
|
||||
- ✅ No API costs
|
||||
- ✅ Works offline
|
||||
- ✅ Privacy-first (audio never leaves device)
|
||||
|
||||
**However**, it requires downloading AI models from CDN servers.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Common Issue: CDN Blocked
|
||||
|
||||
### Error Message:
|
||||
```
|
||||
NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope':
|
||||
The script at 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2' failed to load.
|
||||
```
|
||||
|
||||
### What This Means:
|
||||
Your network/firewall is blocking access to:
|
||||
- `cdn.jsdelivr.net` (JavaScript library CDN)
|
||||
- `cdn-lfs.huggingface.co` (AI model files)
|
||||
|
||||
### Why It Happens:
|
||||
1. **Corporate firewall** - Many organizations block CDN domains
|
||||
2. **Browser extensions** - Ad blockers, privacy tools may block CDN
|
||||
3. **Network proxy** - Company proxy might filter JavaScript CDN
|
||||
4. **CSP restrictions** - Very strict Content Security Policy
|
||||
|
||||
---
|
||||
|
||||
## ✅ Solutions
|
||||
|
||||
### Option 1: Use Server Transcription (Recommended)
|
||||
|
||||
**Browser Whisper is optional!** The app works perfectly fine with server-side transcription.
|
||||
|
||||
**Server transcription providers:**
|
||||
- Google Gemini (via Vertex AI) - HIPAA-eligible
|
||||
- AWS Transcribe - HIPAA-eligible
|
||||
- OpenAI Whisper - Fast, accurate
|
||||
- LiteLLM - Routes to any provider
|
||||
|
||||
**To use server transcription:**
|
||||
1. Go to Settings → Browser Transcription
|
||||
2. **Leave it disabled** (or if stuck, disable it)
|
||||
3. Record audio normally - will use server
|
||||
|
||||
**Advantages:**
|
||||
- More accurate (larger models)
|
||||
- No download needed
|
||||
- Works immediately
|
||||
- Professional grade
|
||||
|
||||
### Option 2: Whitelist CDN Domains
|
||||
|
||||
If you control your network/firewall, whitelist these domains:
|
||||
|
||||
```
|
||||
cdn.jsdelivr.net
|
||||
cdn-lfs.huggingface.co
|
||||
cdn-lfs-us-1.huggingface.co
|
||||
cdn-lfs-us-2.huggingface.co
|
||||
huggingface.co
|
||||
```
|
||||
|
||||
**For corporate IT:**
|
||||
- These are legitimate AI/JavaScript CDNs
|
||||
- Used by major companies worldwide
|
||||
- No security risk (public CDN content)
|
||||
- Required only for browser-based AI features
|
||||
|
||||
### Option 3: Disable Browser Extensions
|
||||
|
||||
Try disabling:
|
||||
- Ad blockers (uBlock Origin, AdBlock Plus)
|
||||
- Privacy extensions (Privacy Badger, Ghostery)
|
||||
- Script blockers (NoScript, ScriptSafe)
|
||||
|
||||
Then refresh and try again.
|
||||
|
||||
### Option 4: Try Different Browser
|
||||
|
||||
Some browsers have stricter security:
|
||||
- ✅ **Chrome** - Best compatibility
|
||||
- ✅ **Edge** - Works well
|
||||
- ⚠️ **Firefox** - May block CDN
|
||||
- ❌ **Safari** - Limited WebAssembly support
|
||||
|
||||
---
|
||||
|
||||
## 🧪 How to Test If It's Working
|
||||
|
||||
### Test 1: Check CDN Access
|
||||
```bash
|
||||
# From your computer, run:
|
||||
curl -I https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2
|
||||
|
||||
# Should return: HTTP/2 200
|
||||
# If 403 or timeout: CDN is blocked
|
||||
```
|
||||
|
||||
### Test 2: Browser Console
|
||||
1. Open DevTools (F12)
|
||||
2. Go to Console tab
|
||||
3. Settings → Browser Transcription
|
||||
4. Click "Pre-download model"
|
||||
5. Watch for:
|
||||
```
|
||||
✅ [WhisperWorker] Transformers library loaded successfully
|
||||
OR
|
||||
❌ NetworkError: Failed to load
|
||||
```
|
||||
|
||||
### Test 3: Network Tab
|
||||
1. Open DevTools (F12)
|
||||
2. Go to Network tab
|
||||
3. Click "Pre-download model"
|
||||
4. Look for requests to:
|
||||
- `cdn.jsdelivr.net` (should be 200 OK)
|
||||
- `cdn-lfs.huggingface.co` (should be 200 OK)
|
||||
5. If blocked: Status will show "failed" or "blocked"
|
||||
|
||||
---
|
||||
|
||||
## 📊 When to Use Each Option
|
||||
|
||||
| Scenario | Recommendation | Why |
|
||||
|----------|---------------|-----|
|
||||
| Corporate network | **Server transcription** | CDN likely blocked |
|
||||
| Home network | **Browser Whisper** | Fast, free, private |
|
||||
| Mobile device | **Server transcription** | Limited storage/memory |
|
||||
| Offline use needed | **Browser Whisper** | Works without internet (after initial download) |
|
||||
| High accuracy needed | **Server transcription** | Larger models available |
|
||||
| Maximum privacy | **Browser Whisper** | Audio never leaves device |
|
||||
| Can't access CDN | **Server transcription** | No choice - CDN blocked |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
### What Gets Downloaded (First Time Only):
|
||||
|
||||
**Tiny model** (~39 MB):
|
||||
- onnx-runtime.wasm (~10 MB)
|
||||
- whisper-tiny.en model files (~29 MB)
|
||||
- Cached in browser IndexedDB (permanent)
|
||||
|
||||
**Base model** (~74 MB):
|
||||
- Larger model, better accuracy
|
||||
|
||||
**Small model** (~244 MB):
|
||||
- Best quality, slower processing
|
||||
|
||||
### Where It's Stored:
|
||||
- **Location:** Browser IndexedDB
|
||||
- **Persistence:** Permanent (until you clear browser data)
|
||||
- **Shared:** Across all tabs/windows for this domain
|
||||
- **Size:** Selected model size (39/74/244 MB)
|
||||
|
||||
### Performance:
|
||||
- **Tiny:** 2-3 seconds per 30-second clip
|
||||
- **Base:** 3-5 seconds per 30-second clip
|
||||
- **Small:** 6-10 seconds per 30-second clip
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Q: Is Browser Whisper required?**
|
||||
A: No! It's completely optional. Server transcription works great.
|
||||
|
||||
**Q: Why doesn't it work on my corporate network?**
|
||||
A: Most corporate firewalls block CDN domains for security. Use server transcription instead.
|
||||
|
||||
**Q: Can I download the models manually?**
|
||||
A: Not easily - they're optimized for CDN delivery. Use server transcription if CDN is blocked.
|
||||
|
||||
**Q: Will server transcription cost money?**
|
||||
A: Depends on your provider:
|
||||
- Google Vertex AI: ~$0.005 per minute
|
||||
- AWS Transcribe: ~$0.024 per minute
|
||||
- OpenAI: $0.006 per minute
|
||||
- Very affordable for typical use
|
||||
|
||||
**Q: Is server transcription HIPAA-safe?**
|
||||
A: Yes, if using:
|
||||
- Google Vertex AI (with BAA)
|
||||
- AWS Transcribe (with BAA)
|
||||
- Azure OpenAI (with BAA)
|
||||
|
||||
OpenAI Whisper direct is NOT HIPAA-eligible.
|
||||
|
||||
**Q: Can I use both?**
|
||||
A: Yes! Enable Browser Whisper in Settings. If it fails (CDN blocked), it automatically falls back to server transcription.
|
||||
|
||||
**Q: How do I know which one is being used?**
|
||||
A: Check the toast notification after recording:
|
||||
- "Transcribed locally" = Browser Whisper
|
||||
- "Transcribed via google-gemini/aws/openai" = Server
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Recommended Setup
|
||||
|
||||
### For Maximum Privacy (Home Network):
|
||||
1. Enable Browser Whisper
|
||||
2. Choose "Tiny" model (fast, good enough for dictation)
|
||||
3. Pre-download model
|
||||
4. Use offline
|
||||
|
||||
### For Corporate/Clinical Use:
|
||||
1. Keep Browser Whisper **disabled**
|
||||
2. Configure server transcription:
|
||||
```bash
|
||||
# In .env:
|
||||
TRANSCRIBE_PROVIDER=google
|
||||
GOOGLE_VERTEX_PROJECT=your-project
|
||||
```
|
||||
3. Use with BAA for HIPAA compliance
|
||||
|
||||
### For Best Accuracy:
|
||||
1. Use server transcription
|
||||
2. Configure Google Gemini 2.0 Flash or AWS Transcribe Medical
|
||||
3. Audio quality + large models = best results
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Still Having Issues?
|
||||
|
||||
1. **Check console logs:** DevTools → Console → Look for `[BrowserWhisper]` errors
|
||||
2. **Check network logs:** DevTools → Network → Filter by `jsdelivr` or `huggingface`
|
||||
3. **Verify server transcription works:** Just disable Browser Whisper and record
|
||||
4. **Contact IT:** Ask to whitelist CDN domains (if you need Browser Whisper)
|
||||
|
||||
**Remember:** Browser Whisper is a nice-to-have feature. Server transcription is the primary, production-ready method that works everywhere!
|
||||
54
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Contributing
|
||||
|
||||
<!-- Pipeline verified 2026-04-15: auto-version + PAT + multi-arch docker -->
|
||||
|
||||
## Commit format
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org). `.github/workflows/auto-version.yml`
|
||||
parses messages since the last semver tag and decides whether to bump.
|
||||
|
||||
| Prefix | Bump | |
|
||||
|---|---|---|
|
||||
| `fix:` | patch | bug fix |
|
||||
| `feat:` | minor | new feature |
|
||||
| `feat!:` / `fix!:` / `BREAKING CHANGE:` in body | major | breaking change |
|
||||
| `docs:` `refactor:` `chore:` `test:` `style:` `ci:` `build:` | none | no release |
|
||||
|
||||
Append `[skip ci]` to suppress the run for that commit.
|
||||
|
||||
## Manual release
|
||||
|
||||
```bash
|
||||
scripts/release.sh 6.2.0 --push # local
|
||||
```
|
||||
|
||||
or Actions tab → **Version bump & release** → Run workflow → pick bump type.
|
||||
|
||||
## What a tag push triggers
|
||||
|
||||
| Workflow | Output |
|
||||
|---|---|
|
||||
| `android-release.yml` | signed APK on GitHub release, `make_latest=true` |
|
||||
| `docker-publish.yml` | `danielonyejesi/pediatric-ai-scribe-v3:{version,latest}` on Docker Hub (amd64) |
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
docker compose up -d # Postgres + app
|
||||
docker logs -f pediatric-ai-scribe
|
||||
```
|
||||
|
||||
Web changes hot-reload via browser refresh (JS/CSS cached 1h — add `?v=` query
|
||||
or clear cache; the build-ID server-side cache-buster appends `?v=<git SHA>`
|
||||
automatically on fresh page loads).
|
||||
|
||||
Server code changes require `docker compose build pediatric-scribe && docker compose up -d`.
|
||||
|
||||
## Mobile
|
||||
|
||||
See `docs/mobile-build.md`.
|
||||
|
||||
## DB migrations
|
||||
|
||||
`src/db/database.js` is the baseline (idempotent CREATE-IF-NOT-EXISTS). New
|
||||
changes go in `migrations/` via `node-pg-migrate`. See `docs/migrations.md`.
|
||||
43
Dockerfile
|
|
@ -1,18 +1,59 @@
|
|||
# ─── OpenBao CLI, copied from upstream image (multi-arch automatic) ───
|
||||
# Update the tag here to adopt a newer OpenBao. Binary is statically linked,
|
||||
# safe to drop into the Node alpine image as-is.
|
||||
FROM openbao/openbao:2.5.3 AS bao-src
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ffmpeg: audio conversion for AWS Transcribe (WebM → PCM)
|
||||
# curl: download Whisper models for browser-based transcription
|
||||
# jq: JSON parsing for the entrypoint's OpenBao secret-fetch step
|
||||
RUN apk add --no-cache ffmpeg curl jq
|
||||
|
||||
# Pull the bao CLI out of the upstream image — matches host arch because
|
||||
# buildx pulls the right manifest-list variant per build.
|
||||
COPY --from=bao-src /bin/bao /usr/local/bin/bao
|
||||
RUN /usr/local/bin/bao version
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
# argon2 compiles native code via node-gyp — needs python3/make/g++ at build time
|
||||
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
|
||||
&& npm install --omit=dev \
|
||||
&& apk del .build-deps
|
||||
|
||||
COPY . .
|
||||
|
||||
# Ensure the entrypoint is executable regardless of host file permissions
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p /app/data/logs
|
||||
|
||||
# Download Browser Whisper (COMPLETE self-hosting - zero CDN dependencies)
|
||||
# Library + Models all bundled and served from our server
|
||||
RUN mkdir -p /app/public/models/Xenova/whisper-tiny.en/onnx && \
|
||||
cd /app/public/models && \
|
||||
echo "Downloading transformers.js library (worker-compatible build)..." && \
|
||||
curl -sL -o transformers.min.js https://cdn.jsdelivr.net/npm/@xenova/transformers@2.0.0/dist/transformers.min.js && \
|
||||
cd Xenova/whisper-tiny.en && \
|
||||
echo "Downloading Whisper model files..." && \
|
||||
curl -sL -o config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json && \
|
||||
curl -sL -o tokenizer.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json && \
|
||||
curl -sL -o preprocessor_config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json && \
|
||||
curl -sL -o generation_config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json && \
|
||||
curl -sL -o onnx/encoder_model_quantized.onnx https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx && \
|
||||
curl -sL -o onnx/decoder_model_merged_quantized.onnx https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx && \
|
||||
echo "✅ Browser Whisper: 100% self-hosted (library: 760KB, models: 42MB)"
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Entrypoint wrapper handles optional OpenBao secret fetch before exec'ing CMD.
|
||||
# See docker-entrypoint.sh for the logic — it is a no-op if OPENBAO_ADDR is
|
||||
# unset, so legacy .env-only deployments continue to work unchanged.
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
|
|
|
|||
268
EMBEDDINGS_SETUP.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# Embeddings & Semantic Search Setup
|
||||
|
||||
This guide explains how to set up and use the new vector-based semantic search for the Learning Hub.
|
||||
|
||||
## 🎯 What's New
|
||||
|
||||
- **Semantic search** - Find content by meaning, not just keywords
|
||||
- **3 search modes**:
|
||||
- **Keyword** (`/api/learning/search`) - Traditional text matching
|
||||
- **Semantic** (`/api/learning/search/semantic`) - AI-powered vector similarity
|
||||
- **Hybrid** (`/api/learning/search/hybrid`) - Combines both for best results
|
||||
- **Auto-embedding** - Content is automatically vectorized when created/updated
|
||||
- **HIPAA-compliant** - Uses Vertex AI embeddings (BAA available)
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
### 1. Install pgvector Extension
|
||||
|
||||
The database needs the `pgvector` extension for vector operations:
|
||||
|
||||
```bash
|
||||
# For PostgreSQL 16 on Ubuntu/Debian
|
||||
sudo apt-get install postgresql-16-pgvector
|
||||
|
||||
# For PostgreSQL 15
|
||||
sudo apt-get install postgresql-15-pgvector
|
||||
|
||||
# For Docker (add to Dockerfile or docker-compose)
|
||||
# The postgres:16-alpine base image doesn't include pgvector by default
|
||||
# You'll need to use a custom image or install at runtime
|
||||
```
|
||||
|
||||
**For Docker deployments**, use this postgres image instead:
|
||||
```yaml
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
# ... rest of your config
|
||||
```
|
||||
|
||||
### 2. Configure Embedding Provider
|
||||
|
||||
Add to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Option 1: Vertex AI (HIPAA-eligible, recommended)
|
||||
EMBEDDING_MODEL=vertex_ai/text-embedding-005
|
||||
EMBEDDING_DIMENSIONS=768
|
||||
VERTEX_PROJECT=your-gcp-project-id
|
||||
VERTEX_LOCATION=us-central1
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
|
||||
|
||||
# Option 2: LiteLLM Proxy (routes to any provider)
|
||||
LITELLM_API_BASE=http://localhost:4000
|
||||
LITELLM_API_KEY=your-key
|
||||
EMBEDDING_MODEL=text-embedding-005 # LiteLLM will route to configured provider
|
||||
|
||||
# Option 3: OpenAI (NOT HIPAA-eligible, fallback only)
|
||||
OPENAI_API_KEY=sk-your-key
|
||||
# Uses text-embedding-3-small automatically
|
||||
```
|
||||
|
||||
## 🚀 Available Vertex AI Embedding Models
|
||||
|
||||
Tested and working via LiteLLM:
|
||||
|
||||
| Model | Dimensions | Use Case | HIPAA |
|
||||
|-------|-----------|----------|-------|
|
||||
| **vertex_ai/text-embedding-005** | 768 | English + code (recommended) | ✅ Yes |
|
||||
| **vertex_ai/gemini-embedding-001** | 768-3072 | Multilingual + code, best quality | ✅ Yes |
|
||||
| **vertex_ai/text-multilingual-embedding-002** | 768 | Multilingual focus | ✅ Yes |
|
||||
|
||||
## 🔧 Setup Steps
|
||||
|
||||
### 1. Database Migration
|
||||
|
||||
The database will automatically:
|
||||
- Enable the `pgvector` extension
|
||||
- Add `embedding vector(768)` column to `learning_content`
|
||||
- Create IVFFLAT index for fast similarity search (after 10+ embeddings)
|
||||
|
||||
Just restart your server after installing pgvector.
|
||||
|
||||
### 2. Generate Embeddings for Existing Content
|
||||
|
||||
Two options:
|
||||
|
||||
**Option A: Admin API (recommended)**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/admin/learning/embeddings/generate \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"regenerateAll": false}'
|
||||
```
|
||||
|
||||
**Option B: Via Admin Panel**
|
||||
- Go to Admin → Learning Hub → Settings
|
||||
- Click "Generate Embeddings" button
|
||||
- Check status at `/api/admin/learning/embeddings/status`
|
||||
|
||||
### 3. Verify Setup
|
||||
|
||||
Check embedding status:
|
||||
```bash
|
||||
curl http://localhost:3000/api/admin/learning/embeddings/status \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"enabled": true,
|
||||
"total": 50,
|
||||
"withEmbeddings": 50,
|
||||
"missing": 0,
|
||||
"model": "vertex_ai/text-embedding-005",
|
||||
"dimensions": 768
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 Using Semantic Search
|
||||
|
||||
### Keyword Search (existing)
|
||||
```bash
|
||||
GET /api/learning/search?q=pneumonia
|
||||
```
|
||||
Returns exact text matches in title/subject/body.
|
||||
|
||||
### Semantic Search (new)
|
||||
```bash
|
||||
GET /api/learning/search/semantic?q=childhood breathing problems&limit=10&threshold=0.5
|
||||
```
|
||||
Returns content similar by **meaning** (e.g., finds "pediatric asthma" articles).
|
||||
|
||||
**Parameters:**
|
||||
- `q` (required) - Search query
|
||||
- `limit` (optional, default 10, max 50) - Max results
|
||||
- `threshold` (optional, default 0.5) - Similarity threshold (0-1, higher = more similar)
|
||||
- `contentType` (optional) - Filter by type: article, quiz, pearl, presentation
|
||||
|
||||
### Hybrid Search (recommended)
|
||||
```bash
|
||||
GET /api/learning/search/hybrid?q=fever management
|
||||
```
|
||||
Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance.
|
||||
|
||||
## 🔬 How It Works
|
||||
|
||||
1. **Content Creation/Update**:
|
||||
- Text is extracted from `title`, `subject`, and `body` (HTML stripped)
|
||||
- Sent to embedding model (Vertex AI)
|
||||
- Returns 768-dimensional vector
|
||||
- Stored in `learning_content.embedding` column
|
||||
|
||||
2. **Semantic Search**:
|
||||
- Query text → embedding vector
|
||||
- PostgreSQL pgvector computes cosine similarity
|
||||
- Returns top N most similar documents
|
||||
- Similarity score 0-1 (1 = identical, 0 = unrelated)
|
||||
|
||||
3. **Hybrid Search**:
|
||||
- Runs both keyword + semantic searches in parallel
|
||||
- Merges results (semantic first for quality)
|
||||
- Deduplicates by content ID
|
||||
- Sorts by relevance score
|
||||
|
||||
## 💰 Cost Estimate (Vertex AI)
|
||||
|
||||
**Titan Text Embeddings (AWS) pricing:**
|
||||
- ~$0.10 per 1M tokens
|
||||
- Average article: 2,000 words (~2,700 tokens) = $0.00027
|
||||
- 1,000 articles: ~**$0.27 one-time**
|
||||
- Search queries: ~500 tokens = $0.00005 per query
|
||||
|
||||
**Google Vertex AI pricing:**
|
||||
- text-embedding-005: $0.025 per 1M characters
|
||||
- Average article: 10,000 chars = $0.00025
|
||||
- 1,000 articles: ~**$0.25 one-time**
|
||||
- Search queries: ~$0.0000125 per query
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "pgvector extension not available"
|
||||
- Install: `apt-get install postgresql-16-pgvector`
|
||||
- For Docker: Use `pgvector/pgvector:pg16` image
|
||||
|
||||
### "Embeddings not configured"
|
||||
- Verify `.env` has `VERTEX_PROJECT` or `LITELLM_API_BASE` or `OPENAI_API_KEY`
|
||||
- Check service account credentials: `GOOGLE_APPLICATION_CREDENTIALS`
|
||||
- Test: `curl http://localhost:3000/api/admin/learning/embeddings/status`
|
||||
|
||||
### "Embedding generation failed"
|
||||
- Check logs for API errors
|
||||
- Verify Vertex AI API is enabled in GCP
|
||||
- Verify service account has `aiplatform.endpoints.predict` permission
|
||||
- Check content isn't empty (skips empty bodies)
|
||||
|
||||
### "No results from semantic search"
|
||||
- Check if embeddings exist: `/api/admin/learning/embeddings/status`
|
||||
- Lower threshold: `?threshold=0.3` (default 0.5)
|
||||
- Verify pgvector index exists: `\di` in psql
|
||||
|
||||
## 📊 Performance
|
||||
|
||||
- **Embedding generation**: ~500ms per article (Vertex AI)
|
||||
- **Search latency**:
|
||||
- Keyword: 10-50ms
|
||||
- Semantic: 20-100ms (with IVFFLAT index)
|
||||
- Hybrid: 30-150ms
|
||||
- **Index build time**: ~1-5 seconds per 1,000 articles
|
||||
|
||||
## 🔐 Security & Compliance
|
||||
|
||||
- **HIPAA-eligible**: Vertex AI supports BAA (Business Associate Agreement)
|
||||
- **Data retention**: Embeddings stored in your database only
|
||||
- **No PHI**: Only article content (not patient data) is embedded
|
||||
- **Encryption**: TLS in transit, at-rest encryption via PostgreSQL
|
||||
|
||||
## 🎓 Example Queries
|
||||
|
||||
**Before (keyword):**
|
||||
```
|
||||
Query: "fever in babies"
|
||||
Results: Only articles with exact words "fever" or "babies"
|
||||
```
|
||||
|
||||
**After (semantic):**
|
||||
```
|
||||
Query: "fever in babies"
|
||||
Results:
|
||||
- Infant hyperthermia management (similarity: 0.89)
|
||||
- Pediatric fever evaluation (similarity: 0.87)
|
||||
- Febrile seizures in toddlers (similarity: 0.82)
|
||||
- Neonatal temperature regulation (similarity: 0.78)
|
||||
```
|
||||
|
||||
**Hybrid (best):**
|
||||
```
|
||||
Query: "asthma"
|
||||
Results:
|
||||
- Childhood asthma management (keyword + semantic: 1.0)
|
||||
- Pediatric breathing difficulties (semantic: 0.91)
|
||||
- Reactive airway disease (semantic: 0.86)
|
||||
- Bronchiolitis vs asthma (keyword: 1.0)
|
||||
```
|
||||
|
||||
## 📚 API Reference
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
- `POST /api/admin/learning/embeddings/generate` - Backfill embeddings
|
||||
- `GET /api/admin/learning/embeddings/status` - Check status
|
||||
- `GET /api/admin/learning/stats` - Includes embedding count
|
||||
|
||||
### User Endpoints
|
||||
|
||||
- `GET /api/learning/search` - Keyword search
|
||||
- `GET /api/learning/search/semantic` - Semantic search
|
||||
- `GET /api/learning/search/hybrid` - Hybrid search (recommended)
|
||||
|
||||
All endpoints require authentication (JWT token).
|
||||
|
||||
---
|
||||
|
||||
**Questions?** Check logs for detailed error messages, or review the code in:
|
||||
- `/src/utils/embeddings.js` - Core embedding logic
|
||||
- `/src/routes/learningHub.js` - Search endpoints
|
||||
- `/src/routes/learningAdmin.js` - Admin management
|
||||
347
FEATURES_EXPLAINED.md
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
# Features Explained - Pediatric AI Scribe v14
|
||||
|
||||
## 🎙️ **Audio Backups**
|
||||
|
||||
### How It Works:
|
||||
Audio backups happen **automatically every time you record**, regardless of transcription success/failure.
|
||||
|
||||
**Flow:**
|
||||
1. You press "Stop" on recording
|
||||
2. Audio is immediately saved **before** transcription starts
|
||||
3. Server-side backup (PostgreSQL, gzip compressed) attempted first
|
||||
4. If server fails → fallback to browser IndexedDB
|
||||
5. After successful transcription → audio backup is deleted
|
||||
6. If transcription fails → audio backup remains for retry
|
||||
|
||||
**Location:**
|
||||
- Server: PostgreSQL `audio_backups` table (auto-deleted after 24 hours)
|
||||
- Browser: IndexedDB `PedScribeAudioBackup` database (manual cleanup)
|
||||
|
||||
**Purpose:**
|
||||
- Retry transcription if it fails
|
||||
- Recover audio if browser crashes
|
||||
- Audit trail (24 hour retention)
|
||||
|
||||
**Access:**
|
||||
Settings → Audio Backups section shows:
|
||||
- Date/time of recording
|
||||
- Module (encounter, dictation, etc.)
|
||||
- File size
|
||||
- "Retry Transcription" button (if transcription failed)
|
||||
- "Delete" button
|
||||
|
||||
**Cost:**
|
||||
Server backups are compressed (gzip) to ~1/10 original size. A 2MB recording becomes ~200KB in database.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **S3 Document Storage**
|
||||
|
||||
### How It Works:
|
||||
Upload documents (PDFs, images, Word docs, text files) to S3-compatible storage.
|
||||
|
||||
**Supported Providers:**
|
||||
- AWS S3 (default)
|
||||
- Backblaze B2
|
||||
- MinIO (self-hosted)
|
||||
- Any S3-compatible service
|
||||
|
||||
**Configuration (.env):**
|
||||
```bash
|
||||
# AWS S3 (uses Bedrock credentials if available)
|
||||
S3_BUCKET=your-bucket-name
|
||||
S3_REGION=us-east-1
|
||||
S3_PREFIX=documents/ # Optional: folder prefix
|
||||
|
||||
# Backblaze B2
|
||||
S3_BUCKET=your-bucket-name
|
||||
S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com
|
||||
S3_REGION=us-west-004
|
||||
S3_ACCESS_KEY_ID=your-b2-application-key-id
|
||||
S3_SECRET_ACCESS_KEY=your-b2-application-key
|
||||
|
||||
# MinIO (self-hosted)
|
||||
S3_BUCKET=your-bucket
|
||||
S3_ENDPOINT=http://minio:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY_ID=minio-access-key
|
||||
S3_SECRET_ACCESS_KEY=minio-secret-key
|
||||
S3_FORCE_PATH_STYLE=true # Required for MinIO
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- ✅ 10 MB file size limit
|
||||
- ✅ AES-256 server-side encryption
|
||||
- ✅ Per-user folder organization (`documents/{userId}/{uuid}/filename`)
|
||||
- ✅ Metadata stored in PostgreSQL (filename, mime type, size, description)
|
||||
- ✅ Presigned URLs for secure access (1 hour expiry)
|
||||
|
||||
**Allowed File Types:**
|
||||
- PDF (`.pdf`)
|
||||
- Images (`.jpg`, `.jpeg`, `.png`, `.gif`)
|
||||
- Word documents (`.doc`, `.docx`)
|
||||
- Text files (`.txt`, `.csv`)
|
||||
|
||||
**Access:**
|
||||
Settings → Documents section
|
||||
|
||||
**Status Check:**
|
||||
If S3 is not configured, the Documents section shows empty with message: "S3 not configured"
|
||||
|
||||
---
|
||||
|
||||
## 📚 **Learning Hub - Default Browse Path**
|
||||
|
||||
### What It Is:
|
||||
A user preference that sets the **starting folder** when browsing Nextcloud files for AI content generation.
|
||||
|
||||
### When It's Used:
|
||||
Only in the **Learning Hub AI Content Generator** (Admin/Moderator feature).
|
||||
|
||||
**Scenario:**
|
||||
1. Admin/Moderator wants to create AI-generated learning content
|
||||
2. They choose "Upload from Nextcloud"
|
||||
3. File browser opens
|
||||
4. Instead of starting at root `/`, it opens at the configured path
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Default path: /Medical-Resources
|
||||
↓
|
||||
When you click "Browse Nextcloud", it opens:
|
||||
/Medical-Resources/
|
||||
├── Pediatric-Guidelines/
|
||||
├── Clinical-Protocols/
|
||||
└── Research-Papers/
|
||||
|
||||
Instead of:
|
||||
/
|
||||
├── Personal/
|
||||
├── Photos/
|
||||
├── Medical-Resources/ ← you'd have to navigate here every time
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
Settings → Nextcloud Integration → "Learning Hub — Default Browse Path"
|
||||
|
||||
**Examples:**
|
||||
- `/Medical-Resources` - Opens in Medical Resources folder
|
||||
- `/Shared/Clinical-Content` - Opens in shared clinical content
|
||||
- `/` (empty) - Opens at root (default behavior)
|
||||
|
||||
**Who Can Use This:**
|
||||
- Any authenticated user (not just moderators)
|
||||
- It's a personal preference per user
|
||||
- Only affects Learning Hub AI file picker
|
||||
|
||||
**Why This Exists:**
|
||||
If you store learning resources in a specific Nextcloud folder, you don't want to navigate there every single time you generate content. Set it once, it remembers.
|
||||
|
||||
---
|
||||
|
||||
## 🎤 **Browser Whisper Pre-Download**
|
||||
|
||||
### Issue You Reported:
|
||||
"Pre-download models works, stuck at starting download"
|
||||
|
||||
### What's Happening:
|
||||
The download **is actually working** but progress updates are slow because:
|
||||
1. HuggingFace CDN serves large files (39-244 MB)
|
||||
2. Progress callbacks are not granular (reported per-file, not per-chunk)
|
||||
3. Initial ONNX runtime download has no progress tracking
|
||||
|
||||
### Fixed:
|
||||
- ✅ Added console logging to track progress
|
||||
- ✅ Added 30-second timeout warning (doesn't stop download)
|
||||
- ✅ Better error messages
|
||||
|
||||
### How to Test:
|
||||
1. Open browser DevTools (F12) → Console tab
|
||||
2. Click "Pre-download model"
|
||||
3. Watch console for progress logs:
|
||||
```
|
||||
[BrowserWhisper] Starting preload...
|
||||
[BrowserWhisper] Progress: onnx-runtime 0%
|
||||
[BrowserWhisper] Progress: model.bin 23%
|
||||
[BrowserWhisper] Progress: model.bin 47%
|
||||
...
|
||||
[BrowserWhisper] Progress: 100%
|
||||
```
|
||||
|
||||
### Expected Download Times:
|
||||
- **Tiny** (39 MB): 5-15 seconds (fast connection)
|
||||
- **Base** (74 MB): 10-30 seconds
|
||||
- **Small** (244 MB): 30-90 seconds
|
||||
|
||||
### If Still Stuck:
|
||||
**Check these:**
|
||||
1. Open DevTools → Network tab
|
||||
2. Filter by "HuggingFace"
|
||||
3. Look for downloads from `cdn-lfs-us-1.huggingface.co`
|
||||
4. Check if files are actually downloading
|
||||
|
||||
**Common issues:**
|
||||
- Slow internet connection (244 MB takes time!)
|
||||
- Corporate firewall blocking HuggingFace CDN
|
||||
- Browser IndexedDB quota exceeded
|
||||
|
||||
**Workaround:**
|
||||
Just enable it and record audio - the model will download on first use (same as pre-download, but triggered automatically).
|
||||
|
||||
---
|
||||
|
||||
## 🔊 **TTS Voice Preview**
|
||||
|
||||
### Issue You Reported:
|
||||
"Preview button next to TTS seems to do nothing"
|
||||
|
||||
### Fixed:
|
||||
- ✅ Added error logging to console
|
||||
- ✅ Better validation (checks for empty selection)
|
||||
- ✅ Clear user feedback messages
|
||||
|
||||
### How to Use:
|
||||
1. Go to Settings → Voice Preferences
|
||||
2. Select a voice from "Text-to-Speech Voice" dropdown
|
||||
3. Click "Preview" button
|
||||
4. Wait 2-3 seconds
|
||||
5. Audio should play automatically
|
||||
|
||||
### If Nothing Happens:
|
||||
**Check browser console for errors:**
|
||||
- Open DevTools (F12) → Console tab
|
||||
- Click Preview
|
||||
- Look for `[VoicePrefs] Preview error:` message
|
||||
|
||||
**Common issues:**
|
||||
1. **No voice selected** → Select from dropdown first
|
||||
2. **TTS not configured** → Check `.env` has `GOOGLE_VERTEX_PROJECT` or `LITELLM_API_BASE`
|
||||
3. **Network error** → Check server logs for TTS API errors
|
||||
4. **Browser autoplay policy** → Some browsers block autoplay, click page first
|
||||
|
||||
### Testing Checklist:
|
||||
```bash
|
||||
# 1. Check TTS is configured
|
||||
curl http://localhost:3000/api/health | grep tts
|
||||
|
||||
# 2. Test TTS endpoint directly
|
||||
curl -X POST http://localhost:3000/api/text-to-speech \
|
||||
-H "Authorization: Bearer YOUR_JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text":"Test"}' \
|
||||
--output test.mp3
|
||||
|
||||
# 3. Play the audio file
|
||||
mpg123 test.mp3 # or open in browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 **Summary of User Settings**
|
||||
|
||||
### Voice Preferences
|
||||
**Location:** Settings → Voice Preferences (top section)
|
||||
|
||||
| Setting | Options | Default | Purpose |
|
||||
|---------|---------|---------|---------|
|
||||
| **STT Model** | gemini-2.0-flash-exp, gemini-2.0-flash, gemini-1.5-flash, gemini-1.5-pro, whisper-1 | Server default | Controls transcription accuracy |
|
||||
| **TTS Voice** | Journey-F/D, Studio-O/M, Neural2 series, alloy, echo, fable, onyx, nova, shimmer | Server default | Controls read-aloud voice |
|
||||
|
||||
### Browser Whisper
|
||||
**Location:** Settings → Browser Transcription (Local Whisper)
|
||||
|
||||
| Setting | Options | Default | Purpose |
|
||||
|---------|---------|---------|---------|
|
||||
| **Enable** | On/Off | Off | Local transcription (HIPAA-safe) |
|
||||
| **Model** | Tiny, Base, Small | Tiny | Accuracy vs speed tradeoff |
|
||||
|
||||
### Nextcloud
|
||||
**Location:** Settings → Nextcloud Integration
|
||||
|
||||
| Setting | Purpose |
|
||||
|---------|---------|
|
||||
| **Nextcloud URL** | Your Nextcloud instance |
|
||||
| **Username** | Nextcloud username |
|
||||
| **App Password** | Generate in Nextcloud → Security |
|
||||
| **Default Browse Path** | Starting folder for Learning Hub AI picker |
|
||||
|
||||
### Documents (S3)
|
||||
**Location:** Settings → Documents
|
||||
|
||||
Shows list of uploaded documents if S3 is configured. Upload limit: 10 MB per file.
|
||||
|
||||
### Audio Backups
|
||||
**Location:** Settings → Audio Backups
|
||||
|
||||
Shows last 24 hours of recordings. Can retry transcription or delete.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **Troubleshooting Guide**
|
||||
|
||||
### Pre-Download Stuck
|
||||
1. ✅ Open browser console (F12)
|
||||
2. ✅ Look for `[BrowserWhisper] Progress:` logs
|
||||
3. ✅ Check Network tab for HuggingFace downloads
|
||||
4. ✅ Wait - 244 MB takes time!
|
||||
5. ✅ If truly stuck (no network activity): refresh page, try again
|
||||
|
||||
### Preview Button Silent
|
||||
1. ✅ Check voice is selected in dropdown
|
||||
2. ✅ Open console for error messages
|
||||
3. ✅ Test TTS endpoint directly (curl command above)
|
||||
4. ✅ Check server logs for TTS provider errors
|
||||
5. ✅ Verify `.env` has TTS provider configured
|
||||
|
||||
### S3 Not Working
|
||||
1. ✅ Check `.env` has `S3_BUCKET` set
|
||||
2. ✅ Verify credentials: `S3_ACCESS_KEY_ID` + `S3_SECRET_ACCESS_KEY`
|
||||
3. ✅ Test bucket access from server:
|
||||
```bash
|
||||
aws s3 ls s3://your-bucket/ --region us-east-1
|
||||
```
|
||||
4. ✅ Check server logs for S3 errors when uploading
|
||||
|
||||
### Audio Backups Not Showing
|
||||
1. ✅ Record audio first (they're created on recording, not transcription)
|
||||
2. ✅ Check database: `SELECT COUNT(*) FROM audio_backups;`
|
||||
3. ✅ Verify IndexedDB in browser: DevTools → Application → IndexedDB → `PedScribeAudioBackup`
|
||||
4. ✅ Backups auto-delete after 24 hours
|
||||
|
||||
### Learning Hub Path Not Working
|
||||
1. ✅ This only affects **AI content generator file picker**
|
||||
2. ✅ It does NOT affect manual Nextcloud document browsing
|
||||
3. ✅ Path must exist in your Nextcloud
|
||||
4. ✅ Path format: `/Folder/Subfolder` (starts with `/`)
|
||||
|
||||
---
|
||||
|
||||
## 📊 **Feature Status Matrix**
|
||||
|
||||
| Feature | Status | Config Required | HIPAA-Safe | Notes |
|
||||
|---------|--------|-----------------|------------|-------|
|
||||
| **Audio Backups** | ✅ Working | None (auto) | ✅ Yes | Server + IndexedDB |
|
||||
| **S3 Documents** | ✅ Working | S3_BUCKET | ✅ Yes (AWS) | Optional feature |
|
||||
| **Browser Whisper** | ✅ Working | None (optional) | ✅ Yes | Client-side only |
|
||||
| **Voice Preferences** | ✅ Working | Provider config | Depends | Google/AWS = yes |
|
||||
| **Learning Hub Path** | ✅ Working | Nextcloud config | ✅ Yes | User preference |
|
||||
| **TTS Preview** | ✅ Fixed | TTS provider | Depends | Check logs if fails |
|
||||
| **Embeddings** | ✅ Working | Vertex/LiteLLM | ✅ Yes | Requires pgvector |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **Next Steps**
|
||||
|
||||
1. **Push v14 to Docker** (in progress via GitHub Actions)
|
||||
2. **Test features after deployment**
|
||||
3. **Check browser console for any errors**
|
||||
4. **Verify TTS preview works with your provider**
|
||||
5. **Test browser whisper download with different models**
|
||||
|
||||
---
|
||||
|
||||
**Questions? Check the logs:**
|
||||
- Browser: F12 → Console tab
|
||||
- Server: `docker logs pediatric-ai-scribe -f`
|
||||
- Database: `psql -d pedscribe -c "SELECT COUNT(*) FROM audio_backups;"`
|
||||
188
IMPROVEMENTS.md
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# Pediatric AI Scribe — Improvement Roadmap
|
||||
|
||||
A non-technical overview of what the app does today and how it can be taken further.
|
||||
|
||||
---
|
||||
|
||||
## What the App Does Today
|
||||
|
||||
Pediatric AI Scribe is a clinical documentation tool for pediatric physicians. It listens to doctor-patient encounters (or accepts typed/pasted notes) and uses AI to generate structured medical notes — HPIs, SOAP notes, hospital courses, chart reviews, well visit and sick visit documentation.
|
||||
|
||||
It also includes pediatric calculators (blood pressure percentiles, BMI, growth charts, bilirubin nomograms, vital signs reference), a Learning Hub for educational content and quizzes, and a full security layer (two-factor authentication, session management, audit logging, single sign-on).
|
||||
|
||||
The app runs as a self-hosted web application with a mobile-friendly PWA interface.
|
||||
|
||||
---
|
||||
|
||||
## Areas for Improvement
|
||||
|
||||
### 1. Visual Growth Charts
|
||||
|
||||
**Current state:** Growth percentiles are displayed as numbers (e.g., "75th percentile, Z-score 0.67").
|
||||
|
||||
**Improvement:** Plot actual WHO/CDC percentile curves (the familiar growth chart lines pediatricians use) with the patient's data point shown on the chart. This would make results immediately interpretable at a glance, matching the paper charts physicians are trained on. Support for plotting multiple visits over time would make it even more useful for tracking growth trends.
|
||||
|
||||
### 2. Blood Pressure Calculator Accuracy
|
||||
|
||||
**Current state:** The BP calculator uses simplified reference values at the 50th height percentile only.
|
||||
|
||||
**Improvement:** Implement the full Rosner quantile spline regression method (the same math used by the Baylor College of Medicine reference calculator). This would give exact BP percentiles adjusted for the patient's actual height, not just an approximation. The regression coefficients are publicly available and can be integrated directly.
|
||||
|
||||
### 3. Multi-Visit Tracking
|
||||
|
||||
**Current state:** Each encounter is independent. There is no way to see a patient's history across visits.
|
||||
|
||||
**Improvement:** Allow physicians to associate notes with a patient identifier (MRN, initials, or a pseudonym) and view previous encounters for that patient. This would enable:
|
||||
- Growth tracking over time (plot multiple points on growth curves)
|
||||
- Trend monitoring (weight gain/loss, blood pressure trends)
|
||||
- Quick access to past notes during follow-up visits
|
||||
|
||||
This would need careful design around data retention and privacy since it changes the app from a transient tool to one that stores longitudinal data.
|
||||
|
||||
### 4. EHR Integration
|
||||
|
||||
**Current state:** Notes are copied manually and pasted into the EHR.
|
||||
|
||||
**Improvement:** Direct integration with common EHR systems:
|
||||
- **FHIR API** — connect to Epic, Cerner, or other FHIR-enabled EHRs to push notes directly into the patient chart
|
||||
- **HL7 messaging** — for institutions using traditional interfaces
|
||||
- **Smart on FHIR** — launch the app from within the EHR as an embedded tool
|
||||
|
||||
This is the highest-impact improvement for adoption but also the most complex to implement (requires EHR vendor partnerships and institutional approval).
|
||||
|
||||
### 5. Offline Mode
|
||||
|
||||
**Current state:** The app requires an internet connection for AI generation and cloud-based transcription. Browser Whisper works offline for transcription only.
|
||||
|
||||
**Improvement:** Add a local AI model option (e.g., a small medical LLM running on the device or local server) so the entire workflow — record, transcribe, generate note — can happen without any network calls. This would be valuable for:
|
||||
- Rural clinics with unreliable internet
|
||||
- Maximum privacy (no data leaves the building)
|
||||
- Disaster/field medicine scenarios
|
||||
|
||||
### 6. Specialty Expansion
|
||||
|
||||
**Current state:** Focused on general pediatrics with some subspecialty support in chart review.
|
||||
|
||||
**Improvement:** Add specialty-specific note templates and AI prompts for:
|
||||
- Pediatric cardiology (echo reports, cath summaries)
|
||||
- Pediatric neurology (EEG reports, seizure logs)
|
||||
- Neonatology (daily progress notes, discharge summaries)
|
||||
- Pediatric surgery (operative notes, pre-op assessments)
|
||||
- Pediatric psychiatry (intake assessments, progress notes)
|
||||
|
||||
Each specialty has unique documentation requirements that could be addressed with tailored prompts and input forms.
|
||||
|
||||
### 7. Billing Code Suggestions
|
||||
|
||||
**Current state:** The well visit tab includes some billing code references.
|
||||
|
||||
**Improvement:** Automatically suggest ICD-10 and CPT codes based on the generated note content. After the AI generates a note, it could analyze the diagnoses, procedures, and visit complexity to suggest appropriate billing codes. This saves time on coding and reduces missed charges.
|
||||
|
||||
### 8. Quality Metrics Dashboard
|
||||
|
||||
**Current state:** Admin panel shows basic usage statistics (total API calls, users).
|
||||
|
||||
**Improvement:** Add a dashboard showing:
|
||||
- Average note generation time by type
|
||||
- Most-used AI models and their accuracy (based on how often users edit the output)
|
||||
- Transcription accuracy metrics (if corrections are tracked)
|
||||
- Usage patterns by time of day and day of week
|
||||
- Cost tracking across AI providers
|
||||
|
||||
This would help administrators optimize model selection and identify training opportunities.
|
||||
|
||||
### 9. Patient Education Materials
|
||||
|
||||
**Current state:** The Learning Hub serves educational content to physicians.
|
||||
|
||||
**Improvement:** Add a patient-facing education module that generates age-appropriate handouts based on the diagnosis. For example, after generating a note for a child with asthma, the app could produce a parent-friendly handout explaining the diagnosis, medications, and when to seek emergency care — in the parent's preferred language.
|
||||
|
||||
### 10. Multi-Language Support
|
||||
|
||||
**Current state:** English only.
|
||||
|
||||
**Improvement:** Add support for:
|
||||
- Generating notes in other languages (Spanish, French, Arabic, etc.)
|
||||
- Transcribing encounters conducted in other languages
|
||||
- Patient education materials in the family's language
|
||||
- UI translation for non-English-speaking staff
|
||||
|
||||
Medical Spanish alone would significantly expand the app's reach in the United States.
|
||||
|
||||
### 11. Voice Commands During Recording
|
||||
|
||||
**Current state:** Recording is continuous — the physician presses start and stop.
|
||||
|
||||
**Improvement:** Add voice command recognition during recording:
|
||||
- "New section" — marks a section break in the transcript
|
||||
- "Off the record" — pauses transcription temporarily (for sidebar conversations)
|
||||
- "Add diagnosis: [condition]" — tags a diagnosis without typing
|
||||
- "Skip" — ignores the last segment
|
||||
|
||||
This would make the recording workflow more natural and reduce post-generation editing.
|
||||
|
||||
### 12. Collaborative Notes
|
||||
|
||||
**Current state:** Single-user editing. Notes are created and edited by one physician.
|
||||
|
||||
**Improvement:** Allow multiple team members to work on the same encounter:
|
||||
- Attending reviews and co-signs a resident's note
|
||||
- Nurse adds vital signs and chief complaint before the physician sees the patient
|
||||
- Specialist adds their consultation note to the same encounter
|
||||
|
||||
This mirrors the real workflow in training institutions and group practices.
|
||||
|
||||
### 13. Mobile-Optimized Recording
|
||||
|
||||
**Current state:** Recording works on mobile but stops when the screen locks or the app is backgrounded (browser limitation).
|
||||
|
||||
**Improvement:** Build a native mobile wrapper (using Capacitor or React Native) that can record audio in the background even when the screen is off. This is the single biggest usability improvement for mobile users and removes the most common complaint.
|
||||
|
||||
### 14. Template Library
|
||||
|
||||
**Current state:** Physician memories and corrections provide some personalization.
|
||||
|
||||
**Improvement:** Add a shared template library where physicians can create, share, and browse note templates:
|
||||
- "My asthma follow-up template"
|
||||
- "Standard newborn discharge summary"
|
||||
- "ED laceration repair template"
|
||||
- Import/export templates between institutions
|
||||
|
||||
### 15. Audit and Compliance Reporting
|
||||
|
||||
**Current state:** Audit logs exist in the database but there is no reporting UI.
|
||||
|
||||
**Improvement:** Add an admin-facing compliance dashboard:
|
||||
- Who accessed what, when (filterable by user, date, action)
|
||||
- Export audit logs to CSV/PDF for compliance reviews
|
||||
- Automated alerts for unusual access patterns
|
||||
- HIPAA compliance checklist with green/red status indicators
|
||||
- BAA tracking (which providers have signed BAAs)
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendations
|
||||
|
||||
If resources are limited, focus on these high-impact improvements first:
|
||||
|
||||
| Priority | Improvement | Impact | Effort |
|
||||
|----------|-------------|--------|--------|
|
||||
| 1 | Visual growth charts | High — physicians expect visual curves | Medium |
|
||||
| 2 | Accurate BP calculator | High — clinical accuracy matters | Medium |
|
||||
| 3 | Billing code suggestions | High — direct revenue impact | Medium |
|
||||
| 4 | Multi-language support | High — expands reach significantly | Large |
|
||||
| 5 | Audit/compliance reporting | Medium — required for institutional adoption | Small |
|
||||
| 6 | EHR integration (FHIR) | Very high — but requires partnerships | Very large |
|
||||
|
||||
---
|
||||
|
||||
## What Makes This App Unique
|
||||
|
||||
Compared to existing medical scribes and documentation tools:
|
||||
|
||||
- **Pediatric-specific** — prompts, calculators, milestones, and growth charts designed for children, not adapted from adult tools
|
||||
- **Self-hosted** — runs on your own infrastructure, not a SaaS that holds your data
|
||||
- **Provider-agnostic** — works with any AI provider (swap between them without changing anything)
|
||||
- **Privacy-first** — optional fully offline transcription, auto-expiring data, no permanent PHI storage
|
||||
- **Learning system** — AI improves its output based on each physician's editing patterns
|
||||
- **All-in-one** — documentation, calculators, education, and administration in a single platform
|
||||
346
OPENID_SETUP.md
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
# OpenID Connect (OIDC) / PocketID Setup Guide
|
||||
|
||||
This guide explains how to configure Single Sign-On (SSO) authentication using OpenID Connect providers like PocketID, Keycloak, Azure AD, Okta, or Google.
|
||||
|
||||
## Overview
|
||||
|
||||
The application supports OIDC authentication alongside traditional email/password login. Once configured, users can:
|
||||
|
||||
- Sign in with their SSO provider (e.g., PocketID)
|
||||
- Automatically link existing email accounts to their SSO identity
|
||||
- Admins can optionally disable local password login entirely
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. An OpenID Connect provider (e.g., PocketID instance)
|
||||
2. Admin access to this application
|
||||
3. The public URL where your app is deployed (`APP_URL` in `.env`)
|
||||
|
||||
---
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
### 1. Configure Your Identity Provider
|
||||
|
||||
First, register this application with your OIDC provider. You'll need:
|
||||
|
||||
**Redirect URI / Callback URL:**
|
||||
```
|
||||
https://your-domain.com/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
Replace `your-domain.com` with your actual `APP_URL` value.
|
||||
|
||||
**Example: PocketID Setup**
|
||||
|
||||
1. Log into your PocketID admin panel
|
||||
2. Navigate to **Applications** → **Add Application**
|
||||
3. Set the callback URL: `https://your-domain.com/api/auth/oidc/callback`
|
||||
4. Copy the Client ID and Client Secret
|
||||
|
||||
**Example: Keycloak Setup**
|
||||
|
||||
1. Create a new client in your Keycloak realm
|
||||
2. Set **Access Type** to `confidential`
|
||||
3. Add Valid Redirect URI: `https://your-domain.com/api/auth/oidc/callback`
|
||||
4. Save and note the Client ID and Client Secret from the Credentials tab
|
||||
|
||||
### 2. Enable OIDC in Application Settings
|
||||
|
||||
Log into your application as an **admin** user, then:
|
||||
|
||||
1. Navigate to **Admin Panel** → **Settings** (or access `/admin-settings.html`)
|
||||
2. Look for the **OpenID Connect (SSO)** section
|
||||
3. Fill in the following fields:
|
||||
|
||||
| Field | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| **Enabled** | Toggle to enable OIDC | `true` |
|
||||
| **Issuer URL** | Your provider's discovery endpoint | `https://id.example.com` or `https://keycloak.example.com/realms/myrealm` |
|
||||
| **Client ID** | Application client ID from your provider | `pediatric-scribe-client` |
|
||||
| **Client Secret** | Application client secret (keep confidential) | `a1b2c3d4...` |
|
||||
| **Button Label** | Text shown on the SSO login button | `Sign in with PocketID` |
|
||||
| **Disable Local Auth** | Hide email/password login (optional) | `false` (keep disabled initially) |
|
||||
| **Allowed IPs** | Restrict SSO to specific IP ranges (optional) | Leave blank for no restriction |
|
||||
|
||||
4. Click **Save Settings**
|
||||
|
||||
### 3. Test SSO Login
|
||||
|
||||
1. Log out or open an incognito browser window
|
||||
2. Visit the login page
|
||||
3. You should see a new button: **"Sign in with [Your Provider]"**
|
||||
4. Click it and authenticate with your SSO provider
|
||||
5. You'll be redirected back to the application and logged in
|
||||
|
||||
---
|
||||
|
||||
## Linking Existing Users to SSO
|
||||
|
||||
When a user signs in via OIDC for the first time, the system automatically links their account based on **email address matching**:
|
||||
|
||||
### Scenario 1: Existing User with Matching Email
|
||||
|
||||
If a user already has an account with email `doctor@example.com` and signs in via SSO with the same email:
|
||||
|
||||
1. The system finds the existing user by email
|
||||
2. Links the SSO identity (`oidc_sub`) to the existing account
|
||||
3. The user is logged in
|
||||
4. Future logins can use either method (email/password OR SSO)
|
||||
|
||||
**Database update performed:**
|
||||
```sql
|
||||
UPDATE users
|
||||
SET oidc_sub = '<provider-unique-id>',
|
||||
email_verified = true
|
||||
WHERE email = 'doctor@example.com';
|
||||
```
|
||||
|
||||
### Scenario 2: New User (No Matching Email)
|
||||
|
||||
If the SSO email doesn't match any existing user:
|
||||
|
||||
1. A new account is automatically created
|
||||
2. The user is assigned the `user` role (first user becomes `admin`)
|
||||
3. A random password is generated (not used for SSO logins)
|
||||
4. The user is logged in
|
||||
|
||||
### Scenario 3: Disabled User
|
||||
|
||||
If an existing user is disabled (`disabled = true` in database):
|
||||
|
||||
- SSO login is blocked
|
||||
- User sees an error message
|
||||
- Admin must re-enable the account from the Admin Panel
|
||||
|
||||
---
|
||||
|
||||
## Manual Account Linking (CLI)
|
||||
|
||||
If you need to manually link an existing user to an SSO identity, use the PostgreSQL database directly:
|
||||
|
||||
```bash
|
||||
# Connect to database
|
||||
docker exec -it pediatric-ai-scribe-postgres psql -U pedscribe -d pedscribe
|
||||
|
||||
# Link user by setting their oidc_sub
|
||||
UPDATE users
|
||||
SET oidc_sub = 'provider-sub-12345',
|
||||
email_verified = true
|
||||
WHERE email = 'doctor@example.com';
|
||||
```
|
||||
|
||||
**Finding the `oidc_sub` value:**
|
||||
|
||||
The `oidc_sub` is the unique identifier from your OIDC provider (usually a UUID or numeric ID). To find it:
|
||||
|
||||
1. Have the user attempt SSO login once
|
||||
2. Check the application logs for their `sub` claim:
|
||||
```
|
||||
[OIDC] User logged in: sub=abc-123-def, email=doctor@example.com
|
||||
```
|
||||
3. Use that `sub` value in the UPDATE statement
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### HTTPS Required in Production
|
||||
|
||||
OIDC requires HTTPS for security. Ensure your `APP_URL` uses `https://`:
|
||||
|
||||
```env
|
||||
APP_URL=https://scribe.example.com
|
||||
```
|
||||
|
||||
### Client Secret Protection
|
||||
|
||||
The client secret is stored encrypted in the database. The admin UI masks it after saving (shows `••••••••1234`).
|
||||
|
||||
**Never commit the client secret to Git or share it publicly.**
|
||||
|
||||
### IP Allowlisting (Optional)
|
||||
|
||||
To restrict SSO to specific networks (e.g., hospital VPN):
|
||||
|
||||
1. Set **Allowed IPs** in admin settings to comma-separated CIDR ranges:
|
||||
```
|
||||
10.0.0.0/8, 192.168.1.0/24
|
||||
```
|
||||
2. Users outside these ranges will see an error when attempting SSO
|
||||
|
||||
### Disable Local Password Login
|
||||
|
||||
Once SSO is working, you can optionally disable traditional email/password login:
|
||||
|
||||
1. In Admin Settings, enable **Disable Local Auth**
|
||||
2. The login page will only show the SSO button
|
||||
3. Admins can still use the CLI to reset passwords if needed
|
||||
|
||||
**Warning:** Only disable local auth after confirming all users can access SSO. Keep one admin password as backup.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "SSO is not enabled" error
|
||||
|
||||
- Verify **Enabled** is set to `true` in admin settings
|
||||
- Check application logs for OIDC configuration errors
|
||||
|
||||
### "Invalid state" or "Expired" error
|
||||
|
||||
- The OIDC flow timed out (5 minute window)
|
||||
- Try logging in again
|
||||
- If persistent, check server time synchronization
|
||||
|
||||
### "No email claim" error
|
||||
|
||||
Your OIDC provider didn't return an email address. Ensure:
|
||||
|
||||
1. The `email` scope is requested (default: `openid email profile`)
|
||||
2. Your provider is configured to release email claims
|
||||
3. The user's account has an email address set
|
||||
|
||||
### Email Mismatch
|
||||
|
||||
If a user has different emails in the app vs. SSO provider:
|
||||
|
||||
**Option 1: Update app email to match SSO**
|
||||
```sql
|
||||
UPDATE users SET email = 'new-email@example.com' WHERE id = 123;
|
||||
```
|
||||
|
||||
**Option 2: Update SSO provider email to match app**
|
||||
(Provider-specific — consult your IdP documentation)
|
||||
|
||||
### Callback URL Not Working
|
||||
|
||||
Double-check the redirect URI in your OIDC provider settings matches exactly:
|
||||
|
||||
```
|
||||
https://your-domain.com/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
Common mistakes:
|
||||
- Missing `https://`
|
||||
- Trailing slash (don't include it)
|
||||
- Wrong domain (must match `APP_URL` in `.env`)
|
||||
|
||||
---
|
||||
|
||||
## Provider-Specific Examples
|
||||
|
||||
### PocketID
|
||||
|
||||
```
|
||||
Issuer URL: https://id.pockethost.io
|
||||
Client ID: (from PocketID app settings)
|
||||
Client Secret: (from PocketID app settings)
|
||||
Redirect URI: https://your-domain.com/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
### Keycloak
|
||||
|
||||
```
|
||||
Issuer URL: https://keycloak.example.com/realms/medical
|
||||
Client ID: pediatric-scribe
|
||||
Client Secret: (from Credentials tab)
|
||||
Redirect URI: https://your-domain.com/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
### Azure AD / Entra ID
|
||||
|
||||
```
|
||||
Issuer URL: https://login.microsoftonline.com/{tenant-id}/v2.0
|
||||
Client ID: (Application ID from Azure)
|
||||
Client Secret: (from Certificates & secrets)
|
||||
Redirect URI: https://your-domain.com/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
Note: Azure requires app registration in Azure Portal first.
|
||||
|
||||
### Okta
|
||||
|
||||
```
|
||||
Issuer URL: https://{your-okta-domain}.okta.com
|
||||
Client ID: (from Okta application settings)
|
||||
Client Secret: (from Okta application settings)
|
||||
Redirect URI: https://your-domain.com/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
### Google (Workspace or Gmail)
|
||||
|
||||
```
|
||||
Issuer URL: https://accounts.google.com
|
||||
Client ID: (from Google Cloud Console)
|
||||
Client Secret: (from Google Cloud Console)
|
||||
Redirect URI: https://your-domain.com/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
Note: Google requires OAuth consent screen configuration.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables (Alternative to UI Config)
|
||||
|
||||
For deployment automation, you can set OIDC config via environment variables instead of the admin UI:
|
||||
|
||||
```env
|
||||
# .env file
|
||||
OIDC_ENABLED=true
|
||||
OIDC_ISSUER=https://id.example.com
|
||||
OIDC_CLIENT_ID=my-client-id
|
||||
OIDC_CLIENT_SECRET=my-client-secret
|
||||
OIDC_BUTTON_LABEL=Sign in with PocketID
|
||||
OIDC_DISABLE_LOCAL_AUTH=false
|
||||
```
|
||||
|
||||
**Note:** UI settings take precedence over environment variables. If set in both places, the database values are used.
|
||||
|
||||
---
|
||||
|
||||
## HIPAA Compliance Notes
|
||||
|
||||
OIDC does not transmit PHI to the identity provider. Only authentication-related data (email, name) is exchanged.
|
||||
|
||||
For HIPAA compliance:
|
||||
- Ensure your OIDC provider has appropriate safeguards
|
||||
- Use a self-hosted provider (Keycloak, PocketID) within your secure network
|
||||
- Or use a HIPAA-compliant SaaS provider with a BAA
|
||||
- Enable audit logging for all SSO login events (automatically logged in `audit_log` table)
|
||||
|
||||
---
|
||||
|
||||
## Audit Logging
|
||||
|
||||
All SSO login events are logged in the `audit_log` table:
|
||||
|
||||
```sql
|
||||
SELECT * FROM audit_log WHERE action = 'login_oidc' ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
Logged fields:
|
||||
- User ID
|
||||
- Action: `login_oidc`
|
||||
- IP address
|
||||
- Details: Issuer URL
|
||||
- Timestamp
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues specific to:
|
||||
- **This application**: Check application logs with `docker logs pediatric-ai-scribe`
|
||||
- **Your OIDC provider**: Consult provider documentation (PocketID, Keycloak, Azure, etc.)
|
||||
- **Network/TLS issues**: Verify `APP_URL` matches your reverse proxy configuration
|
||||
|
||||
Common log locations:
|
||||
```bash
|
||||
# Application logs
|
||||
docker logs pediatric-ai-scribe
|
||||
|
||||
# PostgreSQL logs
|
||||
docker logs pediatric-ai-scribe-postgres
|
||||
```
|
||||
399
README.md
|
|
@ -1,67 +1,78 @@
|
|||
# 🩺 Pediatric AI Scribe v3
|
||||
# Pediatric AI Scribe v6
|
||||
|
||||
AI-powered clinical documentation platform for pediatric medicine. Generates HPIs, hospital courses, chart reviews, SOAP notes, and developmental milestone assessments from voice recordings or dictation — in seconds, in plain copy-ready text.
|
||||
AI-powered clinical documentation platform for pediatric medicine. Generates HPIs, hospital courses, chart reviews, SOAP notes, well/sick visit notes, and developmental milestone assessments from voice recordings or dictation.
|
||||
|
||||
## Features
|
||||
|
||||
- **Live Encounter → HPI** — record a live doctor-patient conversation, AI generates a structured OLDCARTS HPI
|
||||
- **Voice Dictation → HPI / SOAP** — dictate your narrative, AI cleans and restructures it
|
||||
- **Hospital Course Generator** — paste progress notes, AI generates prose, day-by-day, organ-system (ICU), or psych format summaries
|
||||
- **Chart Review / Precharting** — summarize outpatient, subspecialty, and ED notes into a precharting brief
|
||||
- **SOAP Note Generator** — full SOAP or subjective-only from dictation
|
||||
- **Well Visit / Preventive Care** — AAP 2025 Bright Futures periodicity; vaccines, screenings, billing codes; By Visit Age, Milestones, SSHADESS (12+), and Visit Note subtabs
|
||||
- **Sick Visit Note** — quick documentation with auto-suggested ROS and PE systems from chief complaint
|
||||
- **Developmental Milestones** — AAP/Nelson milestone tracker (birth–11 years) with narrative, structured list, or 3-sentence summary; copy to Visit Note
|
||||
- **SSHADESS Assessment** — adolescent psychosocial screening for ages 12+; auto-fills into Visit Note
|
||||
- **Vaccine Schedule** — full AAP immunization schedule reference
|
||||
- **Catch-Up Schedule** — catch-up immunization guide
|
||||
- **Plain text output** — all documents generated without markdown, ready to paste into any EHR
|
||||
- **Read Aloud** — browser TTS reads generated documents; ElevenLabs (Adam voice) supported
|
||||
- **Copy & Export** — one-click copy or export to Nextcloud
|
||||
- **Refine & Shorten** — edit any document with plain-language AI instructions
|
||||
- **Per-tab model selector** — choose fast vs. smart vs. reasoning models per task
|
||||
- **Collapsible sidebar** — desktop sidebar collapses to icon rail, state persisted
|
||||
- **Save & Resume** — encounters saved with unique IDs; persist across page refresh
|
||||
- **Admin Panel** — user management, registration control, audit logs
|
||||
### Clinical Documentation
|
||||
- **Live Encounter** — record doctor-patient conversations, AI generates structured OLDCARTS HPI
|
||||
- **Voice Dictation** — dictate narrative, AI cleans and restructures
|
||||
- **Hospital Course** — paste progress notes, generates prose, day-by-day, organ-system (ICU), or psych format
|
||||
- **Chart Review / Precharting** — summarize outpatient, subspecialty, and ED notes
|
||||
- **SOAP Notes** — full SOAP or subjective-only from dictation
|
||||
- **Well Visit** — AAP 2025 Bright Futures periodicity with vaccines, screenings, billing codes, SSHADESS (12+), milestones
|
||||
- **Sick Visit** — quick documentation with auto-suggested ROS and PE from chief complaint
|
||||
- **Developmental Milestones** — AAP/Nelson tracker (birth-11y) with narrative/structured/summary output
|
||||
|
||||
### AI & Speech
|
||||
- **5 AI Providers** — OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
|
||||
- **5 STT Providers** — Google Gemini, Amazon Transcribe (Medical), OpenAI Whisper, Local Whisper, LiteLLM
|
||||
- **3 TTS Providers** — Google Cloud TTS, LiteLLM (OpenAI), ElevenLabs
|
||||
- **Browser Whisper** — fully offline in-browser transcription via WebAssembly (HIPAA-safe)
|
||||
- **Per-tab model selector** — choose fast vs. smart vs. premium models per task
|
||||
- **Physician memory system** — Dragon-like learning from your corrections
|
||||
|
||||
### Learning Hub
|
||||
- **Content Management** — articles, clinical pearls, quizzes, presentations
|
||||
- **AI Content Generation** — generate from topics, uploaded PDFs, or Nextcloud files
|
||||
- **Marp Presentations** — slide editor with preview and PPTX export
|
||||
- **Semantic Search** — vector-based search via pgvector embeddings
|
||||
- **Quiz System** — MCQ, multi-select, true/false with scoring and progress tracking
|
||||
|
||||
### Platform
|
||||
- **Multi-user with roles** — admin, moderator, user
|
||||
- **OIDC/SSO** — Azure AD, Okta, Keycloak, PocketID, Google
|
||||
- **2FA** — TOTP-based two-factor authentication
|
||||
- **Multi-provider AI** — OpenRouter, AWS Bedrock, or Azure OpenAI
|
||||
- **Cloudflare Turnstile** — bot protection on login, register, password reset
|
||||
- **Email verification** — with customizable templates
|
||||
- **Nextcloud integration** — WebDAV export
|
||||
- **S3 Document Storage** — AWS S3, Backblaze B2, MinIO
|
||||
- **PWA** — installable, works on mobile
|
||||
- **Admin Panel** — user management, settings, prompt editor, model configuration, logs
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Docker)
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone and configure
|
||||
### 1. Configure
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ifedan-ed/pediatric-ai-scribe-v3.git
|
||||
cd pediatric-ai-scribe-v3
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` — at minimum set:
|
||||
|
||||
```env
|
||||
OPENROUTER_API_KEY=sk-or-v1-...
|
||||
OPENAI_API_KEY=sk-... # for Whisper transcription
|
||||
JWT_SECRET=<64-char random string>
|
||||
AI_PROVIDER=litellm # or openrouter, bedrock, azure, vertex
|
||||
LITELLM_API_BASE=https://your-litellm.example.com
|
||||
LITELLM_API_KEY=sk-...
|
||||
|
||||
OPENAI_API_KEY=sk-... # for Whisper transcription (if not using LiteLLM STT)
|
||||
|
||||
JWT_SECRET=<64-char random> # openssl rand -hex 32
|
||||
DB_PASSWORD=<strong password>
|
||||
APP_URL=https://your-domain.com
|
||||
```
|
||||
|
||||
Generate a strong JWT secret:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### 2. Start
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
App runs on **port 3552** by default. The first user to register becomes admin automatically.
|
||||
App runs on **port 3552**. First user to register becomes admin.
|
||||
|
||||
### 3. Admin CLI (inside container)
|
||||
### 3. Admin CLI
|
||||
|
||||
```bash
|
||||
docker exec pediatric-ai-scribe node admin-cli.js list-users
|
||||
|
|
@ -74,13 +85,117 @@ docker exec pediatric-ai-scribe node admin-cli.js stats
|
|||
|
||||
---
|
||||
|
||||
## AI Provider Configuration
|
||||
|
||||
Switch providers by setting `AI_PROVIDER` in `.env`. No code changes needed.
|
||||
|
||||
| Provider | HIPAA | Config |
|
||||
|----------|-------|--------|
|
||||
| **LiteLLM** | Depends on backend | `LITELLM_API_BASE`, `LITELLM_API_KEY` |
|
||||
| **AWS Bedrock** | Yes (with BAA) | `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
|
||||
| **Azure OpenAI** | Yes (with BAA) | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME` |
|
||||
| **Google Vertex AI** | Yes (with BAA) | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION` |
|
||||
| **OpenRouter** | No | `OPENROUTER_API_KEY` |
|
||||
|
||||
---
|
||||
|
||||
## Transcription (Speech-to-Text)
|
||||
|
||||
Set `TRANSCRIBE_PROVIDER` or let the app auto-detect.
|
||||
|
||||
| Provider | HIPAA | Config |
|
||||
|----------|-------|--------|
|
||||
| **Google Gemini** | Yes | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_STT_MODEL` |
|
||||
| **Amazon Transcribe** | Yes | AWS creds + `TRANSCRIBE_PROVIDER=aws` |
|
||||
| **Amazon Transcribe Medical** | Yes | `AWS_TRANSCRIBE_MEDICAL=true`, `AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE` |
|
||||
| **Local Whisper** | Yes (offline) | `TRANSCRIBE_PROVIDER=local`, `WHISPER_BINARY`, `WHISPER_MODEL_SIZE` |
|
||||
| **OpenAI Whisper** | No | `OPENAI_API_KEY` |
|
||||
| **LiteLLM** | Depends | `TRANSCRIBE_PROVIDER=litellm`, `LITELLM_STT_MODEL` |
|
||||
| **Browser Whisper** | Yes (client-side) | No config needed — toggle in user settings |
|
||||
|
||||
---
|
||||
|
||||
## Text-to-Speech
|
||||
|
||||
| Provider | HIPAA | Config |
|
||||
|----------|-------|--------|
|
||||
| **Google Cloud TTS** | Yes | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_TTS_VOICE` |
|
||||
| **LiteLLM** | Depends | `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` |
|
||||
| **ElevenLabs** | No | `ELEVENLABS_API_KEY` |
|
||||
|
||||
---
|
||||
|
||||
## OpenID Connect / SSO
|
||||
|
||||
Supports Azure AD, Okta, Keycloak, PocketID, Google, and any OIDC-compliant provider.
|
||||
|
||||
1. Register callback URL: `https://your-domain.com/api/auth/oidc/callback`
|
||||
2. Admin Panel > Settings > Configure OIDC (Issuer URL, Client ID, Client Secret)
|
||||
3. Users are auto-created and linked by email on first SSO login
|
||||
|
||||
See [OPENID_SETUP.md](OPENID_SETUP.md) for provider-specific guides.
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare Turnstile (Bot Protection)
|
||||
|
||||
Optional CAPTCHA on login, registration, and password reset forms.
|
||||
|
||||
```env
|
||||
TURNSTILE_SITE_KEY=0x4AAA...
|
||||
TURNSTILE_SECRET_KEY=0x4AAA...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Email
|
||||
|
||||
Without SMTP, email verification is skipped and users are auto-verified.
|
||||
|
||||
```env
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Maintenance CLI
|
||||
|
||||
After a Postgres image upgrade (major version bump or silent base-layer change),
|
||||
btree indexes on text columns can become inconsistent with the new ICU/glibc
|
||||
library. The app auto-detects this at startup and reindexes on drift, but you
|
||||
can also trigger it manually:
|
||||
|
||||
```bash
|
||||
# Health check — no writes
|
||||
docker exec pediatric-ai-scribe npm run maint:check
|
||||
|
||||
# Rebuild all indexes + refresh collation + ANALYZE
|
||||
docker exec pediatric-ai-scribe npm run maint:reindex
|
||||
```
|
||||
|
||||
Run `maint:reindex` any time after:
|
||||
|
||||
- Upgrading the Postgres image (major or minor)
|
||||
- Restoring from a dump created on a different Linux distro
|
||||
- Seeing "invalid credentials" on credentials you know are correct
|
||||
- Seeing `0 rows` returned from a lookup that should match
|
||||
|
||||
The reindex takes seconds on a small DB and a minute or two on larger ones.
|
||||
Safe to run while the app is serving traffic, though queries may slow briefly.
|
||||
|
||||
---
|
||||
|
||||
## Docker Hub
|
||||
|
||||
```bash
|
||||
docker pull danielonyejesi/pediatric-ai-scribe-v3:latest
|
||||
```
|
||||
|
||||
### Minimal docker-compose without building
|
||||
Minimal compose without building:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
|
|
@ -95,7 +210,7 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_DB: pedscribe
|
||||
POSTGRES_USER: pedscribe
|
||||
|
|
@ -114,112 +229,36 @@ volumes:
|
|||
|
||||
---
|
||||
|
||||
## AI Provider Configuration
|
||||
|
||||
Switch providers by changing `AI_PROVIDER` in `.env`. No code changes needed.
|
||||
|
||||
### OpenRouter (default — cheapest, NOT HIPAA)
|
||||
|
||||
```env
|
||||
AI_PROVIDER=openrouter
|
||||
OPENROUTER_API_KEY=sk-or-v1-...
|
||||
```
|
||||
|
||||
### AWS Bedrock (HIPAA compliant with BAA)
|
||||
|
||||
```env
|
||||
AI_PROVIDER=bedrock
|
||||
AWS_BEDROCK_REGION=us-east-1
|
||||
AWS_ACCESS_KEY_ID=AKIA...
|
||||
AWS_SECRET_ACCESS_KEY=...
|
||||
```
|
||||
|
||||
Or use an IAM role (no keys needed when running on EC2/ECS — just set the region).
|
||||
|
||||
Available Bedrock models (auto-selected when `AI_PROVIDER=bedrock`):
|
||||
- vendor model Opus 4.6 — best language nuance (`anthropic.agent-config-opus-4-6-20251001-v1:0`)
|
||||
- vendor model Sonnet 4.6 — recommended (`anthropic.agent-config-sonnet-4-6-20251001-v1:0`)
|
||||
- vendor model Sonnet 4 (`anthropic.agent-config-sonnet-4-20250514-v1:0`)
|
||||
- vendor model 3.5 Sonnet (`anthropic.agent-config-3-5-sonnet-20241022-v2:0`)
|
||||
- vendor model 3 Haiku — cheapest (`anthropic.agent-config-3-haiku-20240307-v1:0`)
|
||||
- Llama 3.1 70B / 8B
|
||||
- Mistral Large
|
||||
|
||||
### Azure OpenAI (HIPAA compliant with BAA)
|
||||
|
||||
```env
|
||||
AI_PROVIDER=azure
|
||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
|
||||
AZURE_OPENAI_API_KEY=...
|
||||
AZURE_DEPLOYMENT_NAME=gpt-4o-mini
|
||||
AZURE_OPENAI_API_VERSION=2024-02-01
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Whisper Transcription
|
||||
|
||||
Always uses OpenAI Whisper regardless of the AI provider setting:
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Email (optional — for verification & password reset)
|
||||
|
||||
Without SMTP configured, email verification is skipped and users are auto-verified on registration.
|
||||
|
||||
```env
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `OPENROUTER_API_KEY` | If using OpenRouter | OpenRouter API key |
|
||||
| `AI_PROVIDER` | No | `openrouter` (default), `bedrock`, or `azure` |
|
||||
| `AWS_BEDROCK_REGION` | If using Bedrock | e.g. `us-east-1` |
|
||||
| `AWS_ACCESS_KEY_ID` | If using Bedrock (no IAM role) | AWS access key |
|
||||
| `AWS_SECRET_ACCESS_KEY` | If using Bedrock (no IAM role) | AWS secret key |
|
||||
| `AZURE_OPENAI_ENDPOINT` | If using Azure | Azure OpenAI endpoint URL |
|
||||
| `AZURE_OPENAI_API_KEY` | If using Azure | Azure API key |
|
||||
| `AZURE_DEPLOYMENT_NAME` | If using Azure | Deployment name, e.g. `gpt-4o-mini` |
|
||||
| `OPENAI_API_KEY` | For transcription | OpenAI key (Whisper) |
|
||||
| `ELEVENLABS_API_KEY` | No | ElevenLabs TTS (optional) |
|
||||
| `JWT_SECRET` | **Yes** | Random 64-char string — keep secret |
|
||||
| `DATABASE_URL` | No | PostgreSQL URL (auto-set by docker-compose) |
|
||||
| `DB_PASSWORD` | **Yes** | PostgreSQL password |
|
||||
| `APP_URL` | Recommended | Public URL e.g. `https://scribe.example.com` (used for CORS, emails) |
|
||||
| `PORT` | No | Internal port, default `3000` |
|
||||
| `SMTP_HOST` | No | SMTP server for email |
|
||||
| `SMTP_PORT` | No | Default `587` |
|
||||
| `SMTP_USER` | No | SMTP username |
|
||||
| `SMTP_PASS` | No | SMTP password / app password |
|
||||
| `SMTP_FROM` | No | From address for emails |
|
||||
|
||||
---
|
||||
|
||||
## HIPAA Notice
|
||||
|
||||
This application processes data through third-party AI APIs.
|
||||
|
||||
- ✅ All connections use HTTPS/TLS
|
||||
- ✅ Authentication required for all AI endpoints
|
||||
- ✅ 2FA available
|
||||
- ✅ No patient data stored on server (only audit logs)
|
||||
- ⚠️ **OpenRouter does not offer a BAA** — do not use with real PHI
|
||||
- ✅ **AWS Bedrock** and **Azure OpenAI** offer BAAs — suitable for PHI with proper configuration
|
||||
- All connections use HTTPS/TLS
|
||||
- Authentication required for all AI endpoints
|
||||
- 2FA and SSO available
|
||||
- Cloudflare Turnstile bot protection
|
||||
- **AWS Bedrock**, **Azure OpenAI**, and **Google Vertex AI** offer BAAs
|
||||
- **OpenRouter** and **ElevenLabs** do NOT offer BAAs
|
||||
- **Browser Whisper** and **Local Whisper** keep audio fully private
|
||||
|
||||
**Recommendation:** Do not enter real patient data until your organization has executed BAAs with all AI providers in use.
|
||||
**Do not use real PHI without executed BAAs with all providers in your deployment.**
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
See the [docs/](docs/) directory for detailed documentation:
|
||||
|
||||
- [Architecture Overview](docs/architecture.md)
|
||||
- [API Reference](docs/api-reference.md)
|
||||
- [Database Schema](docs/database.md)
|
||||
- [Authentication & Security](docs/authentication.md)
|
||||
- [AI Providers & Models](docs/ai-providers.md)
|
||||
- [Speech (STT/TTS)](docs/speech.md)
|
||||
- [Learning Hub & CMS](docs/learning-hub.md)
|
||||
- [Configuration Reference](docs/configuration.md)
|
||||
- [Deployment Guide](docs/deployment.md)
|
||||
- [Developer Guide](docs/developer-guide.md)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -228,6 +267,86 @@ This application processes data through third-party AI APIs.
|
|||
```bash
|
||||
npm install
|
||||
cp .env.example .env # edit with your keys
|
||||
# Requires a running PostgreSQL instance (see DATABASE_URL in .env)
|
||||
# Requires PostgreSQL with pgvector
|
||||
node server.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Two layers, both zero-config after the initial setup.
|
||||
|
||||
### Unit tests — pure dose math (Node built-in)
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Runs `node --test test/` against `public/js/calc-math.js` — pure functions for
|
||||
APLS / Best Guess weight, Parkland, Holliday-Segar 4-2-1, PRAM, Westley,
|
||||
epi (anaphylaxis vs arrest vs NRP, different concentrations), RSI drugs,
|
||||
min SBP, ETT sizing, Lund-Browder TBSA. **36 assertions, no dependencies.**
|
||||
|
||||
### End-to-end tests — Playwright smoke suite
|
||||
|
||||
Runs a headless Chromium against the live app. **128 tests** covering every
|
||||
calculator tab, every Bedside sub-pill + widget, auth-gated pages (encounter,
|
||||
well visit, charts, vaccines, catch-up, learning hub, dictation, settings,
|
||||
FAQ), at **both desktop and mobile (Pixel 5) viewports**.
|
||||
|
||||
```bash
|
||||
# First-time setup: spin up the auth-less test container (port 3553)
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
|
||||
|
||||
# Then run the full suite (runs inside an official Playwright container)
|
||||
npm run e2e
|
||||
```
|
||||
|
||||
The runner script (`scripts/e2e.sh`) uses `mcr.microsoft.com/playwright` so you
|
||||
don't need Node or browsers on the host.
|
||||
|
||||
**Test environment:**
|
||||
|
||||
- `pediatric-ai-scribe` (port 3552) — your normal app
|
||||
- `pediatric-ai-scribe-e2e` (port 3553) — identical image, but with
|
||||
`TURNSTILE_SECRET_KEY=""` and `SMTP_HOST=""` so Playwright can log in
|
||||
without a bot challenge. Shares the same Postgres + pgdata volume.
|
||||
- Test user: `e2e-user@ped-ai.test` (auto-verified on first register)
|
||||
- Harness page: `public/e2e-harness.html` loads the calculators component
|
||||
without the auth wall for smoke tests that don't need a logged-in session.
|
||||
|
||||
**Viewing failures** — Playwright writes `e2e/test-results/<test-name>/`
|
||||
with:
|
||||
|
||||
- `test-failed-1.png` — screenshot at the point of failure
|
||||
- `trace.zip` — full action trace (replay with `npx playwright show-trace`)
|
||||
- `error-context.md` — DOM snapshot and console logs
|
||||
|
||||
Everything but the specs and config is gitignored under `e2e/`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- `e2e/tests/bedside-smoke.spec.js` — 26 tests for the Bedside module
|
||||
- `e2e/tests/top-calculators.spec.js` — 27 tests for BP / BMI / Growth /
|
||||
Bili / Vitals / BSA / Dose / Resus / GCS / Equipment
|
||||
- `e2e/tests/auth-gated-smoke.spec.js` — 11 tests for the auth-gated tabs
|
||||
- `e2e/playwright.config.js` — runs all the above under both `chromium`
|
||||
(Desktop Chrome) and `mobile-chrome` (Pixel 5) projects
|
||||
|
||||
**Writing a new test:**
|
||||
|
||||
```js
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('my new smoke test', async ({ page }) => {
|
||||
await page.goto('/e2e-harness.html'); // bypasses auth for calculators
|
||||
await page.waitForFunction(() => window.__harnessReady === true);
|
||||
await page.click('button.calc-nav-pill[data-calc="bedside"]');
|
||||
await expect(page.locator('#calc-bedside')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
For auth-gated routes, use the login fixture in `auth-gated-smoke.spec.js`
|
||||
as a template — it caches the token at module scope so you don't hit the
|
||||
login rate-limit.
|
||||
|
|
|
|||
279
TRANSCRIPTION_OPTIONS.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# Transcription Options Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Pediatric AI Scribe v2+ offers **three transcription methods**, allowing you to choose between **privacy**, **speed**, and **real-time feedback**.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Comparison Table
|
||||
|
||||
| Feature | Browser Whisper | Server Transcription | Web Speech API |
|
||||
|---------|----------------|---------------------|----------------|
|
||||
| **Privacy** | ⭐⭐⭐⭐⭐ 100% offline | ⭐⭐⭐⭐ (with BAA) | ⭐ Sends to cloud |
|
||||
| **Accuracy** | ⭐⭐⭐⭐⭐ Whisper | ⭐⭐⭐⭐⭐ Gemini/AWS | ⭐⭐⭐ Browser-dependent |
|
||||
| **Speed** | ⭐⭐⭐ 2-10s | ⭐⭐⭐⭐⭐ ~1s | ⭐⭐⭐⭐⭐ Instant |
|
||||
| **Real-time** | ❌ Batch mode | ❌ Batch mode | ✅ Live streaming |
|
||||
| **HIPAA** | ✅ Yes | ✅ (Vertex/AWS) | ❌ No |
|
||||
| **Cost** | Free | ~$0.005/min | Free |
|
||||
| **Internet** | ❌ Not required | ✅ Required | ✅ Required |
|
||||
| **Setup** | None (bundled) | API keys | None (built-in) |
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Browser Whisper (Offline, Private) ⭐ RECOMMENDED
|
||||
|
||||
### What It Is
|
||||
- Runs **OpenAI Whisper** entirely in your browser using WebAssembly
|
||||
- Audio **never leaves your device** - 100% offline after initial page load
|
||||
- Models bundled in Docker image (self-hosted, no CDN)
|
||||
|
||||
### When to Use
|
||||
- ✅ Clinical documentation (HIPAA-compliant)
|
||||
- ✅ Maximum privacy required
|
||||
- ✅ Offline/air-gapped environments
|
||||
- ✅ No API costs
|
||||
- ✅ Zero vendor dependency
|
||||
|
||||
### How to Enable
|
||||
1. Settings → Browser Transcription
|
||||
2. Toggle "Enable browser transcription" ON
|
||||
3. (Optional) Click "Pre-download model" if you want to cache it first
|
||||
4. Start recording - transcription happens automatically after recording
|
||||
|
||||
### Models Available
|
||||
- **Tiny** (~39MB) - Fast, good for short clips (2-3 seconds)
|
||||
- **Base** (~74MB) - Balanced accuracy and speed (3-5 seconds)
|
||||
- **Small** (~244MB) - Best quality, slower (6-10 seconds)
|
||||
|
||||
### Performance
|
||||
- Transcribes ~30-second clip in 2-10 seconds (depending on model)
|
||||
- First run may be slower (model loading)
|
||||
- Subsequent runs are instant (cached)
|
||||
|
||||
### Privacy
|
||||
- ✅ Audio never transmitted
|
||||
- ✅ Models run locally in WASM
|
||||
- ✅ No network calls during transcription
|
||||
- ✅ HIPAA-compliant
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Server Transcription (Cloud, Fast)
|
||||
|
||||
### What It Is
|
||||
- Sends audio to your configured AI provider
|
||||
- Uses Google Gemini, AWS Transcribe, OpenAI Whisper, or LiteLLM
|
||||
|
||||
### When to Use
|
||||
- ✅ Maximum speed (~1 second for 30-second clip)
|
||||
- ✅ Best accuracy (cloud models)
|
||||
- ✅ Long recordings (Browser Whisper can be slow for 5+ minutes)
|
||||
- ✅ HIPAA-compliant with BAA providers
|
||||
|
||||
### HIPAA-Eligible Providers
|
||||
- **Google Vertex AI** (with BAA) ✅
|
||||
- **AWS Transcribe** (with BAA) ✅
|
||||
- **Azure OpenAI** (with BAA) ✅
|
||||
- **OpenAI Whisper Direct** ❌ Not HIPAA-eligible
|
||||
|
||||
### How to Enable
|
||||
- Configured via environment variables (`.env`)
|
||||
- No user action needed - just works if API keys present
|
||||
- Falls back automatically if Browser Whisper fails
|
||||
|
||||
### Cost
|
||||
- Google Gemini: ~$0.005/minute
|
||||
- AWS Transcribe: ~$0.024/minute
|
||||
- OpenAI: $0.006/minute
|
||||
|
||||
---
|
||||
|
||||
## Option 3: Web Speech API (Real-Time, Experimental) ⚠️
|
||||
|
||||
### What It Is
|
||||
- Uses your browser's built-in speech recognition
|
||||
- Shows transcription **in real-time** as you speak (streaming)
|
||||
- Chrome/Edge → Google Cloud Speech
|
||||
- Safari → Apple Speech Recognition
|
||||
|
||||
### ⚠️ PRIVACY WARNING
|
||||
- **Audio IS sent to cloud servers** (Google, Apple, etc.)
|
||||
- **NOT HIPAA-compliant**
|
||||
- Only use for non-clinical, personal use
|
||||
|
||||
### When to Use
|
||||
- ✅ Personal notes (non-clinical)
|
||||
- ✅ Want real-time feedback while speaking
|
||||
- ✅ Demonstration/testing
|
||||
- ❌ **NEVER for patient data**
|
||||
|
||||
### How to Enable
|
||||
1. Settings → Real-Time Streaming Transcription
|
||||
2. Read privacy warning carefully
|
||||
3. Toggle "Enable real-time streaming" ON
|
||||
4. Confirm warning dialog
|
||||
5. Grants microphone permission
|
||||
6. Start recording - see words appear live
|
||||
|
||||
### Limitations
|
||||
- Not available in all browsers (requires Web Speech API)
|
||||
- Accuracy varies by browser
|
||||
- Requires internet connection
|
||||
- May have usage limits
|
||||
|
||||
---
|
||||
|
||||
## Choosing the Right Option
|
||||
|
||||
### For Clinical Use (HIPAA Required)
|
||||
**Use:** Browser Whisper (offline) OR Server (Vertex AI/AWS with BAA)
|
||||
- Browser Whisper: Maximum privacy, no costs
|
||||
- Server: Faster, better for long recordings
|
||||
|
||||
### For Personal Use (Non-HIPAA)
|
||||
**Use:** Any option
|
||||
- Browser Whisper: Best balance of privacy and accuracy
|
||||
- Server: Fastest
|
||||
- Web Speech: Real-time feedback
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
Is this clinical/patient data?
|
||||
├─ YES → Use Browser Whisper or Server (Vertex/AWS)
|
||||
│ ├─ Need offline? → Browser Whisper
|
||||
│ ├─ Need speed? → Server (Vertex AI)
|
||||
│ └─ Want free? → Browser Whisper
|
||||
│
|
||||
└─ NO → Any option
|
||||
├─ Want real-time? → Web Speech API
|
||||
├─ Want privacy? → Browser Whisper
|
||||
└─ Want speed? → Server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Browser Whisper
|
||||
```bash
|
||||
# No configuration needed - bundled in Docker image
|
||||
# Models at: /app/public/models/Xenova/whisper-tiny.en/
|
||||
```
|
||||
|
||||
### Server Transcription
|
||||
```bash
|
||||
# .env file
|
||||
TRANSCRIBE_PROVIDER=google # google, aws, openai, litellm
|
||||
|
||||
# Google Vertex AI
|
||||
GOOGLE_VERTEX_PROJECT=your-project-id
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
|
||||
|
||||
# AWS Transcribe
|
||||
AWS_BEDROCK_REGION=us-east-1
|
||||
AWS_ACCESS_KEY_ID=your-key
|
||||
AWS_SECRET_ACCESS_KEY=your-secret
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# LiteLLM (proxy)
|
||||
LITELLM_API_BASE=http://localhost:4000
|
||||
LITELLM_API_KEY=optional
|
||||
```
|
||||
|
||||
### Web Speech API
|
||||
```bash
|
||||
# No configuration - uses browser built-in
|
||||
# Privacy warning shown in Settings UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: Which is most accurate?
|
||||
**A:** Browser Whisper and Server (Gemini/Whisper) are equally accurate. Web Speech is slightly less accurate.
|
||||
|
||||
### Q: Which is fastest?
|
||||
**A:** Server transcription (~1s) > Web Speech (real-time) > Browser Whisper (2-10s)
|
||||
|
||||
### Q: Which is most private?
|
||||
**A:** Browser Whisper (100% offline) > Server (with BAA) > Web Speech (not private)
|
||||
|
||||
### Q: Can I use multiple at once?
|
||||
**A:** No. Priority: Web Speech > Browser Whisper > Server (whichever is enabled first)
|
||||
|
||||
### Q: What if transcription fails?
|
||||
**A:** Automatic fallback chain:
|
||||
1. Browser Whisper (if enabled)
|
||||
2. Falls back to Server (if configured)
|
||||
3. Falls back to live transcript (if available)
|
||||
|
||||
### Q: Is Browser Whisper really offline?
|
||||
**A:** Yes! Models are bundled in the Docker image. After the page loads once, transcription works with zero network access.
|
||||
|
||||
### Q: Does Web Speech work offline?
|
||||
**A:** No. It requires internet to send audio to cloud servers.
|
||||
|
||||
### Q: Can I train/customize the models?
|
||||
**A:** No. Browser Whisper uses pre-trained models. Server transcription uses cloud models. No custom training available.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Browser Whisper stuck at "Initializing"
|
||||
- **Cause:** Models not loaded or network blocked during initial download
|
||||
- **Fix:** See BROWSER_WHISPER_TROUBLESHOOTING.md
|
||||
|
||||
### Server transcription returns "No provider"
|
||||
- **Cause:** API keys not configured
|
||||
- **Fix:** Set environment variables in `.env`
|
||||
|
||||
### Web Speech says "Not supported"
|
||||
- **Cause:** Browser doesn't support Web Speech API
|
||||
- **Fix:** Use Chrome, Edge, or Safari
|
||||
|
||||
### Transcription is slow
|
||||
- **Browser Whisper:** Try switching to "Tiny" model
|
||||
- **Server:** Check API provider status
|
||||
- **Web Speech:** Check internet connection
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Clinical Documentation
|
||||
1. Use Browser Whisper for all patient data
|
||||
2. Enable audio backups (automatic in v2)
|
||||
3. Keep recordings under 5 minutes for faster processing
|
||||
4. Use "Tiny" model for quick notes, "Base" for detailed documentation
|
||||
|
||||
### Personal Use
|
||||
1. Web Speech for quick, informal notes
|
||||
2. Browser Whisper for anything you want private
|
||||
3. Server for long recordings
|
||||
|
||||
### Performance Optimization
|
||||
1. Pre-download Browser Whisper model before first use
|
||||
2. Use shorter clips (30-60 seconds) for fastest results
|
||||
3. Clear browser cache if models seem corrupted
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Need | Recommendation |
|
||||
|------|---------------|
|
||||
| Clinical/HIPAA | Browser Whisper (offline) |
|
||||
| Fast transcription | Server (Vertex AI) |
|
||||
| Real-time feedback | Web Speech (non-clinical only) |
|
||||
| Maximum privacy | Browser Whisper |
|
||||
| Zero cost | Browser Whisper |
|
||||
| Long recordings | Server (faster for 5+ min clips) |
|
||||
| Offline use | Browser Whisper |
|
||||
|
||||
**Default recommendation:** Browser Whisper for 95% of use cases. It's private, accurate, free, and offline. Only use alternatives when you have specific needs for speed or real-time feedback.
|
||||
44
android/app/build.gradle
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.pediatricscribe.twa'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.pediatricscribe.twa"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
|
||||
// TWA host URL — default: peds.danvics.com (change if self-hosting elsewhere)
|
||||
def twaHost = project.hasProperty('TWA_HOST') ? project.property('TWA_HOST') : "peds.danvics.com"
|
||||
def twaUrl = "https://${twaHost}"
|
||||
manifestPlaceholders = [
|
||||
hostName: twaHost,
|
||||
defaultUrl: twaUrl,
|
||||
launcherName: "PedScribe",
|
||||
assetStatements: "[{ \"relation\": [\"delegate_permission/common.handle_all_urls\"], \"target\": { \"namespace\": \"web\", \"site\": \"${twaUrl}\" } }]"
|
||||
]
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.browser:browser:1.7.0'
|
||||
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0'
|
||||
}
|
||||
73
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="${launcherName}"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<meta-data
|
||||
android:name="asset_statements"
|
||||
android:value='${assetStatements}' />
|
||||
|
||||
<activity
|
||||
android:name="com.google.androidbrowserhelper.trusted.LauncherActivity"
|
||||
android:exported="true"
|
||||
android:label="${launcherName}">
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.DEFAULT_URL"
|
||||
android:value="${defaultUrl}" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
|
||||
android:resource="@color/colorStatusBar" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
|
||||
android:resource="@color/colorNavigationBar" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.SPLASH_IMAGE_DRAWABLE"
|
||||
android:resource="@drawable/splash" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.SPLASH_SCREEN_BACKGROUND_COLOR"
|
||||
android:resource="@color/colorSplashBackground" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.SCREEN_ORIENTATION"
|
||||
android:value="default" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="${hostName}" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Foreground service for background audio recording -->
|
||||
<service
|
||||
android:name="com.pediatricscribe.twa.AudioRecordingService"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.pediatricscribe.twa;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the app alive during audio recording.
|
||||
* Acquires a partial wake lock to prevent CPU sleep during recording.
|
||||
* The TWA web app sends a message to start/stop this service when recording.
|
||||
*/
|
||||
public class AudioRecordingService extends Service {
|
||||
|
||||
private static final String CHANNEL_ID = "recording_channel";
|
||||
private static final int NOTIFICATION_ID = 1;
|
||||
private static final String WAKE_LOCK_TAG = "PedScribe:AudioRecording";
|
||||
|
||||
public static final String ACTION_STOP = "com.pediatricscribe.twa.STOP_RECORDING";
|
||||
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
createNotificationChannel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
// Acquire wake lock to keep CPU active during recording
|
||||
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
|
||||
if (pm != null) {
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG);
|
||||
wakeLock.acquire(60 * 60 * 1000L); // 1 hour max
|
||||
}
|
||||
|
||||
// Stop action in notification
|
||||
Intent stopIntent = new Intent(this, AudioRecordingService.class);
|
||||
stopIntent.setAction(ACTION_STOP);
|
||||
PendingIntent stopPending = PendingIntent.getService(
|
||||
this, 0, stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
|
||||
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Pediatric AI Scribe")
|
||||
.setContentText("Recording in progress...")
|
||||
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.addAction(android.R.drawable.ic_media_pause, "Stop Recording", stopPending)
|
||||
.build();
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification);
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (wakeLock != null && wakeLock.isHeld()) {
|
||||
wakeLock.release();
|
||||
wakeLock = null;
|
||||
}
|
||||
stopForeground(true);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Recording",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
);
|
||||
channel.setDescription("Shows when audio recording is active");
|
||||
channel.setShowBadge(false);
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
if (manager != null) {
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:translateX="22" android:translateY="22">
|
||||
<path
|
||||
android:fillColor="#2563EB"
|
||||
android:pathData="M32,0C49.67,0 64,14.33 64,32C64,49.67 49.67,64 32,64C14.33,64 0,49.67 0,32C0,14.33 14.33,0 32,0Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M32,12C32,12 22,20 22,30C22,35.52 26.48,40 32,40C37.52,40 42,35.52 42,30C42,20 32,12 32,12ZM32,52C32,52 28,48 28,46C28,43.79 29.79,42 32,42C34.21,42 36,43.79 36,46C36,48 32,52 32,52Z" />
|
||||
</group>
|
||||
</vector>
|
||||
BIN
android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
8
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#2563EB</color>
|
||||
<color name="colorPrimaryDark">#1E40AF</color>
|
||||
<color name="colorStatusBar">#2563EB</color>
|
||||
<color name="colorNavigationBar">#1E40AF</color>
|
||||
<color name="colorSplashBackground">#FFFFFF</color>
|
||||
</resources>
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Pediatric AI Scribe</string>
|
||||
</resources>
|
||||
10
android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@color/colorSplashBackground</item>
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="android:statusBarColor">@color/colorStatusBar</item>
|
||||
<item name="android:navigationBarColor">@color/colorNavigationBar</item>
|
||||
</style>
|
||||
</resources>
|
||||
16
android/build.gradle
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.0'
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
3
android/gradle.properties
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
org.gradle.jvmargs=-Xmx2048m
|
||||
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
15
android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
#!/bin/sh
|
||||
# Gradle wrapper stub - download if not present
|
||||
GRADLE_VERSION="8.5"
|
||||
GRADLE_DIR="$HOME/.gradle/wrapper/dists/gradle-${GRADLE_VERSION}-bin"
|
||||
|
||||
if [ ! -f "gradle/wrapper/gradle-wrapper.jar" ]; then
|
||||
echo "Downloading Gradle wrapper..."
|
||||
mkdir -p gradle/wrapper
|
||||
curl -sL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip
|
||||
unzip -q /tmp/gradle.zip -d /tmp
|
||||
cp /tmp/gradle-${GRADLE_VERSION}/lib/gradle-wrapper-*.jar gradle/wrapper/gradle-wrapper.jar 2>/dev/null || true
|
||||
rm -rf /tmp/gradle.zip /tmp/gradle-${GRADLE_VERSION}
|
||||
fi
|
||||
|
||||
exec java -jar gradle/wrapper/gradle-wrapper.jar "$@"
|
||||
2
android/settings.gradle
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
rootProject.name = 'PediatricAIScribe'
|
||||
include ':app'
|
||||
24
client/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
client/README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
21
client/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
22
client/eslint.config.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
client/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3723
client/package-lock.json
generated
Normal file
47
client/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.1",
|
||||
"@tiptap/extension-link": "^3.22.4",
|
||||
"@tiptap/extension-underline": "^3.22.4",
|
||||
"@tiptap/pm": "^3.22.4",
|
||||
"@tiptap/react": "^3.22.4",
|
||||
"@tiptap/starter-kit": "^3.22.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.9.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
1
client/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
client/public/icons.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
101
client/src/App.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
|
||||
import Layout from '@/components/Layout';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
|
||||
// Lightweight pages stay in the main chunk.
|
||||
import Extensions from '@/pages/Extensions';
|
||||
import Faq from '@/pages/Faq';
|
||||
|
||||
// Heavy pages lazy-load — keeps the initial bundle small.
|
||||
const Auth = lazy(() => import('@/pages/Auth'));
|
||||
const ResetPassword = lazy(() => import('@/pages/ResetPassword'));
|
||||
const Dictation = lazy(() => import('@/pages/Dictation'));
|
||||
const Encounter = lazy(() => import('@/pages/Encounter'));
|
||||
const Soap = lazy(() => import('@/pages/Soap'));
|
||||
const SickVisit = lazy(() => import('@/pages/SickVisit'));
|
||||
const HospitalCourse = lazy(() => import('@/pages/HospitalCourse'));
|
||||
const ChartReview = lazy(() => import('@/pages/ChartReview'));
|
||||
const WellVisit = lazy(() => import('@/pages/WellVisit'));
|
||||
const VaxSchedule = lazy(() => import('@/pages/VaxSchedule'));
|
||||
const Catchup = lazy(() => import('@/pages/Catchup'));
|
||||
const Settings = lazy(() => import('@/pages/Settings'));
|
||||
const Learning = lazy(() => import('@/pages/Learning'));
|
||||
const PeGuide = lazy(() => import('@/pages/PeGuide'));
|
||||
const Bedside = lazy(() => import('@/pages/Bedside'));
|
||||
const Calculators = lazy(() => import('@/pages/Calculators'));
|
||||
const Admin = lazy(() => import('@/pages/Admin'));
|
||||
const Cms = lazy(() => import('@/pages/Cms'));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
|
||||
});
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 text-sm text-muted-foreground">Loading…</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
||||
<h1 className="text-2xl font-semibold">Pediatric AI Scribe</h1>
|
||||
<p className="text-sm text-muted-foreground">Pick a tool from the sidebar.</p>
|
||||
<ul className="list-disc pl-6 text-sm space-y-1">
|
||||
<li><Link to="/encounter" className="underline">Encounter HPI</Link></li>
|
||||
<li><Link to="/dictation" className="underline">Dictation HPI</Link></li>
|
||||
<li><Link to="/soap" className="underline">SOAP Note</Link></li>
|
||||
<li><Link to="/sickvisit" className="underline">Sick Visit</Link></li>
|
||||
<li><Link to="/wellvisit" className="underline">Well Visit</Link></li>
|
||||
<li><Link to="/peguide" className="underline">Physical Exam Guide</Link></li>
|
||||
<li><Link to="/bedside" className="underline">Bedside</Link></li>
|
||||
<li><Link to="/calculators" className="underline">Calculators</Link></li>
|
||||
<li><Link to="/learning" className="underline">Learning Hub</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
|
||||
{/* Private routes — AuthGuard + Layout wrap everything */}
|
||||
<Route element={<AuthGuard />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/encounter" element={<Encounter />} />
|
||||
<Route path="/dictation" element={<Dictation />} />
|
||||
<Route path="/soap" element={<Soap />} />
|
||||
<Route path="/sickvisit" element={<SickVisit />} />
|
||||
<Route path="/hospital" element={<HospitalCourse />} />
|
||||
<Route path="/chart" element={<ChartReview />} />
|
||||
<Route path="/wellvisit" element={<WellVisit />} />
|
||||
<Route path="/vaxschedule" element={<VaxSchedule />} />
|
||||
<Route path="/catchup" element={<Catchup />} />
|
||||
<Route path="/extensions" element={<Extensions />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/learning" element={<Learning />} />
|
||||
<Route path="/peguide" element={<PeGuide />} />
|
||||
<Route path="/bedside" element={<Bedside />} />
|
||||
<Route path="/calculators" element={<Calculators />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/cms" element={<Cms />} />
|
||||
<Route path="/faq" element={<Faq />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
30
client/src/components/AuthGuard.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// ============================================================
|
||||
// AUTH GUARD — redirects to /auth if /api/auth/me returns 401.
|
||||
// Wraps every private route so unauthenticated users land on
|
||||
// the login screen automatically.
|
||||
// ============================================================
|
||||
|
||||
import { Navigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import type { MeOk } from '@/shared/types';
|
||||
|
||||
export default function AuthGuard() {
|
||||
const loc = useLocation();
|
||||
const { data, isLoading, isError } = useQuery<MeOk>({
|
||||
queryKey: ['auth-me'],
|
||||
queryFn: () => api.get<MeOk>('/api/auth/me'),
|
||||
retry: false,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="min-h-screen flex items-center justify-center text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
if (isError || !data?.user) {
|
||||
// Preserve deep link so we can bounce the user back after sign-in.
|
||||
const next = encodeURIComponent(loc.pathname + loc.search);
|
||||
return <Navigate to={`/auth?next=${next}`} replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
111
client/src/components/ConfirmModal.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// ============================================================
|
||||
// CONFIRM MODAL — React replacement for the vanilla showConfirm()
|
||||
// helper. Daniel's feedback is explicit: never call window.confirm()
|
||||
// in the frontend — use a styled modal that matches the app's design
|
||||
// language. Supports a danger variant (destructive actions like
|
||||
// revoke) and an optional password-input variant (e.g. "confirm by
|
||||
// entering your password" for 2FA backup-code regen).
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
body?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
// When true, a password field is shown and the value is passed to onConfirm.
|
||||
requirePassword?: boolean;
|
||||
passwordPlaceholder?: string;
|
||||
onConfirm: (password?: string) => void;
|
||||
onCancel: () => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
body,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
danger = false,
|
||||
requirePassword = false,
|
||||
passwordPlaceholder = 'Password',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
busy = false,
|
||||
}: ConfirmModalProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setPassword('');
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const confirmDisabled = busy || (requirePassword && !password);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-lg border border-border bg-background p-5 shadow-lg space-y-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="confirm-modal-title" className="text-base font-semibold">{title}</h3>
|
||||
{body && <p className="text-sm text-muted-foreground">{body}</p>}
|
||||
{requirePassword && (
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={passwordPlaceholder}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !confirmDisabled) onConfirm(password);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md border border-border px-3 py-2 text-sm"
|
||||
data-testid="confirm-modal-cancel"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={confirmDisabled}
|
||||
onClick={() => onConfirm(requirePassword ? password : undefined)}
|
||||
className={
|
||||
'rounded-md px-3 py-2 text-sm font-medium text-white disabled:opacity-50 ' +
|
||||
(danger ? 'bg-destructive' : 'bg-primary')
|
||||
}
|
||||
data-testid="confirm-modal-ok"
|
||||
>
|
||||
{busy ? 'Working…' : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
client/src/components/DxPicker.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// ============================================================
|
||||
// DxPicker — ICD-10 diagnosis picker. Live search via NLM Clinical
|
||||
// Tables API (free, no auth, CORS-enabled) + a grid of common
|
||||
// pediatric diagnoses for one-click add. Mirrors the vanilla
|
||||
// renderDxComponent / searchIcd10 in public/js/shadess.js (@be14578).
|
||||
//
|
||||
// Selected diagnoses render as removable chips. Consumers pass the
|
||||
// current array + a setter; the component owns search state only.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { COMMON_DX, type DxEntry } from '@shared/clinical/ros-pe-dx';
|
||||
|
||||
interface Props {
|
||||
value: DxEntry[];
|
||||
onChange: (next: DxEntry[]) => void;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
const sm = 'rounded-md border border-input bg-background px-2 py-1 text-sm';
|
||||
|
||||
async function searchIcd10(q: string, signal: AbortSignal): Promise<DxEntry[]> {
|
||||
const url = 'https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search?sf=code,name&terms='
|
||||
+ encodeURIComponent(q) + '&maxList=12';
|
||||
const r = await fetch(url, { signal });
|
||||
const data = await r.json();
|
||||
// NLM returns [count, [codes], null, [[code, name], …]]
|
||||
const rows = (data[3] || []) as Array<[string, string]>;
|
||||
return rows.map(([code, name]) => ({ code, name }));
|
||||
}
|
||||
|
||||
export default function DxPicker({ value, onChange, testIdPrefix = 'dx' }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<DxEntry[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim() || query.trim().length < 2) {
|
||||
setResults([]); setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
searchIcd10(query.trim(), ctrl.signal)
|
||||
.then((rows) => { setResults(rows); setOpen(rows.length > 0); })
|
||||
.catch(() => { /* fetch aborted or failed — leave previous state */ });
|
||||
}, 280);
|
||||
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
|
||||
}, [query]);
|
||||
|
||||
function add(dx: DxEntry) {
|
||||
const already = value.some((d) => d.code === dx.code && d.name === dx.name);
|
||||
if (already) return;
|
||||
onChange([...value, dx]);
|
||||
}
|
||||
function remove(i: number) {
|
||||
onChange(value.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid={testIdPrefix + '-picker'}>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder='Search ICD-10 (e.g. "otitis", "J06")…'
|
||||
autoComplete="off"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => results.length && setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
if (e.key === 'Enter' && results[0]) {
|
||||
e.preventDefault();
|
||||
add(results[0]);
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
className={sm + ' w-full'}
|
||||
data-testid={testIdPrefix + '-search'}
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute z-30 left-0 right-0 mt-1 max-h-60 overflow-auto rounded-md border border-border bg-card shadow-lg">
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.code}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
add(r);
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-muted border-b border-border last:border-0"
|
||||
data-testid={testIdPrefix + '-result-' + r.code}
|
||||
>
|
||||
<span className="inline-block w-20 text-xs font-mono text-muted-foreground">{r.code}</span>
|
||||
<span>{r.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5" data-testid={testIdPrefix + '-tags'}>
|
||||
{value.map((d, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 rounded-full bg-primary/10 border border-primary/30 text-primary px-2 py-0.5 text-xs">
|
||||
{d.code && <strong className="font-semibold">{d.code}</strong>}
|
||||
<span>{d.name}</span>
|
||||
<button type="button" onClick={() => remove(i)} className="text-xs text-muted-foreground hover:text-destructive" title="Remove">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground mb-1">Common pediatric diagnoses:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{COMMON_DX.map((dx) => (
|
||||
<button
|
||||
key={dx.code}
|
||||
type="button"
|
||||
onClick={() => add(dx)}
|
||||
className="text-[11px] rounded border border-border bg-background px-2 py-0.5 hover:bg-muted"
|
||||
title={dx.name}
|
||||
data-testid={testIdPrefix + '-chip-' + dx.code}
|
||||
>
|
||||
<span className="font-mono text-muted-foreground mr-1">{dx.code}</span>
|
||||
{dx.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
client/src/components/EditableResult.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// ============================================================
|
||||
// EditableResult — editable AI-output box + correction tracking +
|
||||
// OutputActions toolbar. Restores two pieces of vanilla behavior
|
||||
// the React migration silently dropped:
|
||||
//
|
||||
// 1. The output divs were `contenteditable="true"` in vanilla.
|
||||
// Users routinely fix AI mistakes inline before saving the
|
||||
// encounter or copying out. The early React port rendered the
|
||||
// output as a read-only div — copy was the only path.
|
||||
//
|
||||
// 2. correctionTracker.js (public/js/correctionTracker.js) saved
|
||||
// every meaningful inline edit to user_memories under category
|
||||
// `correction_<section>` so the AI learns user preferences.
|
||||
// Settings → Corrections still shows them, but nothing in React
|
||||
// was *writing* them. This component restores that loop.
|
||||
//
|
||||
// On blur, if the text differs from the captured "original AI
|
||||
// output" by more than the noise threshold, POST /api/memories/
|
||||
// correction. Mirrors the vanilla heuristic exactly so we don't
|
||||
// flood memories with whitespace-only edits.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import OutputActions from '@/components/OutputActions';
|
||||
import { api } from '@/lib/api';
|
||||
import { isMeaningfulChange } from '@shared/clinical/correction-tracker';
|
||||
|
||||
// Server enum — must match VALID_CATEGORIES in src/routes/memories.ts.
|
||||
export type CorrectionSection =
|
||||
| 'encounter'
|
||||
| 'hpi'
|
||||
| 'soap'
|
||||
| 'wellvisit'
|
||||
| 'sickvisit';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
onChange: (next: string) => void;
|
||||
/**
|
||||
* Which note type this is — selects the correction memory bucket.
|
||||
* Pass `null` to skip correction tracking (Hospital Course and Chart
|
||||
* Review aren't in the server's VALID_CATEGORIES enum).
|
||||
*/
|
||||
section: CorrectionSection | null;
|
||||
/** Filename prefix for Nextcloud export (passed through to OutputActions). */
|
||||
exportLabel: string;
|
||||
/** docType field for Nextcloud export. */
|
||||
exportType: string;
|
||||
/** Optional source material — refine reads this for context. */
|
||||
sourceContext?: string;
|
||||
/** Title shown in the section header. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function EditableResult({
|
||||
text, onChange, section, exportLabel, exportType, sourceContext, title,
|
||||
}: Props) {
|
||||
// The "AI baseline" — what the model produced last. Updated when refine
|
||||
// or shorten replaces the body (handled in onUpdate below) so the next
|
||||
// user edit is measured against the new baseline, not the original.
|
||||
const originalRef = useRef<string>(text);
|
||||
// Re-baseline whenever the text grows/changes from a non-edit source —
|
||||
// i.e. when generation produced new output (parent flipped from null→text).
|
||||
// We can't perfectly distinguish a parent-driven change from a user typing,
|
||||
// but the typical generation flow is null → full text, so a length jump
|
||||
// back to a different value is treated as a new baseline.
|
||||
useEffect(() => {
|
||||
if (!originalRef.current && text) originalRef.current = text;
|
||||
}, [text]);
|
||||
|
||||
async function maybeSaveCorrection() {
|
||||
if (!section) return;
|
||||
if (!isMeaningfulChange(originalRef.current, text)) return;
|
||||
try {
|
||||
await api.post('/api/memories/correction', {
|
||||
section,
|
||||
original_snippet: originalRef.current,
|
||||
corrected_snippet: text,
|
||||
});
|
||||
// Successful save → make the new text the baseline so successive
|
||||
// edits get tracked against the most-recently-saved version.
|
||||
originalRef.current = text;
|
||||
} catch {
|
||||
// Fail silently — correction tracking is best-effort, never blocks
|
||||
// the user's primary copy/refine/save flow.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card" data-testid={'editable-result-' + exportType}>
|
||||
<header className="px-4 py-2 border-b border-border bg-muted/40">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
</header>
|
||||
<textarea
|
||||
className="w-full p-4 text-sm bg-transparent resize-y min-h-[200px] focus:outline-none focus:ring-0 border-0 whitespace-pre-wrap"
|
||||
value={text}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={maybeSaveCorrection}
|
||||
data-testid={'editable-result-body-' + exportType}
|
||||
spellCheck
|
||||
/>
|
||||
<div className="px-4 pb-4">
|
||||
<OutputActions
|
||||
text={text}
|
||||
onUpdate={(t) => {
|
||||
// Refine / shorten output replaces the body — re-baseline.
|
||||
originalRef.current = t;
|
||||
onChange(t);
|
||||
}}
|
||||
sourceContext={sourceContext}
|
||||
exportLabel={exportLabel}
|
||||
exportType={exportType}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
159
client/src/components/EncounterToolbar.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// ============================================================
|
||||
// Encounter Toolbar — Save / New Patient / Load + label input.
|
||||
// Mirrors the vanilla saveFromTab / clearTab flow from
|
||||
// public/js/encounters.js. Each note page gets one of these so
|
||||
// transcripts + generated notes persist across sign-outs.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
saveEncounter, loadEncounter, listSavedEncounters,
|
||||
getSavedEncId, clearTabState,
|
||||
type EncType, type SavedEncounterListEntry, type LoadedEncounter,
|
||||
} from '@/lib/encounter-persistence';
|
||||
|
||||
const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
|
||||
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
|
||||
|
||||
interface Props {
|
||||
type: EncType;
|
||||
label: string;
|
||||
setLabel: (s: string) => void;
|
||||
transcript: string;
|
||||
generatedNote: string;
|
||||
partialData?: unknown;
|
||||
onLoad: (enc: LoadedEncounter) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function EncounterToolbar({
|
||||
type, label, setLabel, transcript, generatedNote, partialData, onLoad, onClear,
|
||||
}: Props) {
|
||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err' | 'info'; text: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [saved, setSaved] = useState<SavedEncounterListEntry[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [savedId, setSavedId] = useState<number | null>(getSavedEncId(type));
|
||||
|
||||
// Sync the savedId chip whenever sessionStorage changes (e.g. after Load).
|
||||
useEffect(() => {
|
||||
const v = getSavedEncId(type);
|
||||
setSavedId(v);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [transcript, generatedNote]);
|
||||
|
||||
async function save() {
|
||||
if (!label.trim()) { setMsg({ kind: 'err', text: 'Enter a patient label first' }); return; }
|
||||
setMsg(null); setSaving(true);
|
||||
try {
|
||||
const r = await saveEncounter({ type, label, transcript, generatedNote, partialData });
|
||||
setSavedId(r.id);
|
||||
setMsg({ kind: 'ok', text: 'Saved (' + label + ')' });
|
||||
} catch (e) {
|
||||
setMsg({ kind: 'err', text: (e as Error).message });
|
||||
} finally { setSaving(false); }
|
||||
}
|
||||
|
||||
function newPatient() {
|
||||
clearTabState(type);
|
||||
setSavedId(null);
|
||||
setLabel('');
|
||||
onClear();
|
||||
setMsg({ kind: 'info', text: 'New patient — fields cleared.' });
|
||||
}
|
||||
|
||||
async function openLoad() {
|
||||
if (popoverOpen) { setPopoverOpen(false); return; }
|
||||
setPopoverOpen(true);
|
||||
setLoadingList(true);
|
||||
try {
|
||||
const list = await listSavedEncounters();
|
||||
setSaved(list.filter((e) => e.enc_type === type));
|
||||
} finally { setLoadingList(false); }
|
||||
}
|
||||
async function pick(id: number) {
|
||||
try {
|
||||
const enc = await loadEncounter(id);
|
||||
setSavedId(id);
|
||||
onLoad(enc);
|
||||
setPopoverOpen(false);
|
||||
setMsg({ kind: 'ok', text: 'Loaded ' + (enc.label || '') });
|
||||
} catch (e) {
|
||||
setMsg({ kind: 'err', text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = search
|
||||
? saved.filter((e) => (e.label || '').toLowerCase().includes(search.toLowerCase()))
|
||||
: saved;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid={'enc-toolbar-' + type}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className={input + ' flex-1 min-w-[180px] max-w-md'}
|
||||
placeholder="Patient label (e.g. JD-2026-04-24)"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
data-testid="enc-label"
|
||||
/>
|
||||
<button type="button" onClick={save} disabled={saving} className={btnPrimary} data-testid="enc-save">
|
||||
💾 {saving ? 'Saving…' : (savedId != null ? 'Save (update)' : 'Save')}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button type="button" onClick={openLoad} className={btn} data-testid="enc-load-toggle">
|
||||
📂 Load
|
||||
</button>
|
||||
{popoverOpen && (
|
||||
<div className="absolute z-30 mt-1 right-0 w-80 max-h-96 overflow-auto rounded-md border border-border bg-card shadow-lg">
|
||||
<div className="sticky top-0 bg-card p-2 border-b border-border">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Filter saved encounters…"
|
||||
className={input + ' w-full text-xs'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
data-testid="enc-load-search"
|
||||
/>
|
||||
</div>
|
||||
{loadingList && <div className="p-3 text-xs text-muted-foreground">Loading…</div>}
|
||||
{!loadingList && filtered.length === 0 && (
|
||||
<div className="p-3 text-xs text-muted-foreground italic">No saved {type} encounters.</div>
|
||||
)}
|
||||
{filtered.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={() => pick(e.id)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-muted border-b border-border last:border-0"
|
||||
data-testid={'enc-load-pick-' + e.id}
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{e.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Updated {new Date(e.updated_at).toLocaleString()} · expires {new Date(e.expires_at).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={newPatient} className={btn} data-testid="enc-new">
|
||||
🆕 New patient
|
||||
</button>
|
||||
{savedId != null && (
|
||||
<span className="text-xs text-muted-foreground">Draft #{savedId}</span>
|
||||
)}
|
||||
</div>
|
||||
{msg && (
|
||||
<div className={
|
||||
'text-sm ' +
|
||||
(msg.kind === 'ok' ? 'text-green-600' : msg.kind === 'err' ? 'text-destructive' : 'text-muted-foreground')
|
||||
}>{msg.text}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
client/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// ============================================================
|
||||
// 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';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import type { MeOk } from '@/shared/types';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
available?: boolean; // false = rendered as "coming soon" stub
|
||||
adminOnly?: boolean; // renders only when me.user.role === 'admin'
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const NAV: NavGroup[] = [
|
||||
{
|
||||
label: 'Encounters',
|
||||
items: [
|
||||
{ to: '/encounter', label: 'Encounter HPI', available: true },
|
||||
{ to: '/dictation', label: 'Dictation HPI', available: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Notes',
|
||||
items: [
|
||||
{ to: '/hospital', label: 'Hospital Course', available: true },
|
||||
{ to: '/chart', label: 'Chart Review', available: true },
|
||||
{ to: '/soap', label: 'SOAP Note', available: true },
|
||||
{ to: '/wellvisit', label: 'Well Visit', available: true },
|
||||
{ to: '/sickvisit', label: 'Sick Visit', available: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Clinical Tools',
|
||||
items: [
|
||||
{ to: '/vaxschedule', label: 'Vaccine Schedule', available: true },
|
||||
{ to: '/catchup', label: 'Catch-Up Schedule', available: true },
|
||||
{ to: '/peguide', label: 'Physical Exam Guide', available: true },
|
||||
{ to: '/bedside', label: 'Bedside', available: true },
|
||||
{ to: '/calculators', label: 'Calculators', available: true },
|
||||
{ to: '/extensions', label: 'Pagers & Extensions', available: true },
|
||||
{ to: '/learning', label: 'Learning Hub', available: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Account',
|
||||
items: [
|
||||
{ to: '/settings', label: 'Settings', available: true },
|
||||
{ to: '/faq', label: 'FAQ', available: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
items: [
|
||||
{ to: '/admin', label: 'Admin Panel', available: true, adminOnly: true },
|
||||
{ to: '/cms', label: 'Content Manager', available: true, adminOnly: 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 }) {
|
||||
// One-shot /me fetch shared across the app via React Query cache.
|
||||
// Settings already uses this queryKey, so the Layout gets it for free
|
||||
// after the first Settings visit — and vice versa.
|
||||
const { data: me } = useQuery<MeOk>({
|
||||
queryKey: ['auth-me'],
|
||||
queryFn: () => api.get<MeOk>('/api/auth/me'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const isAdmin = me?.user.role === 'admin';
|
||||
|
||||
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 flex items-center justify-between gap-2">
|
||||
<div className="font-semibold">Pediatric AI Scribe</div>
|
||||
<a
|
||||
href="/api/auth/logout"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
} catch { /* ignore */ }
|
||||
window.location.href = '/auth';
|
||||
}}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
title="Sign out"
|
||||
>
|
||||
Sign out
|
||||
</a>
|
||||
</div>
|
||||
{NAV.map((group) => {
|
||||
const items = group.items.filter((i) => !i.adminOnly || isAdmin);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<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>
|
||||
{items.map((item) => (
|
||||
<SidebarLink key={item.to} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex-1 min-w-0">
|
||||
{children ?? <Outlet />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
client/src/components/OutputActions.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// ============================================================
|
||||
// OutputActions — Copy / Read / Export / Refine / Shorter bar
|
||||
// that sits under every generated note. Port of the vanilla
|
||||
// `output-actions` + `refine-bar` blocks (same buttons on every
|
||||
// note page component).
|
||||
//
|
||||
// Copy → navigator.clipboard
|
||||
// Read → POST /api/text-to-speech (MPEG bytes → Audio)
|
||||
// Export → POST /api/nextcloud/export
|
||||
// Refine → POST /api/refine (instructions + sourceContext)
|
||||
// Shorter→ POST /api/shorten
|
||||
//
|
||||
// Each button is independent — if Nextcloud is unconfigured, only
|
||||
// that one errors; the rest keep working.
|
||||
// ============================================================
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import type { RefineOk, ShortenOk } from '@/shared/types';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
// Applied after Refine / Shorter — parent updates its result state.
|
||||
onUpdate: (newText: string) => void;
|
||||
// Optional — passed to /api/refine as sourceContext so the model
|
||||
// can reference the original transcript when following instructions.
|
||||
sourceContext?: string;
|
||||
// Nextcloud filename prefix (e.g. 'hpi-encounter', 'soap-note').
|
||||
exportLabel: string;
|
||||
// Nextcloud docType (matches vanilla's data-label / type field).
|
||||
exportType: string;
|
||||
}
|
||||
|
||||
export default function OutputActions({
|
||||
text, onUpdate, sourceContext, exportLabel, exportType,
|
||||
}: Props) {
|
||||
const [instructions, setInstructions] = useState('');
|
||||
const [busy, setBusy] = useState<null | 'refine' | 'shorten' | 'tts' | 'export'>(null);
|
||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioUrlRef = useRef<string | null>(null);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setMsg({ kind: 'ok', text: 'Copied' });
|
||||
} catch {
|
||||
setMsg({ kind: 'err', text: 'Copy failed' });
|
||||
}
|
||||
}
|
||||
|
||||
async function read() {
|
||||
if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to read' }); return; }
|
||||
// If already playing, stop.
|
||||
if (audioRef.current && !audioRef.current.paused) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
if (audioUrlRef.current) { URL.revokeObjectURL(audioUrlRef.current); audioUrlRef.current = null; }
|
||||
return;
|
||||
}
|
||||
setBusy('tts'); setMsg(null);
|
||||
try {
|
||||
const resp = await fetch('/api/text-to-speech', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
const errMsg = ct.includes('json') ? ((await resp.json()).error || 'TTS failed') : 'TTS failed';
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
audioUrlRef.current = url;
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
if (audioUrlRef.current === url) audioUrlRef.current = null;
|
||||
if (audioRef.current === audio) audioRef.current = null;
|
||||
};
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
setMsg({ kind: 'err', text: (e as Error).message });
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
async function exportToNextcloud() {
|
||||
if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to export' }); return; }
|
||||
setBusy('export'); setMsg(null);
|
||||
try {
|
||||
const filename = exportLabel + '-' + Date.now();
|
||||
const data = await api.post<{ success: true; message: string }>('/api/nextcloud/export', {
|
||||
content: text, filename, type: exportType,
|
||||
});
|
||||
setMsg({ kind: 'ok', text: data.message || 'Exported' });
|
||||
} catch (e) {
|
||||
setMsg({ kind: 'err', text: (e as ApiError).message || 'Nextcloud not connected' });
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
async function refine() {
|
||||
if (!text.trim()) { setMsg({ kind: 'err', text: 'No document to refine' }); return; }
|
||||
if (!instructions.trim()) { setMsg({ kind: 'err', text: 'Enter instructions' }); return; }
|
||||
setBusy('refine'); setMsg(null);
|
||||
try {
|
||||
const data = await api.post<RefineOk>('/api/refine', {
|
||||
currentDocument: text,
|
||||
instructions: instructions.trim(),
|
||||
sourceContext: sourceContext || undefined,
|
||||
});
|
||||
onUpdate(data.refined);
|
||||
setInstructions('');
|
||||
setMsg({ kind: 'ok', text: 'Refined' });
|
||||
} catch (e) {
|
||||
setMsg({ kind: 'err', text: (e as ApiError).message || 'Refine failed' });
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
async function shorten() {
|
||||
if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to shorten' }); return; }
|
||||
setBusy('shorten'); setMsg(null);
|
||||
try {
|
||||
const data = await api.post<ShortenOk>('/api/shorten', { document: text });
|
||||
onUpdate(data.shortened);
|
||||
setMsg({ kind: 'ok', text: 'Shortened' });
|
||||
} catch (e) {
|
||||
setMsg({ kind: 'err', text: (e as ApiError).message || 'Shorten failed' });
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
|
||||
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid={'output-actions-' + exportType}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={copy} className={btnPrimary} data-testid="out-copy">
|
||||
📋 Copy
|
||||
</button>
|
||||
<button type="button" onClick={read} disabled={busy === 'tts'} className={btn} data-testid="out-read">
|
||||
{busy === 'tts' ? '⌛ Loading…' : (audioRef.current && !audioRef.current.paused ? '⏹ Stop' : '🔊 Read')}
|
||||
</button>
|
||||
<button type="button" onClick={exportToNextcloud} disabled={busy === 'export'} className={btn} data-testid="out-export">
|
||||
{busy === 'export' ? '⌛ Exporting…' : '☁️ Export to Nextcloud'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<textarea
|
||||
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[60px]"
|
||||
placeholder="Tell AI how to modify (e.g., 'make it shorter', 'add that patient has asthma history')"
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
data-testid="out-refine-input"
|
||||
/>
|
||||
<div className="flex sm:flex-col gap-2">
|
||||
<button type="button" onClick={refine} disabled={busy === 'refine' || !instructions.trim()} className={btnPrimary} data-testid="out-refine">
|
||||
{busy === 'refine' ? '⌛ Refining…' : '✏️ Refine'}
|
||||
</button>
|
||||
<button type="button" onClick={shorten} disabled={busy === 'shorten'} className={btn} data-testid="out-shorten">
|
||||
{busy === 'shorten' ? '⌛ Shortening…' : '📏 Shorter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={'text-xs ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
client/src/components/Recorder.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
// ============================================================
|
||||
// Recorder — shared mic-capture component for every note page.
|
||||
// Faithful React port of public/js/liveEncounter.js (and the
|
||||
// equivalent dictation/SOAP recording wrappers). Mirror behavior:
|
||||
// • Mic permission via getUserMedia (mono / 16 kHz / EC+NS)
|
||||
// • Live preview via Web Speech API (when enabled by user)
|
||||
// • Pause / Resume native MediaRecorder where supported
|
||||
// • On Stop → upload blob to /api/transcribe
|
||||
// • If transcription unavailable, fall back to the live preview text
|
||||
// • Failed uploads → /api/audio-backups (or IndexedDB) for retry
|
||||
//
|
||||
// The recorder is dumb about persistence — the parent note page
|
||||
// owns the transcript text and uses encounter-persistence.ts to
|
||||
// save/resume across sign-outs.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AudioRecorder } from '@/lib/recorder';
|
||||
import { transcribeAudio, isTranscribeAvailable, checkTranscribeStatus } from '@/lib/transcribe';
|
||||
import { createSpeechSession, isSpeechRecognitionEnabled, deduplicateFinal, type SpeechHandle } from '@/lib/web-speech';
|
||||
|
||||
interface Props {
|
||||
module: string; // 'encounter' | 'dictation' | 'soap' | …
|
||||
// Called when transcription completes. The text replaces (or merges with)
|
||||
// whatever the parent currently has in the transcript field.
|
||||
onTranscript: (text: string, meta: { provider?: string; durationSec: number; appended: boolean }) => void;
|
||||
// Called continuously with the live (interim) preview while recording.
|
||||
// Parents can show a faded "interim" string concatenated to the
|
||||
// confirmed transcript for instant visual feedback.
|
||||
onInterim?: (text: string) => void;
|
||||
// Called when transcription fails (so the parent can decide what to
|
||||
// do — typically appending the live preview text instead).
|
||||
onError?: (msg: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const btnRecord = 'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-semibold border transition-colors';
|
||||
|
||||
export default function Recorder({ module, onTranscript, onInterim, onError, disabled }: Props) {
|
||||
const recorderRef = useRef<AudioRecorder | null>(null);
|
||||
const speechRef = useRef<SpeechHandle | null>(null);
|
||||
const finalTextRef = useRef('');
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const pauseAccumRef = useRef<number>(0);
|
||||
const pauseStartRef = useRef<number>(0);
|
||||
|
||||
const [state, setState] = useState<'idle' | 'recording' | 'paused' | 'transcribing'>('idle');
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTranscribeAvailable() === null) checkTranscribeStatus();
|
||||
}, []);
|
||||
|
||||
// Stop everything cleanly on unmount (page navigation while recording).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) window.clearInterval(intervalRef.current);
|
||||
if (speechRef.current) speechRef.current.stop();
|
||||
if (recorderRef.current) recorderRef.current.stop().catch(() => { /* ignore */ });
|
||||
};
|
||||
}, []);
|
||||
|
||||
function tickStart() {
|
||||
if (intervalRef.current) window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
const elapsed = Math.floor((now - startTimeRef.current - pauseAccumRef.current) / 1000);
|
||||
setSeconds(elapsed);
|
||||
}, 1000);
|
||||
}
|
||||
function tickStop() {
|
||||
if (intervalRef.current) { window.clearInterval(intervalRef.current); intervalRef.current = null; }
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (state !== 'idle') return;
|
||||
finalTextRef.current = '';
|
||||
pauseAccumRef.current = 0;
|
||||
setSeconds(0);
|
||||
try {
|
||||
const rec = new AudioRecorder();
|
||||
await rec.start();
|
||||
recorderRef.current = rec;
|
||||
startTimeRef.current = Date.now();
|
||||
tickStart();
|
||||
setState('recording');
|
||||
// Live preview via Web Speech (only if user enabled it in Settings).
|
||||
if (isSpeechRecognitionEnabled()) {
|
||||
const handle = createSpeechSession({
|
||||
onFinal: (chunk) => {
|
||||
const deduped = deduplicateFinal(chunk, finalTextRef.current);
|
||||
finalTextRef.current += deduped;
|
||||
onInterim?.(finalTextRef.current);
|
||||
},
|
||||
onInterim: (interim) => onInterim?.(finalTextRef.current + interim),
|
||||
onError: () => { /* swallow */ },
|
||||
});
|
||||
speechRef.current = handle;
|
||||
handle?.start();
|
||||
}
|
||||
} catch {
|
||||
onError?.('Microphone permission denied');
|
||||
setState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (state !== 'recording') return;
|
||||
recorderRef.current?.pause();
|
||||
speechRef.current?.stop();
|
||||
pauseStartRef.current = Date.now();
|
||||
tickStop();
|
||||
setState('paused');
|
||||
}
|
||||
function resume() {
|
||||
if (state !== 'paused') return;
|
||||
recorderRef.current?.resume();
|
||||
pauseAccumRef.current += Date.now() - pauseStartRef.current;
|
||||
tickStart();
|
||||
if (isSpeechRecognitionEnabled() && !speechRef.current) {
|
||||
const handle = createSpeechSession({
|
||||
onFinal: (chunk) => {
|
||||
const deduped = deduplicateFinal(chunk, finalTextRef.current);
|
||||
finalTextRef.current += deduped;
|
||||
onInterim?.(finalTextRef.current);
|
||||
},
|
||||
onInterim: (interim) => onInterim?.(finalTextRef.current + interim),
|
||||
});
|
||||
speechRef.current = handle;
|
||||
}
|
||||
speechRef.current?.start();
|
||||
setState('recording');
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (state !== 'recording' && state !== 'paused') return;
|
||||
const liveText = finalTextRef.current.trim();
|
||||
speechRef.current?.stop();
|
||||
speechRef.current = null;
|
||||
tickStop();
|
||||
const dur = seconds;
|
||||
setState('transcribing');
|
||||
try {
|
||||
const blob = await recorderRef.current!.stop();
|
||||
recorderRef.current = null;
|
||||
if (!blob || blob.size === 0) {
|
||||
// Recording produced nothing — fall back to live preview if any.
|
||||
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
// Server-side too-large guard mirrors the vanilla 24 MB cap.
|
||||
if (blob.size > 24 * 1024 * 1024) {
|
||||
onError?.('Recording too large for AI transcription — using live transcript');
|
||||
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
const result = await transcribeAudio(blob, module);
|
||||
if (result.success && result.text) {
|
||||
onTranscript(result.text, { provider: result.provider, durationSec: dur, appended: false });
|
||||
} else if (result.noProvider) {
|
||||
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
|
||||
else onError?.('No transcription API configured');
|
||||
} else {
|
||||
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
|
||||
else onError?.(result.error || 'Transcription failed');
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.((e as Error).message);
|
||||
} finally {
|
||||
setState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
|
||||
const ss = String(seconds % 60).padStart(2, '0');
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2" data-testid={'recorder-' + module}>
|
||||
{state === 'idle' && (
|
||||
<button type="button" onClick={start} disabled={disabled}
|
||||
className={btnRecord + ' bg-destructive text-white border-destructive hover:bg-red-700'}
|
||||
data-testid="recorder-start">
|
||||
🎙 Start recording
|
||||
</button>
|
||||
)}
|
||||
{(state === 'recording' || state === 'paused') && (
|
||||
<>
|
||||
<button type="button" onClick={state === 'recording' ? pause : resume}
|
||||
className={btnRecord + ' bg-amber-500 text-white border-amber-500 hover:bg-amber-600'}
|
||||
data-testid="recorder-pause">
|
||||
{state === 'recording' ? '⏸ Pause' : '▶ Resume'}
|
||||
</button>
|
||||
<button type="button" onClick={stop}
|
||||
className={btnRecord + ' bg-slate-800 text-white border-slate-800 hover:bg-slate-900'}
|
||||
data-testid="recorder-stop">
|
||||
⏹ Stop
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-destructive font-mono"
|
||||
data-testid="recorder-timer">
|
||||
<span className={state === 'recording' ? 'animate-pulse' : 'opacity-50'}>●</span>
|
||||
{state === 'paused' ? 'paused ' : ''}{mm}:{ss}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{state === 'transcribing' && (
|
||||
<span className="text-sm text-muted-foreground" data-testid="recorder-transcribing">
|
||||
⌛ Transcribing…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
client/src/components/RichTextEditor.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// ============================================================
|
||||
// RichTextEditor — Tiptap/ProseMirror editor matching the vanilla
|
||||
// tp-toolbar feature set from public/js/learningHub.js (@be14578).
|
||||
//
|
||||
// Toolbar: bold / italic / underline / strike | H2 / H3 |
|
||||
// bulletList / orderedList / blockquote / codeBlock |
|
||||
// link (with URL bar) | clear formatting
|
||||
//
|
||||
// Emits HTML on change — the CMS server stores body as HTML, so
|
||||
// what you type here is what Learning Hub readers see.
|
||||
//
|
||||
// Variants:
|
||||
// default — full toolbar (content body)
|
||||
// mini — bold/italic/list/link only (short fields)
|
||||
// option — bold/italic/link only (quiz option text)
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
|
||||
type Variant = 'default' | 'mini' | 'option';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
variant?: Variant;
|
||||
placeholder?: string;
|
||||
minHeight?: string; // tailwind arbitrary value, e.g. 'min-h-[280px]'
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
const btn = 'inline-flex items-center justify-center min-w-[28px] h-7 px-2 rounded text-xs border border-transparent hover:bg-muted';
|
||||
const btnActive = btn + ' bg-muted border-border';
|
||||
const sep = 'inline-block w-px h-5 bg-border mx-1 align-middle';
|
||||
|
||||
function Tool({ on, active, title, children }: { on: () => void; active: boolean; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); on(); }}
|
||||
className={active ? btnActive : btn}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkBar({ editor, open, setOpen }: { editor: Editor; open: boolean; setOpen: (b: boolean) => void }) {
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setUrl(editor.getAttributes('link').href || '');
|
||||
}, [open, editor]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function apply() {
|
||||
const trimmed = url.trim();
|
||||
if (trimmed) editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run();
|
||||
setOpen(false);
|
||||
}
|
||||
function remove() {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-t border-border bg-muted/30">
|
||||
<input
|
||||
type="url"
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); apply(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
|
||||
}}
|
||||
placeholder="https://"
|
||||
className="flex-1 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<button type="button" onMouseDown={(e) => { e.preventDefault(); apply(); }}
|
||||
className="rounded bg-primary text-primary-foreground px-2 py-1 text-xs">
|
||||
Apply
|
||||
</button>
|
||||
<button type="button" onMouseDown={(e) => { e.preventDefault(); remove(); }}
|
||||
className="rounded border border-border px-2 py-1 text-xs">
|
||||
Remove
|
||||
</button>
|
||||
<button type="button" onMouseDown={(e) => { e.preventDefault(); setOpen(false); }}
|
||||
className="rounded border border-border px-2 py-1 text-xs">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({ editor, variant }: { editor: Editor; variant: Variant }) {
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
|
||||
const toggleLink = useCallback(() => setLinkOpen((v) => !v), []);
|
||||
|
||||
const bold = (
|
||||
<Tool on={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold"><strong>B</strong></Tool>
|
||||
);
|
||||
const italic = (
|
||||
<Tool on={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic"><em>I</em></Tool>
|
||||
);
|
||||
const underline = (
|
||||
<Tool on={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline"><span style={{ textDecoration: 'underline' }}>U</span></Tool>
|
||||
);
|
||||
const strike = (
|
||||
<Tool on={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strike"><span style={{ textDecoration: 'line-through' }}>S</span></Tool>
|
||||
);
|
||||
const h2 = (
|
||||
<Tool on={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2">H2</Tool>
|
||||
);
|
||||
const h3 = (
|
||||
<Tool on={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">H3</Tool>
|
||||
);
|
||||
const bullet = (
|
||||
<Tool on={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list">•</Tool>
|
||||
);
|
||||
const ordered = (
|
||||
<Tool on={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">1.</Tool>
|
||||
);
|
||||
const quote = (
|
||||
<Tool on={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote">❝</Tool>
|
||||
);
|
||||
const code = (
|
||||
<Tool on={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Code">{'</>'}</Tool>
|
||||
);
|
||||
const link = (
|
||||
<Tool on={toggleLink} active={editor.isActive('link')} title="Link">🔗</Tool>
|
||||
);
|
||||
const clear = (
|
||||
<Tool on={() => editor.chain().focus().unsetAllMarks().clearNodes().run()} active={false} title="Clear formatting">⌫</Tool>
|
||||
);
|
||||
|
||||
let buttons: React.ReactNode;
|
||||
if (variant === 'option') {
|
||||
buttons = <>{bold}{italic}{link}</>;
|
||||
} else if (variant === 'mini') {
|
||||
buttons = <>{bold}{italic}{bullet}{link}</>;
|
||||
} else {
|
||||
buttons = (
|
||||
<>
|
||||
{bold}{italic}{underline}{strike}
|
||||
<span className={sep} />
|
||||
{h2}{h3}
|
||||
<span className={sep} />
|
||||
{bullet}{ordered}{quote}{code}
|
||||
<span className={sep} />
|
||||
{link}
|
||||
<span className={sep} />
|
||||
{clear}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1 bg-muted/40 border-b border-border">
|
||||
{buttons}
|
||||
</div>
|
||||
<LinkBar editor={editor} open={linkOpen} setOpen={setLinkOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RichTextEditor({
|
||||
value, onChange, variant = 'default', placeholder, minHeight = 'min-h-[240px]', testId,
|
||||
}: Props) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
||||
Underline,
|
||||
Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' } }),
|
||||
],
|
||||
content: value,
|
||||
onUpdate: ({ editor: ed }) => onChange(ed.getHTML()),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none dark:prose-invert px-3 py-2 ' + minHeight + ' focus:outline-none',
|
||||
'data-placeholder': placeholder || '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sync external value changes back into the editor (e.g. when the user
|
||||
// switches to a different CMS item — parent updates `value`).
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const current = editor.getHTML();
|
||||
if (value !== current) editor.commands.setContent(value || '', { emitUpdate: false });
|
||||
}, [value, editor]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-input bg-background overflow-hidden"
|
||||
data-testid={testId}
|
||||
>
|
||||
<Toolbar editor={editor} variant={variant} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
client/src/components/RosPeTable.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// ============================================================
|
||||
// RosPeTable — structured ROS or PE input. Per-system row with
|
||||
// WNL / Abnormal / Not reviewed toggle + a note field that reveals
|
||||
// when "Abnormal" is selected. Mirrors window.renderRosRows /
|
||||
// wireRosContainer from public/js/shadess.js (@be14578).
|
||||
//
|
||||
// Consumers (WellVisit Note, Sick Visit Note) pass systems list +
|
||||
// data object + label set so the same component renders both
|
||||
// review-of-systems and physical-exam tables.
|
||||
// ============================================================
|
||||
|
||||
import type { RosData, RosStatus, SystemEntry } from '@shared/clinical/ros-pe-dx';
|
||||
|
||||
interface BtnLabels { wnl: string; abnormal: string; notrev: string }
|
||||
|
||||
interface Props {
|
||||
systems: ReadonlyArray<SystemEntry>;
|
||||
data: RosData;
|
||||
onChange: (next: RosData) => void;
|
||||
btnLabels?: BtnLabels;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export function rosAllWnl(systems: ReadonlyArray<SystemEntry>, data: RosData): RosData {
|
||||
const next: RosData = { ...data };
|
||||
for (const s of systems) next[s.key] = { status: 'wnl', note: next[s.key]?.note };
|
||||
return next;
|
||||
}
|
||||
export function rosClear(data: RosData, systems: ReadonlyArray<SystemEntry>): RosData {
|
||||
const next: RosData = { ...data };
|
||||
for (const s of systems) next[s.key] = {};
|
||||
return next;
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS: BtnLabels = { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' };
|
||||
|
||||
function statusClass(active: boolean, kind: RosStatus) {
|
||||
if (!active) return 'bg-background border-border hover:bg-muted';
|
||||
if (kind === 'wnl') return 'bg-green-100 text-green-800 border-green-300';
|
||||
if (kind === 'abnormal') return 'bg-red-100 text-red-800 border-red-300';
|
||||
return 'bg-muted text-muted-foreground border-border';
|
||||
}
|
||||
|
||||
export default function RosPeTable({
|
||||
systems, data, onChange, btnLabels, testIdPrefix = 'ros',
|
||||
}: Props) {
|
||||
const labels = btnLabels || DEFAULT_LABELS;
|
||||
|
||||
function setStatus(key: string, status: RosStatus) {
|
||||
const cur = data[key]?.status || '';
|
||||
const nextStatus: RosStatus = cur === status ? '' : status;
|
||||
onChange({ ...data, [key]: { status: nextStatus, note: data[key]?.note || '' } });
|
||||
}
|
||||
function setNote(key: string, note: string) {
|
||||
onChange({ ...data, [key]: { status: data[key]?.status || '', note } });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border" data-testid={testIdPrefix + '-table'}>
|
||||
{systems.map((sys) => {
|
||||
const cell = data[sys.key] || {};
|
||||
const active = cell.status || '';
|
||||
return (
|
||||
<div key={sys.key} className="flex flex-wrap items-center gap-2 px-2 py-1.5" data-testid={testIdPrefix + '-row-' + sys.key}>
|
||||
<div className="flex-1 min-w-[180px]" title={sys.detail}>
|
||||
<span className="text-sm font-medium">{sys.label}</span>
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">({sys.detail})</span>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{(['wnl', 'abnormal', 'notrev'] as const).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => setStatus(sys.key, kind)}
|
||||
className={'text-[10px] uppercase tracking-wider px-2 py-1 rounded border ' + statusClass(active === kind, kind)}
|
||||
data-testid={testIdPrefix + '-btn-' + sys.key + '-' + kind}
|
||||
>
|
||||
{labels[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{active === 'abnormal' && (
|
||||
<input
|
||||
type="text"
|
||||
value={cell.note || ''}
|
||||
onChange={(e) => setNote(sys.key, e.target.value)}
|
||||
placeholder="Describe finding…"
|
||||
className="flex-1 min-w-[200px] rounded-md border border-input bg-background px-2 py-1 text-xs"
|
||||
data-testid={testIdPrefix + '-note-' + sys.key}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
client/src/components/Turnstile.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// ============================================================
|
||||
// Turnstile widget wrapper. Loads the cloudflare challenges script
|
||||
// once, renders a widget when mounted, exposes the resolved token
|
||||
// via onToken. If `siteKey` is null/empty (e.g. e2e container with
|
||||
// TURNSTILE_SITE_KEY=""), renders nothing and auto-reports an empty
|
||||
// token so the surrounding form can submit unchanged — this mirrors
|
||||
// the vanilla behaviour where an unset site key is a no-op.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
|
||||
reset: (widgetId: string) => void;
|
||||
remove: (widgetId: string) => void;
|
||||
};
|
||||
onTurnstileLoad?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad';
|
||||
let loadedOrLoading = false;
|
||||
const readyCallbacks: Array<() => void> = [];
|
||||
|
||||
function ensureScript(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof window === 'undefined') { resolve(); return; }
|
||||
if (window.turnstile) { resolve(); return; }
|
||||
readyCallbacks.push(resolve);
|
||||
if (loadedOrLoading) return;
|
||||
loadedOrLoading = true;
|
||||
window.onTurnstileLoad = () => {
|
||||
for (const cb of readyCallbacks.splice(0)) cb();
|
||||
};
|
||||
const s = document.createElement('script');
|
||||
s.src = SCRIPT_SRC;
|
||||
s.async = true;
|
||||
s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
interface Props {
|
||||
siteKey: string | null | undefined;
|
||||
onToken: (token: string) => void;
|
||||
action?: string;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
}
|
||||
|
||||
export default function Turnstile({ siteKey, onToken, action, theme = 'light' }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey) { onToken(''); return; }
|
||||
let cancelled = false;
|
||||
ensureScript().then(() => {
|
||||
if (cancelled || !containerRef.current || !window.turnstile) return;
|
||||
try {
|
||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
theme,
|
||||
action,
|
||||
callback: (token: string) => onToken(token),
|
||||
'error-callback': () => onToken(''),
|
||||
'expired-callback': () => onToken(''),
|
||||
});
|
||||
} catch { /* ignore render errors — e.g. repeated mount */ }
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
try { window.turnstile.remove(widgetIdRef.current); } catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [siteKey]);
|
||||
|
||||
if (!siteKey) return null;
|
||||
return <div ref={containerRef} className="cf-turnstile my-2" />;
|
||||
}
|
||||
150
client/src/data/faq.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
export const FAQ_DATA = [
|
||||
{
|
||||
"section": "Getting Started",
|
||||
"items": [
|
||||
{
|
||||
"q": "What is Pediatric AI Scribe?",
|
||||
"a": "Pediatric AI Scribe is an AI-powered clinical documentation tool designed specifically for pediatric medicine. It helps physicians generate structured clinical notes from voice recordings or typed text, saving time on documentation so you can focus on patient care. It supports HPIs, SOAP notes, hospital courses, chart reviews, well visits, sick visits, developmental milestone assessments, and more."
|
||||
},
|
||||
{
|
||||
"q": "How do I create my first note?",
|
||||
"a": "The easiest way to start is with Live Encounter: Go to the Encounter tab Enter the patient's age and gender Click Start Recording and speak naturally during your patient encounter Click Stop when done — the audio is transcribed automatically Click Generate HPI to create a structured note Edit the note as needed, then Copy to paste into your EHR"
|
||||
},
|
||||
{
|
||||
"q": "Can I type or paste notes instead of recording?",
|
||||
"a": "Yes. Every transcript box is editable. You can type directly, paste from another source, or combine typed text with a recording. The AI works with whatever text is in the transcript area when you click Generate."
|
||||
},
|
||||
{
|
||||
"q": "What types of notes can I generate?",
|
||||
"a": "HPI — from live encounters or dictation, with OLDCARTS structure SOAP Notes — full SOAP or subjective-only from dictation Hospital Course — from progress notes, in prose, day-by-day, or organ-system format Chart Review — summarize outpatient, subspecialty, or ED visits for precharting Well Visit — complete preventive care notes with SSHADESS, milestones, and vaccines Sick Visit — quick documentation with auto-suggested ROS and PE Milestone Assessment — developmental narrative from selected milestones"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "AI & Models",
|
||||
"items": [
|
||||
{
|
||||
"q": "What AI model should I use?",
|
||||
"a": "Each tab has a model selector dropdown. All available models have been tested and configured by your administrator for clinical documentation quality. They are routed through HIPAA-compliant providers with signed Business Associate Agreements (BAAs). All models are capable of generating accurate clinical notes. If you are unsure which to pick, start with the default. You can experiment with different models and see which output style you prefer — some may be faster, some more detailed, some more concise. You can choose a different model per tab depending on the task."
|
||||
},
|
||||
{
|
||||
"q": "Does the AI learn from my edits?",
|
||||
"a": "Yes. The app uses a correction tracking system inspired by Dragon Medical's adaptive learning. Here is how it works: When the AI generates a note, the original output is stored in memory You edit the note to match your preferred style — fix phrasing, add details, restructure sections When you click Save, the app detects what you changed and stores the correction On future notes, your past corrections are included as style hints so the AI adapts to your documentation preferences The more you use the app and save your edits, the better the AI gets at matching your style. You can view and manage your stored corrections in Settings > AI Corrections. Note: Corrections are applied as gentle suggestions, not strict rules. The AI prioritizes clinical accuracy over style matching."
|
||||
},
|
||||
{
|
||||
"q": "Can I customize the AI's prompts?",
|
||||
"a": "Administrators can edit all AI prompts from the Admin Panel > Settings > Prompts section. This lets you adjust the instructions the AI follows for each note type without changing any code. Changes take effect immediately."
|
||||
},
|
||||
{
|
||||
"q": "What does the \"Refine\" button do?",
|
||||
"a": "After generating a note, you can give the AI plain-language instructions to modify it. For example: \"Make it shorter\" \"Add that the patient has a history of asthma\" \"Summarize the labs\" \"Change the assessment to include bronchiolitis\" The AI references both its current output and your original source material (transcript, pasted notes, labs) when refining, so it can look up details from the original input."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "Voice & Transcription",
|
||||
"items": [
|
||||
{
|
||||
"q": "How does voice transcription work?",
|
||||
"a": "When you stop recording, the audio is sent to a speech-to-text service that converts it to text. The app supports multiple transcription providers including Whisper, Deepgram, and Google Gemini. Your administrator configures which provider is used. You will see a blue status bar at the top while transcription is in progress. You can continue working on the page while it processes."
|
||||
},
|
||||
{
|
||||
"q": "What is Browser Whisper?",
|
||||
"a": "Browser Whisper runs the Whisper AI model entirely in your browser using WebAssembly. Your audio never leaves your device, making it the most private transcription option. You can enable it in Settings > Browser Whisper. It works offline and is HIPAA-safe since no data is transmitted. The tradeoff is that it is slower than cloud-based transcription and requires downloading the model (~40–240 MB) on first use."
|
||||
},
|
||||
{
|
||||
"q": "Can I use the app on my phone?",
|
||||
"a": "Yes. The app is a Progressive Web App (PWA) that works in any modern browser. On mobile: Open the app in Chrome or Safari Tap \"Add to Home Screen\" to install it as a standalone app Recording works in the foreground, but audio stops if you lock the screen or switch apps on iOS and most Android devices — this is a browser limitation, not specific to this app On desktop, recording continues normally when the browser is minimized or in the background."
|
||||
},
|
||||
{
|
||||
"q": "What happens if transcription fails?",
|
||||
"a": "If transcription fails, your audio is automatically backed up to the server for 24 hours. You can retry transcription from Settings > Audio Backups. If browser speech recognition was active during recording, the live transcript is preserved as a fallback."
|
||||
},
|
||||
{
|
||||
"q": "Can the AI read my notes aloud?",
|
||||
"a": "Yes. Click the Read button on any generated note to hear it spoken aloud. This uses text-to-speech (TTS) powered by Google, OpenAI, or ElevenLabs depending on your setup. You can choose your preferred voice in Settings > Voice Preferences."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "Saving & Export",
|
||||
"items": [
|
||||
{
|
||||
"q": "Are my encounters saved?",
|
||||
"a": "You can save encounters using the Save button at the top of each tab. Saved encounters include the transcript, generated note, and patient label. You can reload them later using the Load button. Saved encounters are automatically deleted after 7 days (configurable by your administrator). This is intentional — the app is a documentation tool, not a medical record system. Copy your final notes to your EHR for permanent storage."
|
||||
},
|
||||
{
|
||||
"q": "How do I export notes?",
|
||||
"a": "Copy — one-click copy to clipboard, ready to paste into any EHR Nextcloud — export directly to your Nextcloud instance (configure in Settings) Documents — upload files to S3-compatible storage from Settings All generated text is plain text with no markdown formatting, designed to paste cleanly into any EHR system."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "Privacy & Security",
|
||||
"items": [
|
||||
{
|
||||
"q": "Is my patient data safe?",
|
||||
"a": "The app is designed with clinical privacy in mind: All connections use HTTPS/TLS encryption Audio and encounter data are temporary — auto-deleted within hours or days No patient data is stored long-term on the server Every action is audit-logged (who accessed what, when) Two-factor authentication (2FA) and session management are available Browser Whisper keeps audio entirely on your device For HIPAA compliance, ensure your administrator has configured a BAA-covered AI provider (such as AWS Bedrock, Google Vertex AI, or Azure OpenAI)."
|
||||
},
|
||||
{
|
||||
"q": "What is two-factor authentication (2FA)?",
|
||||
"a": "2FA adds an extra layer of security to your account. After entering your password, you also enter a 6-digit code from an authenticator app (like Google Authenticator or Authy). Enable it in Settings > Two-Factor Authentication."
|
||||
},
|
||||
{
|
||||
"q": "How do I manage my active sessions?",
|
||||
"a": "Go to Settings > Active Sessions to see all devices where you are logged in. You can revoke any session individually or click Revoke All Other Sessions to log out every other device. Your current session is highlighted and cannot be revoked from this screen — use the Logout button instead."
|
||||
},
|
||||
{
|
||||
"q": "What happens when I change my password?",
|
||||
"a": "When you change your password in Settings > Change Password, all other active sessions are automatically logged out for security. Only your current session remains active. The app also checks if your new password has appeared in known data breaches and warns you (but does not prevent you from using it)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "Well Visit & Sick Visit",
|
||||
"items": [
|
||||
{
|
||||
"q": "How does the Well Visit tab work?",
|
||||
"a": "The Well Visit tab follows the AAP Bright Futures periodicity schedule. It includes: Visit by Age — recommended screenings, vaccines, and anticipatory guidance for each visit age Milestones — developmental milestone tracker from birth through 11 years across multiple domains SSHADESS — adolescent psychosocial assessment (Strengths, School, Home, Activities, Drugs, Emotions, Sexuality, Safety) for ages 12+ Visit Note — generates a complete well visit note combining ROS, PE, milestones, and SSHADESS data"
|
||||
},
|
||||
{
|
||||
"q": "What do the WNL / Abnormal / Not Reviewed buttons do?",
|
||||
"a": "In the ROS and Physical Exam sections, each system has three options: WNL / Normal — within normal limits, no concerns Abnormal — a text box appears so you can describe the finding Not Reviewed / Not Examined — explicitly not assessed Use All WNL to quickly mark everything normal, then click individual systems to change specific ones. Use Clear to reset all selections."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "Learning Hub",
|
||||
"items": [
|
||||
{
|
||||
"q": "What is the Learning Hub?",
|
||||
"a": "The Learning Hub is an educational platform integrated into the app. It contains articles, clinical pearls, quizzes, and slide presentations created by moderators and administrators. You can browse by category, search content, and take quizzes to test your knowledge."
|
||||
},
|
||||
{
|
||||
"q": "How do quizzes work?",
|
||||
"a": "Quizzes include multiple-choice, multi-select, and true/false questions. After submitting your answers, you see your score along with explanations for each question. Your past attempts and scores are tracked so you can monitor your progress over time."
|
||||
},
|
||||
{
|
||||
"q": "Can I create Learning Hub content?",
|
||||
"a": "Moderators and administrators can create content using the CMS tab. You can write articles manually, or use AI to generate content from a topic description, uploaded PDFs, or files from Nextcloud. The CMS also supports Marp-based slide presentations with PPTX export."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "Pediatric Calculators",
|
||||
"items": [
|
||||
{
|
||||
"q": "What calculators are available?",
|
||||
"a": "The Calculators tab includes clinical tools commonly used in pediatric practice: Blood Pressure Percentile — AAP 2017 guidelines using the Rosner quantile spline regression method. Requires age, sex, height, and BP. Provides exact systolic and diastolic percentiles adjusted for height, with AAP classification (Normal, Elevated, Stage 1, Stage 2). Includes definitions of hypertension and hypotension. BMI Percentile — CDC 2000 growth reference with extended obesity classification (Class 1, 2, 3 using % of 95th percentile). Shows BMI chart with percentile curves. Growth Charts — Visual percentile curves (3rd through 97th) with your patient plotted. Includes Weight-for-Age, Length/Height-for-Age (with mid-parental height), Head Circumference, Weight-for-Length, and Fenton preterm charts. Bilirubin — AAP 2022 phototherapy threshold calculator and Bhutani hour-specific nomogram with risk zone classification. Includes Nelson Table 137.1 risk factors. Vital Signs by Age — Harriet Lane reference table for HR, RR, BP, and weight by age from preterm through 18 years. Includes quick formulas for estimated weight, minimum SBP, ETT size, and maintenance fluids. Body Surface Area — Mosteller formula for BSA calculation. Weight-Based Dosing — Dose per kg with frequency, max dose cap, and volume calculation from concentration."
|
||||
},
|
||||
{
|
||||
"q": "How accurate is the BP calculator?",
|
||||
"a": "The BP calculator uses the same Rosner quantile spline regression method as the Baylor College of Medicine reference calculator. It computes exact percentiles (1st-99th) based on your patient's age, sex, and height using published regression coefficients. This is the same methodology underlying the AAP 2017 normative tables. Results are height-adjusted and clinically accurate."
|
||||
},
|
||||
{
|
||||
"q": "What are the growth chart curves?",
|
||||
"a": "The growth charts display WHO/CDC percentile curves (3rd, 5th, 10th, 25th, 50th, 75th, 90th, 95th, 97th percentiles) with your patient's measurement plotted as a blue dot. The 50th percentile is shown as a bold green line. Shaded bands show the normal range between symmetric percentiles. For Length/Height-for-Age, you can optionally enter both parents' heights to see the mid-parental target height range plotted on the chart."
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
90
client/src/data/pe-data.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// ============================================================
|
||||
// PE_DATA parity counts — catches the class of bug where an LLM
|
||||
// silently drops entries from a long clinical array during a port.
|
||||
// Numbers captured 2026-04-24 against public/js/peGuide.js commit
|
||||
// 313ba7f. If the vanilla source changes, update both files in the
|
||||
// same commit.
|
||||
// ============================================================
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PE_DATA, AGE_GROUP_ORDER, SYSTEM_ORDER } from './pe-data';
|
||||
|
||||
describe('PE_DATA shape', () => {
|
||||
it('has the six expected age groups', () => {
|
||||
expect(Object.keys(PE_DATA).sort()).toEqual([...AGE_GROUP_ORDER].sort());
|
||||
});
|
||||
|
||||
it.each([...AGE_GROUP_ORDER])('%s has all four systems', (age) => {
|
||||
const group = PE_DATA[age];
|
||||
for (const s of SYSTEM_ORDER) {
|
||||
expect(group[s]).toBeDefined();
|
||||
expect(group[s].overview.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(group[s].components)).toBe(true);
|
||||
expect(group[s].components.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('component + abnormalHints + pearl + significance counts match vanilla', () => {
|
||||
let componentCount = 0;
|
||||
let pearlCount = 0;
|
||||
let significanceCount = 0;
|
||||
for (const age of AGE_GROUP_ORDER) {
|
||||
for (const sys of SYSTEM_ORDER) {
|
||||
const comps = PE_DATA[age][sys].components;
|
||||
for (const c of comps) {
|
||||
componentCount++;
|
||||
if (c.pearl) pearlCount++;
|
||||
if (c.significance) significanceCount++;
|
||||
expect(Array.isArray(c.steps)).toBe(true);
|
||||
expect(c.steps.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(c.abnormalHints)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Locked against vanilla peGuide.js (2026-04-24):
|
||||
// 103 components, 27 pearl, 23 significance.
|
||||
expect(componentCount).toBe(103);
|
||||
expect(pearlCount).toBe(27);
|
||||
expect(significanceCount).toBe(23);
|
||||
});
|
||||
});
|
||||
|
||||
// Per-age-group × per-system component counts — captured from the
|
||||
// legacy file with:
|
||||
// awk 'NR>=316 && NR<=1334' public/js/peGuide.js |
|
||||
// awk '/^ [a-z]+: \{$/{age=$1} /^ [a-z]+: \{$/{sys=$1}
|
||||
// /^ { name:/{c[age" "sys]++} END{for(k in c) print k" "c[k]}' | sort
|
||||
// so any drift in the TS port surfaces as a failing test here.
|
||||
describe('PE_DATA per-cell component counts', () => {
|
||||
const EXPECTED: Record<string, number> = {
|
||||
'newborn msk': 6,
|
||||
'newborn neuro': 6,
|
||||
'newborn resp': 2,
|
||||
'newborn cv': 2,
|
||||
'infant msk': 4,
|
||||
'infant neuro': 5,
|
||||
'infant resp': 2,
|
||||
'infant cv': 2,
|
||||
'toddler msk': 5,
|
||||
'toddler neuro': 7,
|
||||
'toddler resp': 2,
|
||||
'toddler cv': 2,
|
||||
'preschool msk': 5,
|
||||
'preschool neuro': 7,
|
||||
'preschool resp': 2,
|
||||
'preschool cv': 2,
|
||||
'school msk': 5,
|
||||
'school neuro': 7,
|
||||
'school resp': 3,
|
||||
'school cv': 2,
|
||||
'adolescent msk': 6,
|
||||
'adolescent neuro': 8,
|
||||
'adolescent resp': 6,
|
||||
'adolescent cv': 5,
|
||||
};
|
||||
|
||||
it.each(Object.entries(EXPECTED))('%s matches', (key, expected) => {
|
||||
const [age, sys] = key.split(' ') as ['newborn', 'msk'];
|
||||
expect(PE_DATA[age][sys].components.length).toBe(expected);
|
||||
});
|
||||
});
|
||||
1082
client/src/data/pe-data.ts
Normal file
290
client/src/data/pe-guide.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
// ============================================================
|
||||
// PE-GUIDE DATA — ported verbatim from public/js/peGuide.js
|
||||
// (lines 23-311 of the vanilla file, as of commit before this one).
|
||||
//
|
||||
// This file ONLY contains the stable reference data:
|
||||
// • SCALES — grading scales (MRC, DTR, Levine, Beighton, …)
|
||||
// • SYSTEM_SCALES — which scales belong to which body system
|
||||
// • APTM_LEGEND — the 5 cardiac auscultation points
|
||||
// • INNOCENT_MURMURS — benign childhood murmurs
|
||||
// • RESP_SOUNDS — respiratory sounds library (audio paths)
|
||||
// • CARDIAC_SOUNDS — cardiac sounds library (audio paths)
|
||||
//
|
||||
// PE_DATA (the full age-group × system × component × step hierarchy,
|
||||
// ~1000 lines) is intentionally NOT ported here. It holds clinically
|
||||
// reviewed content and the migration checkpoint explicitly warns
|
||||
// "An LLM will sometimes 'simplify' a long array — don't let that
|
||||
// happen." PE_DATA port belongs in its own dedicated session with
|
||||
// per-entry counts + visual diff verification against the vanilla
|
||||
// source. Until that session, the React PE Guide surfaces the
|
||||
// reference libraries below and links to the legacy viewer for
|
||||
// exam-step checklists and narrative generation.
|
||||
//
|
||||
// Audio files stay in public/audio/respiratory/ and public/audio/cardiac/
|
||||
// and are served unchanged from Express.
|
||||
// ============================================================
|
||||
|
||||
export interface ScaleDef {
|
||||
title: string;
|
||||
icon: string;
|
||||
rows: Array<[string, string]>;
|
||||
}
|
||||
|
||||
export const SCALES: Record<string, ScaleDef> = {
|
||||
mrc: {
|
||||
title: 'MRC strength grade (0–5)',
|
||||
icon: 'fa-hand-fist',
|
||||
rows: [
|
||||
['5', 'Normal power — holds against full resistance'],
|
||||
['4', 'Reduced — moves against gravity + some resistance'],
|
||||
['3', 'Moves against gravity only (no added resistance)'],
|
||||
['2', 'Full range with gravity eliminated (horizontal plane)'],
|
||||
['1', 'Flicker / trace contraction, no joint movement'],
|
||||
['0', 'No contraction'],
|
||||
],
|
||||
},
|
||||
dtr: {
|
||||
title: 'Deep-tendon reflex grade (0–4+)',
|
||||
icon: 'fa-circle-dot',
|
||||
rows: [
|
||||
['0', 'Absent'],
|
||||
['1+', 'Hypoactive — trace, only with reinforcement'],
|
||||
['2+', 'Normal'],
|
||||
['3+', 'Brisk — may still be normal in anxious patients'],
|
||||
['4+', 'Hyperactive with sustained clonus — always abnormal'],
|
||||
],
|
||||
},
|
||||
plantar: {
|
||||
title: 'Plantar response (Babinski)',
|
||||
icon: 'fa-shoe-prints',
|
||||
rows: [
|
||||
['Down-going', 'Normal in anyone ≥ 2 years'],
|
||||
['Up-going', 'Normal < 2 years; abnormal after — UMN lesion'],
|
||||
['Asymmetric', 'Always abnormal at any age'],
|
||||
],
|
||||
},
|
||||
beighton: {
|
||||
title: 'Beighton hypermobility score (0–9)',
|
||||
icon: 'fa-hands',
|
||||
rows: [
|
||||
['≤ 3', 'Normal flexibility'],
|
||||
['4', 'Borderline — consider in context'],
|
||||
['≥ 5', 'Hypermobility spectrum; screen for hEDS if other features present'],
|
||||
],
|
||||
},
|
||||
atr: {
|
||||
title: 'Scoliometer — angle of trunk rotation',
|
||||
icon: 'fa-ruler',
|
||||
rows: [
|
||||
['< 5°', 'Normal, no follow-up'],
|
||||
['5–6°', 'Borderline — re-check at each visit'],
|
||||
['≥ 7°', 'Refer for PA/lateral spine x-ray + orthopedic evaluation'],
|
||||
],
|
||||
},
|
||||
rr: {
|
||||
title: 'Respiratory rate — upper limit by age (awake)',
|
||||
icon: 'fa-lungs',
|
||||
rows: [
|
||||
['Newborn', '≤ 60 /min'],
|
||||
['< 2 months', '≤ 60 /min (WHO tachypnea cutoff)'],
|
||||
['2–12 months', '≤ 50 /min (WHO tachypnea cutoff)'],
|
||||
['1–5 years', '≤ 40 /min (WHO tachypnea cutoff)'],
|
||||
['6–11 years', '≤ 30 /min'],
|
||||
['≥ 12 years', '≤ 20 /min (adult pattern)'],
|
||||
],
|
||||
},
|
||||
spo2: {
|
||||
title: 'Pulse oximetry (SpO₂) — at room air',
|
||||
icon: 'fa-heart-pulse',
|
||||
rows: [
|
||||
['≥ 95%', 'Normal'],
|
||||
['92–94%', 'Mild hypoxemia — investigate cause'],
|
||||
['< 92%', 'Moderate hypoxemia — supplemental O₂'],
|
||||
['< 88%', 'Severe — urgent intervention; target ≥ 90% acutely'],
|
||||
],
|
||||
},
|
||||
silverman: {
|
||||
title: 'Silverman–Andersen retraction score (neonatal, 0–10)',
|
||||
icon: 'fa-baby',
|
||||
rows: [
|
||||
['0', 'No respiratory distress'],
|
||||
['1–3', 'Mild — close observation'],
|
||||
['4–6', 'Moderate distress — consider CPAP / support'],
|
||||
['7–10', 'Severe — imminent respiratory failure, intubate'],
|
||||
],
|
||||
},
|
||||
westley: {
|
||||
title: 'Westley croup severity score',
|
||||
icon: 'fa-stethoscope',
|
||||
rows: [
|
||||
['≤ 2', 'Mild — home management, cool mist, oral dexamethasone'],
|
||||
['3–5', 'Moderate — nebulised epinephrine + dexamethasone'],
|
||||
['6–11', 'Severe — admit, continuous monitoring'],
|
||||
['≥ 12', 'Impending respiratory failure — ICU / airway management'],
|
||||
],
|
||||
},
|
||||
murmurGrade: {
|
||||
title: 'Heart-murmur grading (Levine 1–6)',
|
||||
icon: 'fa-wave-square',
|
||||
rows: [
|
||||
['1/6', 'Very faint — heard only with concentration'],
|
||||
['2/6', 'Soft but readily heard'],
|
||||
['3/6', 'Moderately loud, no thrill'],
|
||||
['4/6', 'Loud WITH a palpable thrill'],
|
||||
['5/6', 'Very loud; audible with stethoscope just off the chest'],
|
||||
['6/6', 'Audible without the stethoscope touching the chest'],
|
||||
],
|
||||
},
|
||||
pulseAmp: {
|
||||
title: 'Pulse amplitude grade (0–4)',
|
||||
icon: 'fa-heart-pulse',
|
||||
rows: [
|
||||
['0', 'Absent'],
|
||||
['1+', 'Diminished, thready'],
|
||||
['2+', 'Normal'],
|
||||
['3+', 'Bounding'],
|
||||
['4+', 'Bounding with visible pulsation (e.g., aortic regurgitation)'],
|
||||
],
|
||||
},
|
||||
capRefill: {
|
||||
title: 'Capillary refill time',
|
||||
icon: 'fa-hand',
|
||||
rows: [
|
||||
['< 2 sec', 'Normal'],
|
||||
['2–3 sec', 'Borderline — consider hydration / perfusion'],
|
||||
['≥ 3 sec', 'Delayed — dehydration, shock, low cardiac output'],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SYSTEM_SCALES: Record<string, string[]> = {
|
||||
msk: ['atr', 'beighton'],
|
||||
neuro: ['mrc', 'dtr', 'plantar'],
|
||||
resp: ['rr', 'spo2', 'silverman', 'westley'],
|
||||
cv: ['murmurGrade', 'pulseAmp', 'capRefill'],
|
||||
};
|
||||
|
||||
// APTM — the 5 classic cardiac auscultation points
|
||||
export interface AptmEntry {
|
||||
letter: string;
|
||||
color: string;
|
||||
title: string;
|
||||
location: string;
|
||||
listen: string;
|
||||
innocent?: string;
|
||||
}
|
||||
export const APTM_LEGEND: AptmEntry[] = [
|
||||
{ letter: 'A', color: '#dc2626', title: 'Aortic area', location: '2nd ICS, right sternal border', listen: 'S2 (aortic component), aortic stenosis, aortic regurgitation' },
|
||||
{ letter: 'P', color: '#2563eb', title: 'Pulmonic area', location: '2nd ICS, left sternal border', listen: 'S2 (pulmonic component), pulmonic stenosis, PDA, physiologic split of S2',
|
||||
innocent: 'Pulmonary flow murmur (children, adolescents) — upper left sternal border' },
|
||||
{ letter: 'E', color: '#059669', title: 'Erb\'s point', location: '3rd ICS, left sternal border', listen: 'Aortic regurgitation (best here), transitional zone murmurs',
|
||||
innocent: 'Still\'s murmur classically radiates to Erb\'s / LLSB' },
|
||||
{ letter: 'T', color: '#d97706', title: 'Tricuspid area', location: '4th–5th ICS, lower left sternal border', listen: 'Tricuspid regurgitation, VSD, S3/S4, holosystolic murmurs',
|
||||
innocent: 'Still\'s murmur — vibratory, musical, age 3–7 y (loudest between LLSB and apex)' },
|
||||
{ letter: 'M', color: '#7c3aed', title: 'Mitral area (apex)', location: '5th ICS, mid-clavicular line', listen: 'S1, mitral regurgitation, mitral stenosis (with bell, left-lateral decubitus)' },
|
||||
];
|
||||
|
||||
// Innocent (benign) childhood murmurs
|
||||
export interface InnocentMurmur {
|
||||
name: string;
|
||||
age: string;
|
||||
location: string;
|
||||
character: string;
|
||||
confirm: string;
|
||||
}
|
||||
export const INNOCENT_MURMURS: InnocentMurmur[] = [
|
||||
{ name: 'Still\'s (vibratory) murmur',
|
||||
age: '3–7 y (most common in children)',
|
||||
location: 'LLSB, radiating to apex',
|
||||
character: 'Low-frequency vibratory / musical systolic, grade 2–3/6, mid-systolic, "twanging-string" quality',
|
||||
confirm: 'Louder supine, softer or disappears on standing or Valsalva. No radiation to neck/back. Normal S2.' },
|
||||
{ name: 'Pulmonary flow murmur',
|
||||
age: 'School-age and adolescents, thin chest',
|
||||
location: 'Upper left sternal border (2nd–3rd ICS)',
|
||||
character: 'Soft blowing early systolic ejection, grade 1–2/6, higher-pitched',
|
||||
confirm: 'No ejection click. Physiologic split of S2. Louder supine, softer on standing. No radiation.' },
|
||||
{ name: 'Venous hum',
|
||||
age: 'Ages 3–8, disappears by adolescence',
|
||||
location: 'Supraclavicular or infraclavicular area, usually right',
|
||||
character: 'Soft continuous hum, louder in diastole. Only innocent continuous murmur.',
|
||||
confirm: 'Disappears when supine OR when jugular vein is gently compressed (key maneuver). Turning head to opposite side also alters it.' },
|
||||
{ name: 'Carotid bruit / supraclavicular bruit',
|
||||
age: 'Children and adolescents',
|
||||
location: 'Supraclavicular fossa, right > left; may radiate to carotid',
|
||||
character: 'Brief early systolic, grade 2–3/6, higher-pitched than Still\'s',
|
||||
confirm: 'Softer or disappears with hyperextension of the shoulders. Normal cardiac exam otherwise. No radiation below the clavicles.' },
|
||||
{ name: 'Peripheral pulmonary stenosis (PPS, neonatal)',
|
||||
age: 'Newborns and infants < 6–12 months',
|
||||
location: 'Upper LSB, radiates to BOTH axillae and the back',
|
||||
character: 'Soft systolic ejection murmur, grade 1–2/6',
|
||||
confirm: 'Typical age + radiation to back/axillae. Resolves by age 1 as branch pulmonary arteries grow. Persistence or louder grade warrants echo.' },
|
||||
];
|
||||
|
||||
// Respiratory sounds library — real recordings served from /public/audio/respiratory/
|
||||
export interface SoundEntry {
|
||||
key: string;
|
||||
src: string;
|
||||
title: string;
|
||||
where: string;
|
||||
rate?: string;
|
||||
features: string;
|
||||
clinical: string;
|
||||
}
|
||||
export const RESP_SOUNDS: SoundEntry[] = [
|
||||
{ key: 'normal', src: '/audio/respiratory/normal-vesicular.ogg', title: 'Normal vesicular breath sounds',
|
||||
where: 'Peripheral lung fields',
|
||||
features: 'Soft, rustling. Inspiration louder and longer than expiration.',
|
||||
clinical: 'Baseline — deviation elsewhere is what you listen for.' },
|
||||
{ key: 'wheeze', src: '/audio/respiratory/wheeze.ogg', title: 'Wheeze',
|
||||
where: 'Diffuse in asthma; localised in foreign body',
|
||||
features: 'Continuous, high-pitched, musical. Usually expiratory; biphasic if severe.',
|
||||
clinical: 'Lower-airway narrowing — asthma, bronchiolitis, foreign body, bronchomalacia. Silent chest in severe asthma is an ominous sign.' },
|
||||
{ key: 'stridor', src: '/audio/respiratory/stridor.ogg', title: 'Stridor',
|
||||
where: 'Louder over neck than chest — upper airway',
|
||||
features: 'Continuous, high-pitched, harsh. Classically inspiratory (extrathoracic obstruction); biphasic if fixed.',
|
||||
clinical: 'Croup, epiglottitis, foreign body, laryngomalacia (infant). Distinguish from wheeze by auscultating the neck — stridor is loudest there.' },
|
||||
{ key: 'finecrackles', src: '/audio/respiratory/crackles-fine.ogg', title: 'Fine (end-inspiratory) crackles',
|
||||
where: 'Bibasilar in pulmonary edema/fibrosis; focal in pneumonia',
|
||||
features: 'Discontinuous, brief, high-pitched. "Velcro" quality. Late inspiratory, do NOT clear with cough.',
|
||||
clinical: 'Alveolar opening — pulmonary fibrosis, pulmonary edema, early pneumonia, atelectasis.' },
|
||||
{ key: 'coarsecrackles', src: '/audio/respiratory/crackles-coarse.ogg', title: 'Coarse crackles',
|
||||
where: 'Lower lobes; either side',
|
||||
features: 'Discontinuous, longer and louder than fine crackles. Lower-pitched. Can be early or late inspiratory; often clear partly with cough.',
|
||||
clinical: 'Secretions in larger airways — bronchitis, later pneumonia, bronchiectasis, aspiration.' },
|
||||
{ key: 'rhonchi', src: '/audio/respiratory/rhonchi.ogg', title: 'Rhonchi',
|
||||
where: 'Central or anywhere with airway secretions',
|
||||
features: 'Continuous, low-pitched, snore-like. Typically expiratory. Clear or change with cough.',
|
||||
clinical: 'Large-airway secretions — bronchitis, pneumonia with large-airway involvement, cystic fibrosis, bronchiectasis.' },
|
||||
{ key: 'pleuralrub', src: '/audio/respiratory/pleural-rub.ogg', title: 'Pleural friction rub',
|
||||
where: 'Focal, often lateral or posterior lower chest',
|
||||
features: 'Grating, creaky — "leather on leather". Biphasic (heard in inspiration and expiration). Does NOT clear with cough.',
|
||||
clinical: 'Pleural inflammation — pleuritis, pulmonary embolism, pneumonia with pleural involvement, viral pleurisy.' },
|
||||
];
|
||||
|
||||
// Cardiac sounds library — real recordings from Wikimedia Commons
|
||||
export const CARDIAC_SOUNDS: SoundEntry[] = [
|
||||
{ key: 'normal', src: '/audio/cardiac/normal.ogg', title: 'Normal heart sounds (S1, S2)',
|
||||
where: 'All four classic auscultation points', rate: '~61 bpm reference',
|
||||
features: '"lub-dub": S1 (closure of mitral + tricuspid) louder at apex; S2 (closure of aortic + pulmonic) louder at base. Physiologic S2 split on inspiration.',
|
||||
clinical: 'Reference for rhythm, rate, and the normal S1–S2 interval. Listen for what\'s changed — not just what\'s added.' },
|
||||
{ key: 'infant-normal', src: '/audio/cardiac/infant-normal.ogg', title: 'Infant normal heart sounds',
|
||||
where: 'Infant chest — rate will be higher than adult', rate: 'Pediatric reference (120–160 bpm range)',
|
||||
features: 'Same S1–S2 pattern, faster rate. Short diastole makes murmurs easier to miss — careful auscultation needed.',
|
||||
clinical: 'Reference for neonatal/infant rhythm. Any murmur in the first 72 h should prompt pre/postductal sat screening.' },
|
||||
{ key: 'vsd', src: '/audio/cardiac/vsd.wav', title: 'Ventricular septal defect (VSD)',
|
||||
where: 'Lower left sternal border (4th ICS)',
|
||||
features: 'Harsh, blowing, holosystolic (pansystolic) murmur — plateau shape through all of systole. Often accompanied by a thrill if large.',
|
||||
clinical: 'Most common congenital heart defect. Small VSD: loud murmur, usually asymptomatic, may close spontaneously. Large VSD: softer murmur (less pressure gradient) but signs of heart failure, pulmonary hypertension.' },
|
||||
{ key: 'mvp', src: '/audio/cardiac/mitral-prolapse.wav', title: 'Mitral valve prolapse (MVP) — click + late systolic murmur',
|
||||
where: 'Apex (5th ICS, mid-clavicular line)',
|
||||
features: 'Mid-systolic click followed by a late-systolic crescendo murmur. Timing of click changes with maneuvers: earlier with standing or Valsalva, later with squatting.',
|
||||
clinical: 'Often benign, especially in thin young women. Features suggesting need for echo: thickened/redundant leaflets, associated MR, symptoms (palpitations, chest pain), arrhythmias.' },
|
||||
{ key: 'stills', src: '/audio/cardiac/stills-murmur.ogg', title: 'Still\'s murmur (innocent)',
|
||||
where: 'LLSB, radiating to apex', rate: 'Classic age 3–7 y (this recording is a toddler)',
|
||||
features: 'Low-frequency vibratory / musical systolic, grade 2–3/6, mid-systolic, "twanging-string" quality.',
|
||||
clinical: 'The most common innocent murmur of childhood. Louder supine, softer or disappears on standing or Valsalva. Normal S2. No radiation to neck or back. No workup needed when classic.' },
|
||||
{ key: 'functional', src: '/audio/cardiac/functional-murmur.wav', title: 'Functional (innocent) murmur — adult female',
|
||||
where: 'Left sternal border, soft systolic',
|
||||
features: 'Soft systolic murmur in a structurally normal heart — often from increased cardiac output, thin chest wall, anemia, hyperthyroidism, or pregnancy.',
|
||||
clinical: 'Benign if it meets the 7 S criteria. Investigate if loud (≥3/6), holosystolic, diastolic, radiating, or with thrill / symptoms.' },
|
||||
];
|
||||
44
client/src/index.css
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* Tailwind v4 uses @theme to declare custom color tokens that then
|
||||
expose the matching utility classes (bg-background, text-foreground,
|
||||
border-border, etc.). Values tuned to the shadcn/ui 'new-york' palette;
|
||||
adjust later to match the existing vanilla app's blue / g100 colors. */
|
||||
|
||||
@theme {
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(222.2 47.4% 11.2%);
|
||||
|
||||
--color-muted: hsl(210 40% 96.1%);
|
||||
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
|
||||
--color-card: hsl(0 0% 100%);
|
||||
--color-card-foreground: hsl(222.2 47.4% 11.2%);
|
||||
|
||||
--color-popover: hsl(0 0% 100%);
|
||||
--color-popover-foreground: hsl(222.2 47.4% 11.2%);
|
||||
|
||||
--color-primary: hsl(222.2 47.4% 11.2%);
|
||||
--color-primary-foreground: hsl(210 40% 98%);
|
||||
|
||||
--color-secondary: hsl(210 40% 96.1%);
|
||||
--color-secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
|
||||
--color-accent: hsl(210 40% 96.1%);
|
||||
--color-accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||
|
||||
--color-destructive: hsl(0 84% 60%);
|
||||
--color-destructive-foreground: hsl(210 40% 98%);
|
||||
|
||||
--color-border: hsl(214.3 31.8% 91.4%);
|
||||
--color-input: hsl(214.3 31.8% 91.4%);
|
||||
--color-ring: hsl(215 20.2% 65.1%);
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
45
client/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Thin fetch wrapper used by every React page. Centralises auth
|
||||
// header handling (cookie-based, credentials: 'include'), JSON
|
||||
// parsing, and typed success-vs-error narrowing via shared/types.
|
||||
import type { ApiResponse } from '@/shared/types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<TOk>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<TOk> {
|
||||
const resp = await fetch(path, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init.headers || {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
|
||||
// Non-JSON responses (e.g. audio blobs) — caller must handle.
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (!ct.includes('application/json')) {
|
||||
if (!resp.ok) throw new ApiError(resp.status, resp.statusText);
|
||||
return (await resp.blob()) as unknown as TOk;
|
||||
}
|
||||
|
||||
const body = (await resp.json()) as ApiResponse<TOk>;
|
||||
if (!resp.ok || body.success === false) {
|
||||
throw new ApiError(resp.status, (body as { error?: string }).error || resp.statusText);
|
||||
}
|
||||
return body as unknown as TOk;
|
||||
}
|
||||
|
||||
// Shortcuts for common verbs
|
||||
export const api = {
|
||||
get: <T>(path: string) => apiFetch<T>(path),
|
||||
post: <T>(path: string, body: unknown) => apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
put: <T>(path: string, body: unknown) => apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
124
client/src/lib/encounter-persistence.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// ============================================================
|
||||
// Encounter persistence — port of public/js/encounters.js save/load
|
||||
// flow. sessionStorage keys survive page refresh + sign-out so a
|
||||
// resumed session lands on the same DB row instead of creating a
|
||||
// duplicate. Optimistic-locking via expected_version preserved.
|
||||
// ============================================================
|
||||
|
||||
export type EncType = 'encounter' | 'dictation' | 'hospital' | 'chart' | 'wellvisit' | 'sickvisit' | 'soap';
|
||||
|
||||
const SAVED_KEY = (t: EncType) => '_savedEncId_' + t;
|
||||
const IDEMP_KEY = (t: EncType) => '_idempKey_' + t;
|
||||
|
||||
export function getSavedEncId(t: EncType): number | null {
|
||||
try {
|
||||
const v = sessionStorage.getItem(SAVED_KEY(t));
|
||||
return v ? Number(v) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
export function setSavedEncId(t: EncType, id: number | null) {
|
||||
try {
|
||||
if (id == null) sessionStorage.removeItem(SAVED_KEY(t));
|
||||
else sessionStorage.setItem(SAVED_KEY(t), String(id));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function getIdempotencyKey(t: EncType): string {
|
||||
try {
|
||||
const existing = sessionStorage.getItem(IDEMP_KEY(t));
|
||||
if (existing) return existing;
|
||||
} catch { /* ignore */ }
|
||||
const fresh = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
try { sessionStorage.setItem(IDEMP_KEY(t), fresh); } catch { /* ignore */ }
|
||||
return fresh;
|
||||
}
|
||||
export function resetIdempotencyKey(t: EncType) {
|
||||
try { sessionStorage.removeItem(IDEMP_KEY(t)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const _versions = new Map<number, number>();
|
||||
|
||||
export interface SaveEncounterInput {
|
||||
type: EncType;
|
||||
label: string;
|
||||
transcript: string;
|
||||
generatedNote: string;
|
||||
partialData?: unknown; // serialized to JSON
|
||||
}
|
||||
export interface SaveEncounterResult {
|
||||
id: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function saveEncounter(input: SaveEncounterInput): Promise<SaveEncounterResult> {
|
||||
if (!input.label.trim()) throw new Error('Enter a patient label first');
|
||||
const id = getSavedEncId(input.type);
|
||||
const body: Record<string, unknown> = {
|
||||
label: input.label.trim(),
|
||||
enc_type: input.type,
|
||||
transcript: input.transcript,
|
||||
generated_note: input.generatedNote,
|
||||
partial_data: input.partialData != null ? JSON.stringify(input.partialData) : undefined,
|
||||
idempotency_key: getIdempotencyKey(input.type),
|
||||
};
|
||||
if (id != null) {
|
||||
body.id = id;
|
||||
const expected = _versions.get(id);
|
||||
if (expected != null) body.expected_version = expected;
|
||||
}
|
||||
const r = await fetch('/api/encounters/saved', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (r.status === 409) throw new Error('Someone else edited this encounter. Reload to see the latest version.');
|
||||
if (!data.success) throw new Error(data.error || 'Save failed');
|
||||
if (data.id != null) {
|
||||
setSavedEncId(input.type, data.id);
|
||||
if (data.version != null) _versions.set(data.id, data.version);
|
||||
}
|
||||
return { id: data.id, version: data.version };
|
||||
}
|
||||
|
||||
export interface LoadedEncounter {
|
||||
id: number;
|
||||
label: string;
|
||||
enc_type: string;
|
||||
transcript: string;
|
||||
generated_note: string;
|
||||
partial_data: string | null;
|
||||
version?: number;
|
||||
}
|
||||
export async function loadEncounter(id: number): Promise<LoadedEncounter> {
|
||||
const r = await fetch('/api/encounters/saved/' + id, { credentials: 'include' });
|
||||
const data = await r.json();
|
||||
if (!data.success) throw new Error(data.error || 'Load failed');
|
||||
if (data.encounter && data.encounter.version != null) _versions.set(id, data.encounter.version);
|
||||
return data.encounter as LoadedEncounter;
|
||||
}
|
||||
|
||||
export interface SavedEncounterListEntry {
|
||||
id: number;
|
||||
label: string;
|
||||
enc_type: EncType;
|
||||
status?: string;
|
||||
updated_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
export async function listSavedEncounters(): Promise<SavedEncounterListEntry[]> {
|
||||
const r = await fetch('/api/encounters/saved', { credentials: 'include' });
|
||||
const data = await r.json();
|
||||
return (data.encounters || []) as SavedEncounterListEntry[];
|
||||
}
|
||||
|
||||
export function clearTabState(t: EncType) {
|
||||
setSavedEncId(t, null);
|
||||
resetIdempotencyKey(t);
|
||||
}
|
||||
59
client/src/lib/recorder.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// ============================================================
|
||||
// AudioRecorder — verbatim port of public/js/app.js:660-687.
|
||||
// Same constraints (mono, 16kHz, EC+NS), same opus 32kbps target.
|
||||
// Exposes the underlying mediaRecorder + stream so the React
|
||||
// Recorder component can pause/resume + restart on the same stream
|
||||
// (matches vanilla liveEncounter.js fallback behavior).
|
||||
// ============================================================
|
||||
|
||||
export class AudioRecorder {
|
||||
mediaRecorder: MediaRecorder | null = null;
|
||||
chunks: Blob[] = [];
|
||||
stream: MediaStream | null = null;
|
||||
|
||||
start(): Promise<void> {
|
||||
this.chunks = [];
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true },
|
||||
}).then((stream) => {
|
||||
this.stream = stream;
|
||||
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
this.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||||
this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); };
|
||||
this.mediaRecorder.start(1000);
|
||||
});
|
||||
}
|
||||
|
||||
stop(): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') { resolve(null); return; }
|
||||
this.mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(this.chunks, { type: this.mediaRecorder!.mimeType });
|
||||
if (this.stream) this.stream.getTracks().forEach((t) => t.stop());
|
||||
resolve(blob);
|
||||
};
|
||||
this.mediaRecorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.mediaRecorder && typeof this.mediaRecorder.pause === 'function' && this.mediaRecorder.state === 'recording') {
|
||||
try { this.mediaRecorder.pause(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
resume() {
|
||||
if (!this.mediaRecorder) return;
|
||||
try {
|
||||
if (typeof this.mediaRecorder.resume === 'function' && this.mediaRecorder.state === 'paused') {
|
||||
this.mediaRecorder.resume();
|
||||
} else if (this.mediaRecorder.state === 'inactive' && this.stream && this.stream.active) {
|
||||
// Browser killed it — restart on the same stream (matches vanilla fallback).
|
||||
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
this.mediaRecorder = new MediaRecorder(this.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||||
this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); };
|
||||
this.mediaRecorder.start(1000);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
57
client/src/lib/sanitize.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// ============================================================
|
||||
// Minimal inline HTML sanitizer. Not as thorough as DOMPurify but
|
||||
// covers the main XSS vectors for CMS-authored Learning Hub
|
||||
// content (admin-authored, so the trust model is higher than
|
||||
// user-generated content anyway):
|
||||
//
|
||||
// • Strips <script>, <style>, <iframe>, <object>, <embed>, <link>
|
||||
// • Strips every on* event-handler attribute
|
||||
// • Strips javascript:/data:(text/html) URL schemes on href/src
|
||||
// • Strips any attribute whose value contains "javascript:"
|
||||
// • Preserves standard formatting tags (p, h1-6, ul/ol/li, strong,
|
||||
// em, code, pre, blockquote, table, a, img, br, hr, span, div…)
|
||||
//
|
||||
// Parses via DOMParser (sandboxed — no scripts run) so the output is
|
||||
// a DOM tree we can walk + clean safely before serializing back.
|
||||
// ============================================================
|
||||
|
||||
const BLOCKED_TAGS = new Set([
|
||||
'script', 'style', 'iframe', 'object', 'embed', 'link',
|
||||
'meta', 'base', 'form', 'input', 'button', 'select', 'textarea',
|
||||
]);
|
||||
|
||||
function stripNode(node: Element) {
|
||||
// Blocked tags — remove entirely.
|
||||
if (BLOCKED_TAGS.has(node.tagName.toLowerCase())) {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
// Strip dangerous attributes.
|
||||
const toRemove: string[] = [];
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = (attr.value || '').trim().toLowerCase();
|
||||
if (name.startsWith('on')) toRemove.push(attr.name);
|
||||
else if ((name === 'href' || name === 'src' || name === 'xlink:href') &&
|
||||
(val.startsWith('javascript:') || val.startsWith('data:text/html'))) {
|
||||
toRemove.push(attr.name);
|
||||
}
|
||||
else if (val.includes('javascript:')) toRemove.push(attr.name);
|
||||
}
|
||||
toRemove.forEach((a) => node.removeAttribute(a));
|
||||
// Recurse into children.
|
||||
for (const child of Array.from(node.children)) stripNode(child);
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString('<!DOCTYPE html><html><body>' + html + '</body></html>', 'text/html');
|
||||
for (const child of Array.from(doc.body.children)) stripNode(child);
|
||||
return doc.body.innerHTML;
|
||||
} catch {
|
||||
// Fall back to plain text if parsing fails — safer than returning raw HTML.
|
||||
return html.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] || c));
|
||||
}
|
||||
}
|
||||
99
client/src/lib/transcribe.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// ============================================================
|
||||
// Transcribe + audio-backup helpers — port of public/js/app.js
|
||||
// transcribeAudio() / _serverTranscribe() / saveAudioBackup().
|
||||
//
|
||||
// Server-first via /api/transcribe (cookie auth — same-origin).
|
||||
// On failure, blob is saved to /api/audio-backups so the user can
|
||||
// retry from Settings → Audio Backups (or vanilla Audio Backups page).
|
||||
// IndexedDB fallback preserved from vanilla audioBackup.js.
|
||||
// ============================================================
|
||||
|
||||
export interface TranscribeResult {
|
||||
success: boolean;
|
||||
text?: string;
|
||||
provider?: string;
|
||||
duration?: number;
|
||||
noProvider?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let transcribeAvailable: boolean | null = null;
|
||||
let transcribeProvider = 'none';
|
||||
|
||||
export async function checkTranscribeStatus(): Promise<void> {
|
||||
try {
|
||||
const r = await fetch('/api/transcribe/status', { credentials: 'include' });
|
||||
const data = await r.json();
|
||||
transcribeAvailable = !!data.available;
|
||||
transcribeProvider = data.provider || 'none';
|
||||
} catch {
|
||||
transcribeAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTranscribeAvailable(): boolean | null { return transcribeAvailable; }
|
||||
export function getTranscribeProvider(): string { return transcribeProvider; }
|
||||
|
||||
export async function transcribeAudio(blob: Blob, module = 'encounter'): Promise<TranscribeResult> {
|
||||
if (transcribeAvailable === null) await checkTranscribeStatus();
|
||||
if (transcribeAvailable === false) {
|
||||
return { success: false, noProvider: true, error: 'No transcription API configured — using live transcript' };
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('audio', blob, 'audio.webm');
|
||||
try {
|
||||
const r = await fetch('/api/transcribe', { method: 'POST', credentials: 'include', body: form });
|
||||
const data: TranscribeResult = await r.json();
|
||||
if (!data.success && blob.size > 0) {
|
||||
// Best-effort: save the blob so the user can retry later.
|
||||
saveAudioBackup(blob, module + '-failed-transcription').catch(() => { /* ignore */ });
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
saveAudioBackup(blob, module + '-failed-transcription').catch(() => { /* ignore */ });
|
||||
return { success: false, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audio backup (server-first, IndexedDB fallback) ──
|
||||
export async function saveAudioBackup(blob: Blob, module: string): Promise<number | null> {
|
||||
// Server first.
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('audio', blob, 'audio.webm');
|
||||
form.append('module', module);
|
||||
const r = await fetch('/api/audio-backups', { method: 'POST', credentials: 'include', body: form });
|
||||
const data = await r.json();
|
||||
if (data.success && data.id) return data.id;
|
||||
} catch { /* fall through */ }
|
||||
// IndexedDB fallback.
|
||||
return saveToIndexedDB(blob, module);
|
||||
}
|
||||
|
||||
const DB_NAME = 'PedScribeAudioBackup';
|
||||
const STORE = 'recordings';
|
||||
let _db: IDBDatabase | null = null;
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
if (_db) return Promise.resolve(_db);
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = (e.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
const store = db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => { _db = req.result; resolve(_db); };
|
||||
req.onerror = () => reject(new Error('IndexedDB open failed'));
|
||||
});
|
||||
}
|
||||
function saveToIndexedDB(blob: Blob, module: string): Promise<number | null> {
|
||||
return openDB().then((db) => new Promise<number>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
const store = tx.objectStore(STORE);
|
||||
const req = store.add({ blob, module, timestamp: Date.now(), size: blob.size, mimeType: blob.type });
|
||||
req.onsuccess = () => resolve(req.result as number);
|
||||
req.onerror = () => reject(new Error('Failed to save audio backup'));
|
||||
})).catch(() => null);
|
||||
}
|
||||
8
client/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// shadcn/ui classname-merge helper — combines clsx + tailwind-merge so
|
||||
// conditional class merging doesn't clobber earlier class values.
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
117
client/src/lib/web-speech.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// ============================================================
|
||||
// Web Speech API wrapper — verbatim port of
|
||||
// public/js/speechRecognition.js. Browser-side live transcription
|
||||
// for showing words as the user speaks. Falls back to no-op when
|
||||
// the API is unsupported. Privacy warning preserved.
|
||||
// ============================================================
|
||||
|
||||
const STORAGE_ENABLED = 'ped_web_speech_enabled';
|
||||
|
||||
interface SpeechRecognitionResult {
|
||||
isFinal: boolean;
|
||||
[index: number]: { transcript: string };
|
||||
}
|
||||
interface SpeechRecognitionResults {
|
||||
length: number;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
interface SpeechRecognitionEvent {
|
||||
resultIndex: number;
|
||||
results: SpeechRecognitionResults;
|
||||
}
|
||||
interface SpeechRecognitionErrorEvent { error: string }
|
||||
interface SpeechRecognitionLike {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
maxAlternatives: number;
|
||||
onresult: ((e: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((e: SpeechRecognitionErrorEvent) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onstart: (() => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
type SRCtor = new () => SpeechRecognitionLike;
|
||||
|
||||
function getCtor(): SRCtor | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const w = window as unknown as { SpeechRecognition?: SRCtor; webkitSpeechRecognition?: SRCtor };
|
||||
return (w.SpeechRecognition || w.webkitSpeechRecognition) || null;
|
||||
}
|
||||
|
||||
export function isSpeechRecognitionSupported(): boolean {
|
||||
return !!getCtor();
|
||||
}
|
||||
export function isSpeechRecognitionEnabled(): boolean {
|
||||
try { return localStorage.getItem(STORAGE_ENABLED) === '1' || localStorage.getItem(STORAGE_ENABLED) === 'true'; }
|
||||
catch { return false; }
|
||||
}
|
||||
export function setSpeechRecognitionEnabled(v: boolean) {
|
||||
try { localStorage.setItem(STORAGE_ENABLED, v ? 'true' : 'false'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export interface SpeechHandle {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
// Creates a continuous-listening session that calls handlers as words
|
||||
// come in. Mirrors the liveEncounter.js wiring (final + interim results).
|
||||
export function createSpeechSession(handlers: {
|
||||
onFinal: (text: string) => void;
|
||||
onInterim: (text: string) => void;
|
||||
onError?: (err: string) => void;
|
||||
}): SpeechHandle | null {
|
||||
const Ctor = getCtor();
|
||||
if (!Ctor) return null;
|
||||
let rec: SpeechRecognitionLike | null = null;
|
||||
let active = false;
|
||||
|
||||
function build(): SpeechRecognitionLike {
|
||||
const r = new (Ctor as SRCtor)();
|
||||
r.continuous = true;
|
||||
r.interimResults = true;
|
||||
r.lang = 'en-US';
|
||||
r.maxAlternatives = 1;
|
||||
r.onresult = (e: SpeechRecognitionEvent) => {
|
||||
let interim = '';
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
const t = e.results[i][0].transcript;
|
||||
if (e.results[i].isFinal) handlers.onFinal(t + ' ');
|
||||
else interim = t;
|
||||
}
|
||||
handlers.onInterim(interim);
|
||||
};
|
||||
r.onerror = (e: SpeechRecognitionErrorEvent) => {
|
||||
if (e.error === 'no-speech' || e.error === 'aborted') return;
|
||||
handlers.onError?.(e.error);
|
||||
};
|
||||
r.onend = () => { if (active) try { r.start(); } catch { /* ignore */ } };
|
||||
return r;
|
||||
}
|
||||
|
||||
return {
|
||||
start: () => { active = true; rec = build(); try { rec.start(); } catch { /* ignore */ } },
|
||||
stop: () => { active = false; if (rec) try { rec.stop(); } catch { /* ignore */ } },
|
||||
};
|
||||
}
|
||||
|
||||
// Verbatim port of deduplicateFinal from public/js/app.js:964-984.
|
||||
export function deduplicateFinal(newText: string, existingText: string): string {
|
||||
if (!newText || !existingText) return newText;
|
||||
const trimmed = newText.trim();
|
||||
if (!trimmed) return '';
|
||||
if (existingText.trimEnd().endsWith(trimmed)) return '';
|
||||
const words = trimmed.split(/\s+/);
|
||||
if (words.length >= 3) {
|
||||
const tail = existingText.trimEnd().split(/\s+/).slice(-words.length).join(' ');
|
||||
if (tail === trimmed) return '';
|
||||
const half = Math.ceil(words.length / 2);
|
||||
const firstHalf = words.slice(0, half).join(' ');
|
||||
if (existingText.trimEnd().endsWith(firstHalf)) {
|
||||
return words.slice(half).join(' ') + ' ';
|
||||
}
|
||||
}
|
||||
return newText;
|
||||
}
|
||||
10
client/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
103
client/src/pages/Admin.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// ============================================================
|
||||
// ADMIN — sub-tab shell for the admin panel. Tabs live in
|
||||
// AdminPanels.tsx (batch 1: Users, Settings, Announcement) +
|
||||
// AdminPanels2.tsx (batch 2: SMTP, Email, Prompts, Models,
|
||||
// TTS, STT, Logs). Role check + query cache shared with Layout.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import type { MeOk } from '@/shared/types';
|
||||
import { AdminUsersTab, AdminSettingsTab, AdminAnnouncementTab } from './AdminPanels';
|
||||
import {
|
||||
AdminSmtpTab, AdminEmailTab, AdminPromptsTab,
|
||||
AdminModelsTab, AdminTtsTab, AdminSttTab, AdminLogsTab,
|
||||
} from './AdminPanels2';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
|
||||
type TabId =
|
||||
| 'users' | 'settings' | 'announcement'
|
||||
| 'models' | 'tts' | 'stt'
|
||||
| 'smtp' | 'email' | 'prompts'
|
||||
| 'logs';
|
||||
|
||||
const TABS: Array<{ id: TabId; label: string }> = [
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'settings', label: 'Site settings' },
|
||||
{ id: 'announcement', label: 'Announcement' },
|
||||
{ id: 'models', label: 'AI models' },
|
||||
{ id: 'tts', label: 'TTS provider' },
|
||||
{ id: 'stt', label: 'STT provider' },
|
||||
{ id: 'smtp', label: 'SMTP' },
|
||||
{ id: 'email', label: 'Email templates' },
|
||||
{ id: 'prompts', label: 'AI prompts' },
|
||||
{ id: 'logs', label: 'Audit logs' },
|
||||
];
|
||||
|
||||
export default function Admin() {
|
||||
const { data: me, isLoading } = useQuery<MeOk>({
|
||||
queryKey: ['auth-me'],
|
||||
queryFn: () => api.get<MeOk>('/api/auth/me'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const [active, setActive] = useState<TabId>('users');
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="max-w-3xl mx-auto p-6 text-sm text-muted-foreground">Checking permissions…</div>;
|
||||
}
|
||||
if (me?.user.role !== 'admin') {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<section className={card} data-testid="admin-access-denied">
|
||||
<h1 className="text-xl font-semibold">Admin only</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This page is restricted to users with the admin role. If you believe this is a mistake, contact your site administrator.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-4" data-testid="admin-shell">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Admin Panel</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Users, site settings, announcement banner, AI model management, TTS/STT provider, SMTP, email templates, and audit logs.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2" data-testid="admin-subnav">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setActive(t.id)}
|
||||
className={
|
||||
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
|
||||
(active === t.id
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted hover:bg-muted/80 border-border')
|
||||
}
|
||||
data-testid={'admin-tab-' + t.id}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{active === 'users' && <AdminUsersTab />}
|
||||
{active === 'settings' && <AdminSettingsTab />}
|
||||
{active === 'announcement' && <AdminAnnouncementTab />}
|
||||
{active === 'models' && <AdminModelsTab />}
|
||||
{active === 'tts' && <AdminTtsTab />}
|
||||
{active === 'stt' && <AdminSttTab />}
|
||||
{active === 'smtp' && <AdminSmtpTab />}
|
||||
{active === 'email' && <AdminEmailTab />}
|
||||
{active === 'prompts' && <AdminPromptsTab />}
|
||||
{active === 'logs' && <AdminLogsTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
337
client/src/pages/AdminPanels.tsx
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
// ============================================================
|
||||
// ADMIN PANELS — real React components for each admin sub-tab.
|
||||
// Batch 1: Users / Settings / Announcement. Remaining tabs
|
||||
// (SMTP, Email Templates, AI Prompts, AI Models, TTS/STT, Logs)
|
||||
// ship in follow-up commits.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
import type {
|
||||
AdminUser,
|
||||
AdminUsersOk,
|
||||
AdminSettingsOk,
|
||||
AdminAnnouncementOk,
|
||||
} from '@/shared/types';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
const label = 'block text-xs font-medium text-muted-foreground';
|
||||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
|
||||
const btnGhost = 'rounded-md border border-border bg-background px-3 py-2 text-xs font-medium hover:bg-muted disabled:opacity-50';
|
||||
const btnDanger = 'rounded-md bg-destructive text-white px-3 py-2 text-xs font-medium disabled:opacity-50';
|
||||
const th = 'text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground';
|
||||
const td = 'px-2 py-1.5 border-b border-border align-top text-sm';
|
||||
|
||||
type Msg = { text: string; kind: 'ok' | 'err' | 'info' } | null;
|
||||
|
||||
function StatusLine({ msg }: { msg: Msg }) {
|
||||
if (!msg) return null;
|
||||
const c = msg.kind === 'ok' ? 'text-green-600' : msg.kind === 'err' ? 'text-destructive' : 'text-muted-foreground';
|
||||
return <div className={'text-sm ' + c}>{msg.text}</div>;
|
||||
}
|
||||
|
||||
// ── Users ───────────────────────────────────────────────────
|
||||
export function AdminUsersTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
|
||||
const [resetTarget, setResetTarget] = useState<AdminUser | null>(null);
|
||||
const [resetPw, setResetPw] = useState('');
|
||||
|
||||
const { data, isLoading, error } = useQuery<AdminUsersOk>({
|
||||
queryKey: ['admin-users'],
|
||||
queryFn: () => api.get<AdminUsersOk>('/api/admin/users'),
|
||||
});
|
||||
|
||||
const verify = useMutation({
|
||||
mutationFn: (id: number) => api.post<{ message: string }>(`/api/admin/users/${id}/verify`, {}),
|
||||
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const disable = useMutation({
|
||||
mutationFn: (id: number) => api.post<{ message: string }>(`/api/admin/users/${id}/disable`, {}),
|
||||
onSuccess: (d) => { setMsg({ text: d.message, kind: 'info' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const enable = useMutation({
|
||||
mutationFn: (id: number) => api.post<{ message: string }>(`/api/admin/users/${id}/enable`, {}),
|
||||
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const setRole = useMutation({
|
||||
mutationFn: (body: { id: number; role: string }) =>
|
||||
api.post<{ message: string }>(`/api/admin/users/${body.id}/role`, { role: body.role }),
|
||||
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.delete<{ message: string }>(`/api/admin/users/${id}`),
|
||||
onSuccess: (d) => { setMsg({ text: d.message, kind: 'info' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const resetPwMutation = useMutation({
|
||||
mutationFn: (body: { id: number; newPassword: string }) =>
|
||||
api.post<{ message: string }>(`/api/admin/users/${body.id}/reset-password`, { newPassword: body.newPassword }),
|
||||
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); setResetTarget(null); setResetPw(''); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
const users = (data?.users || []).filter((u) =>
|
||||
!query || u.email.toLowerCase().includes(query.toLowerCase()) || u.name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-users-tab">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h2 className="text-lg font-semibold">Users</h2>
|
||||
<input
|
||||
type="search"
|
||||
className={input + ' max-w-xs'}
|
||||
placeholder="Search by name or email…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
data-testid="admin-users-search"
|
||||
/>
|
||||
</div>
|
||||
{isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
|
||||
{error && <div className="text-sm text-destructive">{(error as Error).message}</div>}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="admin-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={th}>Email</th>
|
||||
<th className={th}>Name</th>
|
||||
<th className={th}>Role</th>
|
||||
<th className={th}>Verified</th>
|
||||
<th className={th}>2FA</th>
|
||||
<th className={th}>Status</th>
|
||||
<th className={th}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} data-testid={`admin-user-row-${u.id}`} className={u.disabled ? 'opacity-60' : ''}>
|
||||
<td className={td}>{u.email}</td>
|
||||
<td className={td}>{u.name}</td>
|
||||
<td className={td}>
|
||||
<select
|
||||
className={input + ' text-xs w-28'}
|
||||
value={u.role || 'user'}
|
||||
onChange={(e) => setRole.mutate({ id: u.id, role: e.target.value })}
|
||||
data-testid={`admin-user-role-${u.id}`}
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="moderator">moderator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className={td + ' text-xs'}>
|
||||
{u.email_verified ? '✅' : (
|
||||
<button type="button" className={btnGhost} onClick={() => verify.mutate(u.id)} data-testid={`admin-user-verify-${u.id}`}>Verify</button>
|
||||
)}
|
||||
</td>
|
||||
<td className={td + ' text-xs'}>{u.totp_enabled ? '✅' : '—'}</td>
|
||||
<td className={td + ' text-xs'}>
|
||||
{u.disabled ? (
|
||||
<button type="button" className={btnGhost} onClick={() => enable.mutate(u.id)} data-testid={`admin-user-enable-${u.id}`}>Enable</button>
|
||||
) : (
|
||||
<button type="button" className={btnGhost} onClick={() => disable.mutate(u.id)} data-testid={`admin-user-disable-${u.id}`}>Disable</button>
|
||||
)}
|
||||
</td>
|
||||
<td className={td + ' text-xs'}>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" className={btnGhost} onClick={() => setResetTarget(u)} data-testid={`admin-user-reset-${u.id}`}>Reset pw</button>
|
||||
<button type="button" className={btnDanger} onClick={() => setDeleteTarget(u)} data-testid={`admin-user-delete-${u.id}`}>Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && data && (
|
||||
<tr><td className={td + ' text-muted-foreground italic'} colSpan={7}>No users match "{query}".</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<StatusLine msg={msg} />
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title={`Delete ${deleteTarget?.email}?`}
|
||||
body="This deletes the user account. Audit log entries are preserved (user_id set to NULL)."
|
||||
confirmText="Delete"
|
||||
danger
|
||||
busy={del.isPending}
|
||||
onConfirm={() => { if (deleteTarget) del.mutate(deleteTarget.id); setDeleteTarget(null); }}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{resetTarget && (
|
||||
<div role="dialog" aria-modal="true" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setResetTarget(null)}>
|
||||
<div className="w-full max-w-sm rounded-lg border border-border bg-background p-5 shadow-lg space-y-3" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-base font-semibold">Reset password for {resetTarget.email}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className={input}
|
||||
placeholder="New password (8+ chars)"
|
||||
value={resetPw}
|
||||
onChange={(e) => setResetPw(e.target.value)}
|
||||
autoFocus
|
||||
minLength={8}
|
||||
data-testid="admin-user-reset-input"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className={btnGhost} onClick={() => { setResetTarget(null); setResetPw(''); }}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btnPrimary}
|
||||
disabled={resetPw.length < 8 || resetPwMutation.isPending}
|
||||
onClick={() => resetPwMutation.mutate({ id: resetTarget.id, newPassword: resetPw })}
|
||||
data-testid="admin-user-reset-submit"
|
||||
>
|
||||
{resetPwMutation.isPending ? 'Saving…' : 'Reset'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Settings (registration + stats) ────────────────────────
|
||||
export function AdminSettingsTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
|
||||
const { data } = useQuery<AdminSettingsOk>({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => api.get<AdminSettingsOk>('/api/admin/settings'),
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (enabled: boolean) =>
|
||||
api.post<{ message: string; registrationEnabled: boolean }>('/api/admin/settings/registration', { enabled }),
|
||||
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-settings'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-settings-tab">
|
||||
<h2 className="text-lg font-semibold">Site settings</h2>
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Total users</div><div className="text-xl font-bold">{data.stats.totalUsers}</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">API calls (all time)</div><div className="text-xl font-bold">{data.stats.totalApiCalls}</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">API calls (today)</div><div className="text-xl font-bold">{data.stats.todayApiCalls}</div></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-primary size-4"
|
||||
checked={data.settings.registrationEnabled}
|
||||
onChange={(e) => toggle.mutate(e.target.checked)}
|
||||
data-testid="admin-registration-toggle"
|
||||
/>
|
||||
<span className="text-sm font-medium">Allow new user registration</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Announcement banner ────────────────────────────────────
|
||||
export function AdminAnnouncementTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [type, setType] = useState<'info' | 'warning' | 'critical'>('info');
|
||||
const [text, setText] = useState('');
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
const { data } = useQuery<AdminAnnouncementOk>({
|
||||
queryKey: ['admin-announcement'],
|
||||
queryFn: () => api.get<AdminAnnouncementOk>('/api/admin/config/announcement'),
|
||||
});
|
||||
// one-time hydrate from server
|
||||
if (!hydrated && data) {
|
||||
setEnabled(data.enabled);
|
||||
setType(((data as unknown as { type?: string }).type as typeof type) || 'info');
|
||||
setText((data as unknown as { text?: string }).text || '');
|
||||
setHydrated(true);
|
||||
}
|
||||
|
||||
const putConfig = useMutation({
|
||||
mutationFn: async (body: { key: string; value: string }) =>
|
||||
api.put<{ success: true }>(`/api/admin/config/${encodeURIComponent(body.key)}`, { value: body.value }),
|
||||
});
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
await Promise.all([
|
||||
putConfig.mutateAsync({ key: 'announcement.enabled', value: enabled ? 'true' : 'false' }),
|
||||
putConfig.mutateAsync({ key: 'announcement.type', value: type }),
|
||||
putConfig.mutateAsync({ key: 'announcement.text', value: text }),
|
||||
]);
|
||||
setMsg({ text: 'Announcement saved', kind: 'ok' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-announcement'] });
|
||||
} catch (e) {
|
||||
setMsg({ text: (e as Error).message, kind: 'err' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-announcement-tab">
|
||||
<h2 className="text-lg font-semibold">Announcement banner</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shown at the top of every page when enabled. Use for scheduled maintenance, outage notices, or release notes.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-primary size-4"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
data-testid="admin-announcement-enabled"
|
||||
/>
|
||||
<span className="text-sm">Show banner</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="sm:col-span-1">
|
||||
<label className={label}>Severity</label>
|
||||
<select className={input} value={type} onChange={(e) => setType(e.target.value as typeof type)} data-testid="admin-announcement-type">
|
||||
<option value="info">Info (blue)</option>
|
||||
<option value="warning">Warning (amber)</option>
|
||||
<option value="critical">Critical (red)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={label}>Message</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className={input + ' resize-y'}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="e.g. Scheduled maintenance Thursday 02:00 UTC — expect 10 minutes of downtime."
|
||||
data-testid="admin-announcement-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={save} disabled={putConfig.isPending} className={btnPrimary} data-testid="admin-announcement-save">
|
||||
{putConfig.isPending ? 'Saving…' : 'Save announcement'}
|
||||
</button>
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
499
client/src/pages/AdminPanels2.tsx
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
// ============================================================
|
||||
// ADMIN PANELS (batch 2) — SMTP, Email Templates, AI Prompts,
|
||||
// AI Models, TTS provider, STT provider, Audit Logs.
|
||||
// All endpoints live at /api/admin/config/*.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
import type {
|
||||
AdminConfigOk,
|
||||
AdminSmtpStatusOk,
|
||||
AdminPromptsOk,
|
||||
AdminModelsOk,
|
||||
AdminLogsOk,
|
||||
} from '@/shared/types';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
const label = 'block text-xs font-medium text-muted-foreground';
|
||||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
|
||||
const btnGhost = 'rounded-md border border-border bg-background px-3 py-2 text-xs font-medium hover:bg-muted disabled:opacity-50';
|
||||
const btnDanger = 'rounded-md bg-destructive text-white px-3 py-2 text-xs font-medium disabled:opacity-50';
|
||||
const th = 'text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground';
|
||||
const td = 'px-2 py-1.5 border-b border-border align-top text-sm';
|
||||
|
||||
type Msg = { text: string; kind: 'ok' | 'err' | 'info' } | null;
|
||||
function StatusLine({ msg }: { msg: Msg }) {
|
||||
if (!msg) return null;
|
||||
const c = msg.kind === 'ok' ? 'text-green-600' : msg.kind === 'err' ? 'text-destructive' : 'text-muted-foreground';
|
||||
return <div className={'text-sm ' + c}>{msg.text}</div>;
|
||||
}
|
||||
|
||||
// Shared putConfig — PUT /api/admin/config/:key with {value}.
|
||||
function useConfigPut() {
|
||||
return useMutation({
|
||||
mutationFn: (body: { key: string; value: string }) =>
|
||||
api.put<{ success: true }>(`/api/admin/config/${encodeURIComponent(body.key)}`, { value: body.value }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── SMTP ────────────────────────────────────────────────────
|
||||
interface SmtpStatusExt extends AdminSmtpStatusOk { source?: string }
|
||||
export function AdminSmtpTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [clearConfirm, setClearConfirm] = useState(false);
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState('587');
|
||||
const [user, setUser] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [from, setFrom] = useState('');
|
||||
const [secure, setSecure] = useState('false');
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [testTo, setTestTo] = useState('');
|
||||
const [testTemplate, setTestTemplate] = useState('verify');
|
||||
|
||||
const { data } = useQuery<SmtpStatusExt>({
|
||||
queryKey: ['admin-smtp-status'],
|
||||
queryFn: () => api.get<SmtpStatusExt>('/api/admin/config/smtp/status'),
|
||||
});
|
||||
if (!hydrated && data) {
|
||||
setHost(data.host || '');
|
||||
setPort(String(data.port ?? '587'));
|
||||
setUser(data.user || '');
|
||||
setFrom(data.from || '');
|
||||
setHydrated(true);
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: { host: string; port: string; user: string; pass: string; from: string; secure: boolean }) =>
|
||||
api.put<{ success: true }>('/api/admin/config/smtp', body),
|
||||
onSuccess: () => { setMsg({ text: 'SMTP settings saved', kind: 'ok' }); setPass(''); qc.invalidateQueries({ queryKey: ['admin-smtp-status'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.delete<{ message: string }>('/api/admin/config/smtp'),
|
||||
onSuccess: (d) => { setMsg({ text: d.message, kind: 'info' }); qc.invalidateQueries({ queryKey: ['admin-smtp-status'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const testEmail = useMutation({
|
||||
mutationFn: (body: { to: string; template: string }) =>
|
||||
api.post<{ success: true }>('/api/admin/config/test-email', body),
|
||||
onSuccess: () => setMsg({ text: `Test email sent to ${testTo}`, kind: 'ok' }),
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-smtp-tab">
|
||||
<h2 className="text-lg font-semibold">SMTP</h2>
|
||||
{data && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Status: {data.configured ? '✅ Configured' : '❌ Not configured'}
|
||||
{data.source && <> · source: <strong>{data.source}</strong></>}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={label}>Host</label><input className={input} value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" data-testid="smtp-host" /></div>
|
||||
<div><label className={label}>Port</label><input className={input} value={port} onChange={(e) => setPort(e.target.value)} placeholder="587" data-testid="smtp-port" /></div>
|
||||
<div><label className={label}>Username</label><input className={input} value={user} onChange={(e) => setUser(e.target.value)} data-testid="smtp-user" /></div>
|
||||
<div><label className={label}>Password</label><input type="password" className={input} value={pass} onChange={(e) => setPass(e.target.value)} placeholder="Leave blank to keep existing" data-testid="smtp-pass" /></div>
|
||||
<div><label className={label}>From</label><input className={input} value={from} onChange={(e) => setFrom(e.target.value)} placeholder="noreply@example.com" data-testid="smtp-from" /></div>
|
||||
<div><label className={label}>Secure (TLS)</label><select className={input} value={secure} onChange={(e) => setSecure(e.target.value)} data-testid="smtp-secure"><option value="false">STARTTLS (587)</option><option value="true">SSL/TLS (465)</option></select></div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button type="button" className={btnPrimary} disabled={save.isPending || !host}
|
||||
onClick={() => save.mutate({ host, port, user, pass, from, secure: secure === 'true' })}
|
||||
data-testid="smtp-save">
|
||||
{save.isPending ? 'Saving…' : 'Save SMTP settings'}
|
||||
</button>
|
||||
<button type="button" className={btnDanger} onClick={() => setClearConfirm(true)} data-testid="smtp-clear">Clear DB override</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/40 p-3 space-y-2">
|
||||
<div className="text-sm font-semibold">Send test email</div>
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<div className="flex-1 min-w-[200px]"><label className={label}>Recipient</label><input type="email" className={input} value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="recipient@example.com" data-testid="smtp-test-to" /></div>
|
||||
<div><label className={label}>Template</label><select className={input} value={testTemplate} onChange={(e) => setTestTemplate(e.target.value)} data-testid="smtp-test-template"><option value="verify">verify</option><option value="reset">reset</option><option value="password-changed">password-changed</option></select></div>
|
||||
<button type="button" className={btnGhost} disabled={testEmail.isPending || !testTo} onClick={() => testEmail.mutate({ to: testTo, template: testTemplate })} data-testid="smtp-test-send">
|
||||
{testEmail.isPending ? 'Sending…' : 'Send test'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusLine msg={msg} />
|
||||
<ConfirmModal
|
||||
open={clearConfirm}
|
||||
title="Clear DB SMTP settings?"
|
||||
body="Removes smtp.* entries from the DB. Env vars will still apply if they're set (e.g. SMTP_HOST from OpenBao)."
|
||||
confirmText="Clear"
|
||||
danger
|
||||
busy={clear.isPending}
|
||||
onConfirm={() => { clear.mutate(); setClearConfirm(false); }}
|
||||
onCancel={() => setClearConfirm(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Email templates ────────────────────────────────────────
|
||||
const EMAIL_TEMPLATES = ['verify', 'reset', 'password-changed'];
|
||||
export function AdminEmailTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [template, setTemplate] = useState('verify');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
|
||||
const { data } = useQuery<AdminConfigOk>({
|
||||
queryKey: ['admin-config'],
|
||||
queryFn: () => api.get<AdminConfigOk>('/api/admin/config'),
|
||||
});
|
||||
|
||||
function pick(tpl: string) {
|
||||
setTemplate(tpl);
|
||||
const map = new Map((data?.config || []).map((c) => [c.key, c.value || '']));
|
||||
setSubject(map.get('email.' + tpl + '.subject') || '');
|
||||
setBody(map.get('email.' + tpl + '.body') || '');
|
||||
}
|
||||
// Hydrate when data first arrives.
|
||||
if (data && !subject && !body) {
|
||||
pick(template);
|
||||
}
|
||||
|
||||
const putConfig = useConfigPut();
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
await Promise.all([
|
||||
putConfig.mutateAsync({ key: 'email.' + template + '.subject', value: subject }),
|
||||
putConfig.mutateAsync({ key: 'email.' + template + '.body', value: body }),
|
||||
]);
|
||||
setMsg({ text: 'Email template saved', kind: 'ok' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-config'] });
|
||||
} catch (e) {
|
||||
setMsg({ text: (e as Error).message, kind: 'err' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-email-tab">
|
||||
<h2 className="text-lg font-semibold">Email templates</h2>
|
||||
<div className="max-w-xs">
|
||||
<label className={label}>Template</label>
|
||||
<select className={input} value={template} onChange={(e) => pick(e.target.value)} data-testid="email-template">
|
||||
{EMAIL_TEMPLATES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><label className={label}>Subject</label><input className={input} value={subject} onChange={(e) => setSubject(e.target.value)} data-testid="email-subject" /></div>
|
||||
<div><label className={label}>Body (HTML)</label><textarea rows={10} className={input + ' resize-y font-mono text-xs'} value={body} onChange={(e) => setBody(e.target.value)} data-testid="email-body" /></div>
|
||||
<button type="button" className={btnPrimary} disabled={putConfig.isPending} onClick={save} data-testid="email-save">
|
||||
{putConfig.isPending ? 'Saving…' : 'Save template'}
|
||||
</button>
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Prompts ─────────────────────────────────────────────
|
||||
export function AdminPromptsTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [selected, setSelected] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [resetConfirm, setResetConfirm] = useState(false);
|
||||
|
||||
const { data } = useQuery<AdminPromptsOk>({
|
||||
queryKey: ['admin-prompts'],
|
||||
queryFn: () => api.get<AdminPromptsOk>('/api/admin/config/prompts'),
|
||||
});
|
||||
if (data && !selected && data.prompts.length > 0) {
|
||||
setSelected(data.prompts[0].key);
|
||||
setValue(data.prompts[0].value);
|
||||
}
|
||||
function pick(key: string) {
|
||||
setSelected(key);
|
||||
const p = data?.prompts.find((x) => x.key === key);
|
||||
setValue(p?.value || '');
|
||||
}
|
||||
|
||||
const putConfig = useConfigPut();
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (key: string) =>
|
||||
api.post<{ value: string }>(`/api/admin/config/prompts/${encodeURIComponent(key)}/reset`, {}),
|
||||
onSuccess: (d) => { setValue(d.value); setMsg({ text: 'Prompt reset to default', kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-prompts'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
await putConfig.mutateAsync({ key: 'prompt.' + selected, value });
|
||||
setMsg({ text: 'Prompt saved', kind: 'ok' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-prompts'] });
|
||||
} catch (e) {
|
||||
setMsg({ text: (e as Error).message, kind: 'err' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-prompts-tab">
|
||||
<h2 className="text-lg font-semibold">AI Prompts</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
System prompts injected before each generation. Reset restores the hardcoded default from src/utils/prompts.ts.
|
||||
</p>
|
||||
<div className="max-w-md">
|
||||
<label className={label}>Prompt</label>
|
||||
<select className={input} value={selected} onChange={(e) => pick(e.target.value)} data-testid="prompts-select">
|
||||
{(data?.prompts || []).map((p) => <option key={p.key} value={p.key}>{p.key}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<textarea rows={14} className={input + ' resize-y font-mono text-xs'} value={value} onChange={(e) => setValue(e.target.value)} data-testid="prompts-text" />
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className={btnPrimary} disabled={putConfig.isPending || !selected} onClick={save} data-testid="prompts-save">
|
||||
{putConfig.isPending ? 'Saving…' : 'Save prompt'}
|
||||
</button>
|
||||
<button type="button" className={btnGhost} disabled={!selected} onClick={() => setResetConfirm(true)} data-testid="prompts-reset">
|
||||
Reset to default
|
||||
</button>
|
||||
</div>
|
||||
<StatusLine msg={msg} />
|
||||
<ConfirmModal
|
||||
open={resetConfirm}
|
||||
title={`Reset "${selected}"?`}
|
||||
body="Restores the hardcoded default. Cannot be undone."
|
||||
confirmText="Reset"
|
||||
danger
|
||||
busy={resetMutation.isPending}
|
||||
onConfirm={() => { resetMutation.mutate(selected); setResetConfirm(false); }}
|
||||
onCancel={() => setResetConfirm(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Models ──────────────────────────────────────────────
|
||||
interface AdminModelsExtra extends AdminModelsOk { litellmHint?: boolean; custom?: Array<{ id: string; label?: string }> }
|
||||
export function AdminModelsTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const { data } = useQuery<AdminModelsExtra>({
|
||||
queryKey: ['admin-models'],
|
||||
queryFn: () => api.get<AdminModelsExtra>('/api/admin/config/models'),
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (body: { id: string; enabled: boolean }) =>
|
||||
api.put<{ success: true }>('/api/admin/config/models/toggle', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-models'] }),
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
const setDefault = useMutation({
|
||||
mutationFn: (modelId: string) =>
|
||||
api.put<{ success: true }>('/api/admin/config/models/default', { modelId }),
|
||||
onSuccess: (_, id) => { setMsg({ text: `Default model set to ${id}`, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-models'] }); },
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-models-tab">
|
||||
<h2 className="text-lg font-semibold">AI Models</h2>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Active provider: <strong>{data?.provider || '—'}</strong>
|
||||
{data?.defaultModel && <> · Default: <strong>{data.defaultModel}</strong></>}
|
||||
</div>
|
||||
{data?.litellmHint && (
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||||
LiteLLM provider has no built-in model list — use the legacy "Discover" flow to populate.
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="admin-models-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={th}>Enabled</th>
|
||||
<th className={th}>Default</th>
|
||||
<th className={th}>Model ID</th>
|
||||
<th className={th}>Label</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.models || []).map((m) => (
|
||||
<tr key={m.id} data-testid={`admin-model-row-${m.id}`}>
|
||||
<td className={td}>
|
||||
<input type="checkbox" className="accent-primary size-4" checked={m.enabled} onChange={(e) => toggle.mutate({ id: m.id, enabled: e.target.checked })} />
|
||||
</td>
|
||||
<td className={td}>
|
||||
<input type="radio" name="default-model" checked={data?.defaultModel === m.id} onChange={() => setDefault.mutate(m.id)} disabled={!m.enabled} />
|
||||
</td>
|
||||
<td className={td + ' font-mono text-xs'}>{m.id}</td>
|
||||
<td className={td}>{m.label || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{(data?.models || []).length === 0 && <tr><td className={td + ' italic text-muted-foreground'} colSpan={4}>No models available.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
Model discovery (search + add-custom) still lives in the legacy viewer — ports when the provider integration is revamped.
|
||||
</div>
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TTS / STT Provider ─────────────────────────────────────
|
||||
interface VoiceProviderResp {
|
||||
provider: string;
|
||||
defaultVoice?: string | null;
|
||||
defaultModel?: string | null;
|
||||
voices?: Array<{ value: string; label?: string }>;
|
||||
models?: Array<{ value: string; label?: string }>;
|
||||
configured?: boolean;
|
||||
}
|
||||
function useVoiceProvider(path: '/api/admin/config/tts' | '/api/admin/config/stt') {
|
||||
return useQuery<VoiceProviderResp>({
|
||||
queryKey: ['voice-provider', path],
|
||||
queryFn: () => api.get<VoiceProviderResp>(path),
|
||||
});
|
||||
}
|
||||
export function AdminTtsTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const { data } = useVoiceProvider('/api/admin/config/tts');
|
||||
const putConfig = useConfigPut();
|
||||
const [voice, setVoice] = useState('');
|
||||
if (data && voice === '' && data.defaultVoice) setVoice(data.defaultVoice);
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
await putConfig.mutateAsync({ key: 'tts.default_voice', value: voice });
|
||||
setMsg({ text: 'Default TTS voice saved', kind: 'ok' });
|
||||
qc.invalidateQueries({ queryKey: ['voice-provider', '/api/admin/config/tts'] });
|
||||
} catch (e) {
|
||||
setMsg({ text: (e as Error).message, kind: 'err' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-tts-tab">
|
||||
<h2 className="text-lg font-semibold">TTS Provider</h2>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Active provider: <strong>{data?.provider || '—'}</strong>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<label className={label}>Default voice</label>
|
||||
<select className={input} value={voice} onChange={(e) => setVoice(e.target.value)} data-testid="admin-tts-voice">
|
||||
<option value="">(none)</option>
|
||||
{(data?.voices || []).map((v) => <option key={v.value} value={v.value}>{v.label || v.value}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" className={btnPrimary} disabled={putConfig.isPending} onClick={save} data-testid="admin-tts-save">
|
||||
{putConfig.isPending ? 'Saving…' : 'Save default voice'}
|
||||
</button>
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function AdminSttTab() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const { data } = useVoiceProvider('/api/admin/config/stt');
|
||||
const putConfig = useConfigPut();
|
||||
const [model, setModel] = useState('');
|
||||
if (data && model === '' && data.defaultModel) setModel(data.defaultModel);
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
await putConfig.mutateAsync({ key: 'stt.default_model', value: model });
|
||||
setMsg({ text: 'Default STT model saved', kind: 'ok' });
|
||||
qc.invalidateQueries({ queryKey: ['voice-provider', '/api/admin/config/stt'] });
|
||||
} catch (e) {
|
||||
setMsg({ text: (e as Error).message, kind: 'err' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-stt-tab">
|
||||
<h2 className="text-lg font-semibold">STT Provider</h2>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Active provider: <strong>{data?.provider || '—'}</strong>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<label className={label}>Default STT model</label>
|
||||
<select className={input} value={model} onChange={(e) => setModel(e.target.value)} data-testid="admin-stt-model">
|
||||
<option value="">(none)</option>
|
||||
{(data?.models || []).map((m) => <option key={m.value} value={m.value}>{m.label || m.value}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" className={btnPrimary} disabled={putConfig.isPending} onClick={save} data-testid="admin-stt-save">
|
||||
{putConfig.isPending ? 'Saving…' : 'Save default model'}
|
||||
</button>
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Audit logs ─────────────────────────────────────────────
|
||||
const LOG_CATEGORIES = ['', 'auth', 'admin', 'clinical', 'export', 'integration', 'documents'];
|
||||
export function AdminLogsTab() {
|
||||
const [category, setCategory] = useState('');
|
||||
const [limit, setLimit] = useState(100);
|
||||
const { data, isLoading, error, refetch } = useQuery<AdminLogsOk>({
|
||||
queryKey: ['admin-logs', category, limit],
|
||||
queryFn: () => api.get<AdminLogsOk>(
|
||||
`/api/admin/logs/all?limit=${limit}${category ? '&category=' + encodeURIComponent(category) : ''}`,
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="admin-logs-tab">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h2 className="text-lg font-semibold">Audit Logs</h2>
|
||||
<div className="flex gap-2 items-center">
|
||||
<label className={label}>Category</label>
|
||||
<select className={input + ' w-36 text-xs'} value={category} onChange={(e) => setCategory(e.target.value)} data-testid="admin-logs-category">
|
||||
{LOG_CATEGORIES.map((c) => <option key={c} value={c}>{c || '(all)'}</option>)}
|
||||
</select>
|
||||
<label className={label}>Limit</label>
|
||||
<select className={input + ' w-24 text-xs'} value={limit} onChange={(e) => setLimit(Number(e.target.value))} data-testid="admin-logs-limit">
|
||||
{[50, 100, 200, 500].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
<button type="button" className={btnGhost} onClick={() => refetch()}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
|
||||
{error && <div className="text-sm text-destructive">{(error as Error).message}</div>}
|
||||
<div className="overflow-x-auto max-h-[70vh] overflow-y-auto">
|
||||
<table className="w-full text-sm" data-testid="admin-logs-table">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr>
|
||||
<th className={th}>Time</th>
|
||||
<th className={th}>User</th>
|
||||
<th className={th}>Category</th>
|
||||
<th className={th}>Action</th>
|
||||
<th className={th}>Detail</th>
|
||||
<th className={th}>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.logs || []).map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className={td + ' text-xs whitespace-nowrap'}>{new Date(l.timestamp).toLocaleString()}</td>
|
||||
<td className={td + ' text-xs'}>{l.user_email || '—'}{l.user_name ? ` (${l.user_name})` : ''}</td>
|
||||
<td className={td + ' text-xs'}>{l.category}</td>
|
||||
<td className={td + ' text-xs font-mono'}>{l.action}</td>
|
||||
<td className={td + ' text-xs'}>{l.detail}</td>
|
||||
<td className={td + ' text-xs text-muted-foreground'}>{l.ip_address || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
310
client/src/pages/Auth.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
// ============================================================
|
||||
// AUTH SCREEN — login / register / forgot-password. Mirrors the
|
||||
// vanilla auth screen feature-for-feature: Turnstile, optional
|
||||
// 2FA TOTP field (reveals on requires2FA response), SSO button
|
||||
// (when OIDC enabled), resend-verification link, HIPAA notice,
|
||||
// APK download link.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import Turnstile from '@/components/Turnstile';
|
||||
import type { PublicConfigOk } from '@/shared/types';
|
||||
|
||||
type Mode = 'login' | 'register' | 'forgot';
|
||||
|
||||
const card = 'rounded-2xl border border-border bg-card p-6 shadow-lg space-y-4 w-full max-w-md';
|
||||
const btnPrimary = 'w-full rounded-md bg-primary text-primary-foreground px-4 py-3 text-sm font-semibold disabled:opacity-60';
|
||||
const btnSso = 'w-full rounded-md bg-slate-900 text-white px-4 py-3 text-sm font-semibold disabled:opacity-60 hover:bg-slate-800';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
const label = 'block text-xs font-semibold text-muted-foreground mb-1';
|
||||
const linkBtn = 'text-sm text-primary hover:underline';
|
||||
const msgOk = 'rounded-md bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900 p-3 text-sm text-green-800 dark:text-green-100';
|
||||
const msgErr = 'rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 p-3 text-sm text-red-800 dark:text-red-100';
|
||||
const msgInfo = 'rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 p-3 text-sm text-amber-900 dark:text-amber-100';
|
||||
|
||||
function useRedirectAfterLogin() {
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
return () => {
|
||||
const next = new URLSearchParams(loc.search).get('next') || '/';
|
||||
nav(next, { replace: true });
|
||||
};
|
||||
}
|
||||
|
||||
export default function Auth() {
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [err, setErr] = useState('');
|
||||
const [ok, setOk] = useState('');
|
||||
const [info, setInfo] = useState('');
|
||||
const qc = useQueryClient();
|
||||
const redirect = useRedirectAfterLogin();
|
||||
|
||||
const { data: cfg } = useQuery<PublicConfigOk>({
|
||||
queryKey: ['public-config'],
|
||||
queryFn: () => api.get<PublicConfigOk>('/api/auth/public-config'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// If local auth is disabled (SSO-only), hide login/register and force SSO.
|
||||
const localDisabled = !!cfg?.disableLocalAuth;
|
||||
|
||||
function switchMode(next: Mode) {
|
||||
setMode(next); setErr(''); setOk(''); setInfo('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900 flex items-center justify-center p-4">
|
||||
<div className={card}>
|
||||
<header className="text-center space-y-1">
|
||||
<div className="text-4xl">🩺</div>
|
||||
<h1 className="text-xl font-bold">Pediatric AI Scribe</h1>
|
||||
<p className="text-xs text-muted-foreground">AI-Powered Clinical Documentation</p>
|
||||
</header>
|
||||
|
||||
{err && <div className={msgErr}>{err}</div>}
|
||||
{info && <div className={msgInfo}>{info}</div>}
|
||||
{ok && <div className={msgOk}>{ok}</div>}
|
||||
|
||||
{mode === 'login' && !localDisabled && (
|
||||
<LoginForm
|
||||
cfg={cfg}
|
||||
onErr={setErr} onInfo={setInfo} onOk={setOk}
|
||||
onLogin={() => { qc.invalidateQueries({ queryKey: ['auth-me'] }); redirect(); }}
|
||||
switchMode={switchMode}
|
||||
/>
|
||||
)}
|
||||
{mode === 'register' && !localDisabled && (cfg?.registrationEnabled ?? true) && (
|
||||
<RegisterForm cfg={cfg} onErr={setErr} onOk={setOk} switchMode={switchMode} />
|
||||
)}
|
||||
{mode === 'forgot' && !localDisabled && (
|
||||
<ForgotForm cfg={cfg} onErr={setErr} onOk={setOk} switchMode={switchMode} />
|
||||
)}
|
||||
|
||||
{/* SSO — always visible when OIDC enabled, even in login-only mode. */}
|
||||
{cfg?.oidcEnabled && (
|
||||
<>
|
||||
{!localDisabled && <div className="relative my-4"><div className="absolute inset-0 flex items-center"><span className="w-full border-t border-border" /></div><div className="relative flex justify-center text-xs text-muted-foreground"><span className="bg-card px-3">or</span></div></div>}
|
||||
<a href="/api/auth/oidc" className={btnSso + ' text-center block no-underline'} data-testid="auth-oidc">
|
||||
🛡️ {cfg.ssoButtonLabel || 'Sign in with SSO'}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
{localDisabled && !cfg?.oidcEnabled && (
|
||||
<div className={msgInfo}>Single sign-on not configured. Contact your administrator.</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-border text-xs text-muted-foreground space-y-2">
|
||||
<div className="flex gap-2 items-start">
|
||||
<span>⚠️</span>
|
||||
<p>HIPAA-compliant AI providers available with BAA. Check your institution's guidelines. Not intended for clinical use without proper authorization.</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<a href="https://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest"
|
||||
target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
📱 Download Android app (APK)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Login ──────────────────────────────────────────────────
|
||||
function LoginForm({
|
||||
cfg, onErr, onInfo, onOk, onLogin, switchMode,
|
||||
}: {
|
||||
cfg: PublicConfigOk | undefined;
|
||||
onErr: (s: string) => void;
|
||||
onInfo: (s: string) => void;
|
||||
onOk: (s: string) => void;
|
||||
onLogin: () => void;
|
||||
switchMode: (m: Mode) => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totp, setTotp] = useState('');
|
||||
const [needs2fa, setNeeds2fa] = useState(false);
|
||||
const [needsVerify, setNeedsVerify] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [resending, setResending] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onErr(''); onInfo(''); onOk('');
|
||||
if (!email || !password) { onErr('Enter email and password'); return; }
|
||||
if (cfg?.turnstileSiteKey && !turnstileToken) { onErr('Please complete the verification'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const body: Record<string, string> = { email, password };
|
||||
if (turnstileToken) body.turnstileToken = turnstileToken;
|
||||
if (totp) body.totpCode = totp;
|
||||
const resp = await api.post<{ token?: string; requires2FA?: boolean; needsVerification?: boolean; message?: string }>(
|
||||
'/api/auth/login', body,
|
||||
);
|
||||
if (resp.requires2FA) {
|
||||
setNeeds2fa(true);
|
||||
onInfo('Enter your 2FA code');
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
if (resp.needsVerification) {
|
||||
setNeedsVerify(true);
|
||||
onErr('Verify your email first. Check your inbox.');
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
if (resp.token) {
|
||||
onOk('Signed in');
|
||||
onLogin();
|
||||
return;
|
||||
}
|
||||
onErr('Login failed');
|
||||
} catch (e) {
|
||||
onErr((e as ApiError).message || 'Login failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
if (!email) { onErr('Enter your email first'); return; }
|
||||
setResending(true);
|
||||
try {
|
||||
const r = await api.post<{ message?: string }>('/api/auth/resend-verification', { email });
|
||||
onOk(r.message || 'Verification email sent');
|
||||
} catch (e) {
|
||||
onErr((e as ApiError).message || 'Failed to resend');
|
||||
} finally { setResending(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3" data-testid="auth-login-form">
|
||||
<h2 className="text-lg font-semibold text-center">Sign in</h2>
|
||||
<div><label className={label}>Email</label><input type="email" autoFocus required className={input} value={email} onChange={(e) => setEmail(e.target.value)} data-testid="auth-email" /></div>
|
||||
<div><label className={label}>Password</label><input type="password" required className={input} value={password} onChange={(e) => setPassword(e.target.value)} data-testid="auth-password" /></div>
|
||||
{needs2fa && (
|
||||
<div><label className={label}>2FA code</label><input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} className={input + ' font-mono tracking-widest'} value={totp} onChange={(e) => setTotp(e.target.value.replace(/\D/g, ''))} data-testid="auth-totp" autoFocus /></div>
|
||||
)}
|
||||
<Turnstile siteKey={cfg?.turnstileSiteKey} onToken={setTurnstileToken} />
|
||||
<button type="submit" className={btnPrimary} disabled={busy} data-testid="auth-submit">
|
||||
{busy ? 'Signing in…' : needs2fa ? 'Verify 2FA' : 'Sign in'}
|
||||
</button>
|
||||
{needsVerify && (
|
||||
<div className="text-center">
|
||||
<button type="button" onClick={resend} className={linkBtn} disabled={resending} data-testid="auth-resend-verify">
|
||||
{resending ? 'Sending…' : 'Resend verification link'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm">
|
||||
{(cfg?.registrationEnabled ?? true) && (
|
||||
<button type="button" onClick={() => switchMode('register')} className={linkBtn} data-testid="auth-show-register">Create account</button>
|
||||
)}
|
||||
<button type="button" onClick={() => switchMode('forgot')} className={linkBtn + ' ml-auto'} data-testid="auth-show-forgot">Forgot password?</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Register ───────────────────────────────────────────────
|
||||
function RegisterForm({ cfg, onErr, onOk, switchMode }: {
|
||||
cfg: PublicConfigOk | undefined;
|
||||
onErr: (s: string) => void;
|
||||
onOk: (s: string) => void;
|
||||
switchMode: (m: Mode) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onErr(''); onOk('');
|
||||
if (!name || !email || password.length < 8) { onErr('Fill all fields (password 8+ chars)'); return; }
|
||||
if (cfg?.turnstileSiteKey && !turnstileToken) { onErr('Please complete the verification'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const body: Record<string, string> = { name, email, password };
|
||||
if (turnstileToken) body.turnstileToken = turnstileToken;
|
||||
const r = await api.post<{ message?: string; needsVerification?: boolean; token?: string }>('/api/auth/register', body);
|
||||
if (r.needsVerification) {
|
||||
onOk('Account created. Check your email to verify.');
|
||||
} else if (r.token) {
|
||||
onOk('Account created. Signing you in…');
|
||||
setTimeout(() => { window.location.href = '/'; }, 800);
|
||||
} else {
|
||||
onOk(r.message || 'Account created');
|
||||
}
|
||||
switchMode('login');
|
||||
} catch (e) {
|
||||
onErr((e as ApiError).message || 'Registration failed');
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3" data-testid="auth-register-form">
|
||||
<h2 className="text-lg font-semibold text-center">Create account</h2>
|
||||
<div><label className={label}>Full name</label><input type="text" required autoFocus className={input} value={name} onChange={(e) => setName(e.target.value)} data-testid="auth-reg-name" /></div>
|
||||
<div><label className={label}>Email</label><input type="email" required className={input} value={email} onChange={(e) => setEmail(e.target.value)} data-testid="auth-reg-email" /></div>
|
||||
<div><label className={label}>Password (8+ characters)</label><input type="password" required minLength={8} className={input} value={password} onChange={(e) => setPassword(e.target.value)} data-testid="auth-reg-password" /></div>
|
||||
<Turnstile siteKey={cfg?.turnstileSiteKey} onToken={setTurnstileToken} />
|
||||
<button type="submit" className={btnPrimary} disabled={busy} data-testid="auth-reg-submit">
|
||||
{busy ? 'Creating…' : 'Create account'}
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<button type="button" onClick={() => switchMode('login')} className={linkBtn}>Back to sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Forgot ─────────────────────────────────────────────────
|
||||
function ForgotForm({ cfg, onErr, onOk, switchMode }: {
|
||||
cfg: PublicConfigOk | undefined;
|
||||
onErr: (s: string) => void;
|
||||
onOk: (s: string) => void;
|
||||
switchMode: (m: Mode) => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onErr(''); onOk('');
|
||||
if (!email) { onErr('Enter your email'); return; }
|
||||
if (cfg?.turnstileSiteKey && !turnstileToken) { onErr('Please complete the verification'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const body: Record<string, string> = { email };
|
||||
if (turnstileToken) body.turnstileToken = turnstileToken;
|
||||
const r = await api.post<{ message?: string }>('/api/auth/forgot-password', body);
|
||||
onOk(r.message || 'If an account exists, a reset link was sent.');
|
||||
} catch (e) {
|
||||
onErr((e as ApiError).message || 'Request failed');
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3" data-testid="auth-forgot-form">
|
||||
<h2 className="text-lg font-semibold text-center">Reset password</h2>
|
||||
<p className="text-xs text-muted-foreground text-center">Enter your email and we'll send a reset link.</p>
|
||||
<div><label className={label}>Email</label><input type="email" required autoFocus className={input} value={email} onChange={(e) => setEmail(e.target.value)} data-testid="auth-forgot-email" /></div>
|
||||
<Turnstile siteKey={cfg?.turnstileSiteKey} onToken={setTurnstileToken} />
|
||||
<button type="submit" className={btnPrimary} disabled={busy} data-testid="auth-forgot-submit">
|
||||
{busy ? 'Sending…' : 'Send reset link'}
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<button type="button" onClick={() => switchMode('login')} className={linkBtn}>Back to sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
204
client/src/pages/Bedside.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// ============================================================
|
||||
// BEDSIDE — emergency + rapid-reference pediatric tools.
|
||||
// Top-level age-to-weight estimation is React + pure shared TS.
|
||||
// Individual dosing modules port one at a time after parity tests.
|
||||
//
|
||||
// The 15 clinical sub-modules (neonatal, airway, cardiac, respiratory,
|
||||
// ventilation, seizures, sepsis, anaphylaxis, sedation, agitation,
|
||||
// antiemetics, antimicrobials, burns, toxicology, trauma) stay in the
|
||||
// vanilla viewer for now. Each one carries weight-based dosing +
|
||||
// clinical decision content the migration checkpoint explicitly
|
||||
// flagged as must-not-be-"simplified" by an LLM — they belong in
|
||||
// dedicated per-module commits alongside the calculators port (Rosner
|
||||
// BP splines, Fenton LMS, AAP 2022 bilirubin, APLS weights) where
|
||||
// test vectors can verify byte-for-byte parity.
|
||||
//
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
estimateWeightFromAgeMonths,
|
||||
formatAgeMonths,
|
||||
parseAgeMonths,
|
||||
} from '@shared/clinical/calculators';
|
||||
import { renderBedsideRealPanel, REAL_BEDSIDE_PANELS } from './BedsidePanels';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
|
||||
const btnGhost = 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted';
|
||||
const label = 'block text-xs font-medium text-muted-foreground';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
|
||||
interface Pill {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string; // emoji stand-in; font-awesome lives in the legacy shell
|
||||
summary: string;
|
||||
}
|
||||
|
||||
// Order and labels match public/components/bedside.html exactly.
|
||||
const PILLS: Pill[] = [
|
||||
{ id: 'neonatal', label: 'Neonatal', icon: '👶', summary: 'GA classification, AGA/SGA/LGA, prematurity category (Fenton 2013 / WHO).' },
|
||||
{ id: 'airway', label: 'Airway / RSI', icon: '💨', summary: 'ETT size + depth, RSI induction + paralytic dosing by weight.' },
|
||||
{ id: 'cardiac', label: 'Cardiac Arrest', icon: '❤️', summary: 'PALS dosing (epinephrine, amiodarone, lidocaine), defibrillation J/kg.' },
|
||||
{ id: 'respiratory', label: 'Respiratory', icon: '🫁', summary: 'Asthma, bronchiolitis, croup severity + dosing.' },
|
||||
{ id: 'ventilation', label: 'O₂ & Ventilation', icon: '🌀', summary: 'NC / HFNC / CPAP / BiPAP flow + FiO₂ targets by age.' },
|
||||
{ id: 'seizure', label: 'Seizures', icon: '🧠', summary: 'Benzodiazepine + second/third-line weight-based dosing.' },
|
||||
{ id: 'sepsis', label: 'Sepsis & Fever', icon: '🦠', summary: 'Empirical antibiotics + fluid bolus dosing by weight.' },
|
||||
{ id: 'anaphylaxis', label: 'Anaphylaxis', icon: '💉', summary: 'Epinephrine IM, IV infusion, steroid + antihistamine dosing.' },
|
||||
{ id: 'sedation', label: 'Sedation', icon: '🛌', summary: 'Procedural sedation regimens — ketamine, propofol, midazolam.' },
|
||||
{ id: 'agitation', label: 'Agitation', icon: '😤', summary: 'Weight-based haloperidol, olanzapine, lorazepam.' },
|
||||
{ id: 'antiemetics', label: 'Antiemetics', icon: '💊', summary: 'Ondansetron, metoclopramide, promethazine dosing.' },
|
||||
{ id: 'antimicrobials', label: 'Antimicrobials', icon: '🧫', summary: 'Common empirical regimens keyed to syndrome + weight.' },
|
||||
{ id: 'burns', label: 'Burns', icon: '🔥', summary: 'TBSA % (Lund-Browder, Rule of Nines-children), Parkland fluids.' },
|
||||
{ id: 'toxicology', label: 'Toxicology', icon: '☠️', summary: 'Common toxidromes + antidotes + decontamination windows.' },
|
||||
{ id: 'trauma', label: 'Trauma', icon: '🩹', summary: 'PECARN, c-spine, blood-product dosing, TXA.' },
|
||||
];
|
||||
|
||||
function BedsideWeightEstimator() {
|
||||
const [age, setAge] = useState('');
|
||||
const [formula, setFormula] = useState<'apls' | 'bestguess'>('apls');
|
||||
const [manualWeight, setManualWeight] = useState('');
|
||||
|
||||
const months = parseAgeMonths(age);
|
||||
const estimate = months == null ? null : estimateWeightFromAgeMonths(months);
|
||||
const pickedWeight = estimate
|
||||
? formula === 'bestguess'
|
||||
? estimate.all.bestGuess
|
||||
: estimate.all.apls
|
||||
: null;
|
||||
const displayedWeight = manualWeight.trim() || (pickedWeight == null ? '' : String(pickedWeight));
|
||||
|
||||
function clear() {
|
||||
setAge('');
|
||||
setFormula('apls');
|
||||
setManualWeight('');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="bedside-weight-estimator">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Age → Weight Estimator</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shared starting point for Bedside dosing. Uses the same APLS and Best Guess formulas as the legacy app.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[1.2fr_1fr_1fr_auto] md:items-end">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="bedside-react-age" className={label}>Age</label>
|
||||
<input
|
||||
id="bedside-react-age"
|
||||
value={age}
|
||||
onChange={(event) => setAge(event.target.value)}
|
||||
placeholder='e.g. "18m", "3y", "2y5m"'
|
||||
className={input}
|
||||
data-testid="bedside-age-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="bedside-react-formula" className={label}>Formula</label>
|
||||
<select
|
||||
id="bedside-react-formula"
|
||||
value={formula}
|
||||
onChange={(event) => {
|
||||
setFormula(event.target.value as 'apls' | 'bestguess');
|
||||
setManualWeight('');
|
||||
}}
|
||||
className={input}
|
||||
data-testid="bedside-formula-select"
|
||||
>
|
||||
<option value="apls">APLS</option>
|
||||
<option value="bestguess">Best Guess</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="bedside-react-weight" className={label}>Weight (kg)</label>
|
||||
<input
|
||||
id="bedside-react-weight"
|
||||
type="number"
|
||||
min="0.3"
|
||||
step="0.1"
|
||||
value={displayedWeight}
|
||||
onChange={(event) => setManualWeight(event.target.value)}
|
||||
className={input}
|
||||
data-testid="bedside-weight-input"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={clear} className={btnGhost}>Clear</button>
|
||||
</div>
|
||||
{age.trim() && months == null ? (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200">
|
||||
Could not parse age. Try "3y", "18 months", or "15 days".
|
||||
</div>
|
||||
) : null}
|
||||
{estimate && pickedWeight != null ? (
|
||||
<div className="rounded-lg border border-border bg-muted/40 p-4 text-sm" data-testid="bedside-estimate-result">
|
||||
<div className="font-semibold">{pickedWeight} kg estimated from {formatAgeMonths(months ?? 0)}</div>
|
||||
<div className="text-muted-foreground">
|
||||
APLS: {estimate.all.apls} kg · Best Guess: {estimate.all.bestGuess} kg. You can override the weight field.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyPanel({ pill }: { pill: Pill }) {
|
||||
return (
|
||||
<section className={card} data-testid={'bedside-panel-' + pill.id}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl" aria-hidden>{pill.icon}</span>
|
||||
<h2 className="text-lg font-semibold">{pill.label}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{pill.summary}</p>
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-3 text-sm space-y-2">
|
||||
<p className="text-amber-900 dark:text-amber-100">
|
||||
Weight-based calculators for this module run in the legacy viewer while the clinical data is
|
||||
verified for a direct React port. Open the legacy Bedside tab to use the full dosing flow.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/#bedside" className={btnPrimary + ' inline-block'}>
|
||||
Open in legacy viewer
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Bedside() {
|
||||
const [active, setActive] = useState<string>(PILLS[0].id);
|
||||
const pill = PILLS.find((p) => p.id === active) ?? PILLS[0];
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Bedside</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Emergency and rapid-reference pediatric tools. Weight-based dosing throughout — always verify against institutional protocols.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2" data-testid="bedside-subnav">
|
||||
{PILLS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setActive(p.id)}
|
||||
className={
|
||||
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
|
||||
(active === p.id
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted hover:bg-muted/80 border-border')
|
||||
}
|
||||
data-testid={'bedside-pill-' + p.id}
|
||||
>
|
||||
<span className="mr-1" aria-hidden>{p.icon}</span>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<BedsideWeightEstimator />
|
||||
{REAL_BEDSIDE_PANELS.has(pill.id) ? renderBedsideRealPanel(pill.id) : <LegacyPanel pill={pill} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1497
client/src/pages/BedsidePanels.tsx
Normal file
641
client/src/pages/BedsidePanels2.tsx
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
// ============================================================
|
||||
// BEDSIDE PANELS (second batch) — neonatal, respiratory,
|
||||
// ventilation, sepsis, burns. Completes parity with the 15
|
||||
// vanilla Bedside sub-modules. Drug per-kg + max values ported
|
||||
// byte-for-byte from public/js/bedside/<module>.js.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { formatDose } from '@shared/clinical/calculators';
|
||||
import { neonatalAssess, type Sex } from '@shared/clinical/fenton';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
const label = 'block text-xs font-medium text-muted-foreground';
|
||||
const th = 'text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground';
|
||||
const td = 'px-2 py-1.5 border-b border-border align-top text-sm';
|
||||
|
||||
function Dose({ label: l }: { label: string }) {
|
||||
const i = l.indexOf('(');
|
||||
if (i < 0) return <span className="font-semibold">{l}</span>;
|
||||
return <span><span className="font-semibold">{l.slice(0, i).trim()}</span>{' '}<span className="text-xs text-muted-foreground">{l.slice(i)}</span></span>;
|
||||
}
|
||||
|
||||
function DrugTable({ children, notes = true }: { children: React.ReactNode; notes?: boolean }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr><th className={th}>Drug</th><th className={th}>Dose</th><th className={th}>Route</th>{notes && <th className={th}>Notes</th>}</tr></thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ name, dose, route, notes }: { name: string; dose: React.ReactNode; route: string; notes?: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td className={td + ' font-semibold'} dangerouslySetInnerHTML={{ __html: name }} />
|
||||
<td className={td}>{dose}</td>
|
||||
<td className={td + ' text-xs'}>{route}</td>
|
||||
{notes !== undefined && <td className={td + ' text-xs text-muted-foreground'} dangerouslySetInnerHTML={{ __html: notes }} />}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Neonatal ────────────────────────────────────────────────
|
||||
export function NeonatalPanel() {
|
||||
const [weeks, setWeeks] = useState('');
|
||||
const [days, setDays] = useState('0');
|
||||
const [wtG, setWtG] = useState('');
|
||||
const [sex, setSex] = useState<Sex>('male');
|
||||
const [kgForNrp, setKgForNrp] = useState('');
|
||||
const [apgarScores, setApgarScores] = useState<Record<string, number>>({ appearance: 2, pulse: 2, grimace: 2, activity: 2, respiration: 2 });
|
||||
|
||||
const weeksNum = Number.parseInt(weeks, 10);
|
||||
const daysNum = Number.parseInt(days, 10) || 0;
|
||||
const wtNum = Number.parseFloat(wtG);
|
||||
const validAssess = Number.isFinite(weeksNum) && weeksNum >= 22 && weeksNum <= 44 && Number.isFinite(wtNum) && wtNum > 0;
|
||||
const assess = validAssess ? neonatalAssess(weeksNum, daysNum, wtNum, sex) : null;
|
||||
|
||||
const kg = Number.parseFloat(kgForNrp);
|
||||
const validKg = Number.isFinite(kg) && kg > 0;
|
||||
const epiIvLow = validKg ? Math.round(kg * 0.01 * 100) / 100 : 0;
|
||||
const epiIvHigh = validKg ? Math.round(kg * 0.03 * 100) / 100 : 0;
|
||||
const epiEtLow = validKg ? Math.round(kg * 0.05 * 100) / 100 : 0;
|
||||
const epiEtHigh = validKg ? Math.round(kg * 0.1 * 100) / 100 : 0;
|
||||
const ns = validKg ? Math.round(kg * 10) : 0;
|
||||
const d10 = validKg ? Math.round(kg * 2 * 10) / 10 : 0;
|
||||
|
||||
const apgarTotal = Object.values(apgarScores).reduce((s, v) => s + v, 0);
|
||||
const apgarSeverity = apgarTotal >= 7 ? 'Reassuring' : apgarTotal >= 4 ? 'Moderately depressed' : 'Severely depressed';
|
||||
const apgarColor = apgarTotal >= 7 ? 'text-green-600 bg-green-50' : apgarTotal >= 4 ? 'text-amber-600 bg-amber-50' : 'text-destructive bg-red-50';
|
||||
const apgarGuidance = apgarTotal >= 7
|
||||
? 'Routine newborn care. Continue reassessment. Repeat at 5 min.'
|
||||
: apgarTotal >= 4
|
||||
? 'Stimulate, clear airway, warm. Give O₂ if cyanotic. Ventilate with PPV if HR <100 or apneic/gasping. Reassess q30 sec.'
|
||||
: 'Full NRP pathway — PPV immediately. Intubate if PPV ineffective. Chest compressions if HR <60. Epinephrine and volume per NRP.';
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="bedside-panel-neonatal">
|
||||
<h2 className="text-lg font-semibold">Neonatal Assessment + NRP + Apgar</h2>
|
||||
|
||||
{/* Assessment */}
|
||||
<h3 className="text-sm font-semibold">Gestational age + size assessment (Fenton 2013)</h3>
|
||||
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4 max-w-xl">
|
||||
<div><label className={label}>GA weeks</label><input type="number" min="22" max="44" className={input} value={weeks} onChange={(e) => setWeeks(e.target.value)} data-testid="neo-weeks" /></div>
|
||||
<div><label className={label}>GA days (0-6)</label><input type="number" min="0" max="6" className={input} value={days} onChange={(e) => setDays(e.target.value)} data-testid="neo-days" /></div>
|
||||
<div><label className={label}>Birth wt (g)</label><input type="number" min="200" max="7000" className={input} value={wtG} onChange={(e) => setWtG(e.target.value)} data-testid="neo-weight" /></div>
|
||||
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)} data-testid="neo-sex"><option value="male">Male</option><option value="female">Female</option></select></div>
|
||||
</div>
|
||||
{assess && (
|
||||
<div className="grid gap-3 sm:grid-cols-2" data-testid="neo-result">
|
||||
<div className="rounded-md border p-3" style={{ borderColor: assess.gaClass.color + '55', background: assess.gaClass.color + '10' }}>
|
||||
<div className="text-xs text-muted-foreground">Gestational Age</div>
|
||||
<div className="text-base font-bold" style={{ color: assess.gaClass.color }}>{assess.gaClass.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{weeksNum} wk {daysNum} d ({assess.gaDecimal.toFixed(1)} wk)</div>
|
||||
</div>
|
||||
<div className="rounded-md border p-3" style={{ borderColor: assess.weightClass.color + '55', background: assess.weightClass.color + '10' }}>
|
||||
<div className="text-xs text-muted-foreground">Weight for Gestational Age</div>
|
||||
<div className="text-base font-bold" style={{ color: assess.weightClass.color }}>{assess.weightClass.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{assess.percentile.toFixed(1)}th percentile · {assess.weightClass.detail}</div>
|
||||
</div>
|
||||
<div className="rounded-md border p-3" style={{ borderColor: assess.bwClass.color + '55', background: assess.bwClass.color + '10' }}>
|
||||
<div className="text-xs text-muted-foreground">Birth Weight Category</div>
|
||||
<div className="text-base font-bold" style={{ color: assess.bwClass.color }}>{assess.bwClass.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{wtNum} g ({(wtNum / 1000).toFixed(2)} kg)</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wide">Fenton ({sex})</div>
|
||||
<div className="space-y-0.5 mt-1">
|
||||
<div><strong>Expected weight (M):</strong> {assess.expectedWeight} g</div>
|
||||
<div><strong>Z-score:</strong> {assess.z.toFixed(2)}</div>
|
||||
<div><strong>Percentile:</strong> {assess.percentile.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NRP pathway */}
|
||||
<h3 className="text-sm font-semibold mt-3">NRP pathway (AHA/AAP 8th ed 2020)</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">BIRTH — ASSESS (first 30 sec)</div><div className="text-xs text-muted-foreground">Term? Tone? Breathing/crying? All yes → routine care. Any no → warm, dry, stimulate, clear airway PRN, evaluate HR + resp.</div></div>
|
||||
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">HR <100 OR apneic/gasping (60 s)</div><div className="text-xs text-muted-foreground"><strong>Start PPV</strong> 40-60 breaths/min, room air for term / 21-30% for preterm. Attach SpO₂ (right hand) ± ECG. MR SOPA if ineffective.</div></div>
|
||||
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">HR <100 after 30 s effective PPV</div><div className="text-xs text-muted-foreground">Reassess ventilation — ensure chest rise. Consider increasing FiO₂, intubation, or LMA. Continue PPV.</div></div>
|
||||
<div className="rounded-md border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">HR <60 after 30 s effective PPV</div><div className="text-xs text-muted-foreground"><strong>Intubate + chest compressions</strong> — 3:1 ratio (90 compressions + 30 breaths/min), FiO₂ 100%, lower 1/3 sternum, depth 1/3 AP chest.</div></div>
|
||||
<div className="rounded-md border-l-4 border-destructive bg-red-100 dark:bg-red-950/40 p-3"><div className="font-semibold">HR <60 despite compressions + PPV × 60 s</div><div className="text-xs text-muted-foreground"><strong>Epinephrine 1:10,000 (0.1 mg/mL):</strong> IV/IO 0.01-0.03 mg/kg (0.1-0.3 mL/kg) — preferred. ETT 0.05-0.1 mg/kg. Repeat q3-5 min. Hypovolemia: <strong>NS 10 mL/kg IV/IO over 5-10 min</strong>.</div></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs">
|
||||
<div className="rounded-md bg-muted/40 p-2"><strong>Target SpO₂ (preductal):</strong><br />1 min 60-65% · 2 min 65-70% · 3 min 70-75% · 4 min 75-80% · 5 min 80-85% · 10 min 85-95%</div>
|
||||
<div className="rounded-md bg-muted/40 p-2"><strong>Initial ETT size:</strong><br /><1 kg / <28 wk: 2.5 · 1-2 kg / 28-34 wk: 3.0 · 2-3 kg / 34-38 wk: 3.5 · >3 kg / >38 wk: 3.5-4.0</div>
|
||||
<div className="rounded-md bg-muted/40 p-2"><strong>ETT depth (lip):</strong> ~6 + weight(kg) cm</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-3">NRP drug doses</h3>
|
||||
<div className="max-w-xs"><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={kgForNrp} onChange={(e) => setKgForNrp(e.target.value)} data-testid="nrp-weight" /></div>
|
||||
{validKg ? (
|
||||
<DrugTable>
|
||||
<Row name="Epinephrine 1:10,000" dose={<Dose label={`${epiIvLow}-${epiIvHigh} mg, ${Math.round(epiIvLow * 10) / 10}-${Math.round(epiIvHigh * 10) / 10} mL (0.01-0.03 mg/kg = 0.1-0.3 mL/kg)`} />} route="IV / IO" notes="Preferred route. Repeat q3-5 min." />
|
||||
<Row name="Epinephrine 1:10,000" dose={<Dose label={`${epiEtLow}-${epiEtHigh} mg, ${Math.round(epiEtLow * 10) / 10}-${Math.round(epiEtHigh * 10) / 10} mL (0.05-0.1 mg/kg = 0.5-1 mL/kg)`} />} route="ETT" notes="While IV being placed." />
|
||||
<Row name="Normal saline" dose={<Dose label={`${ns} mL (10 mL/kg)`} />} route="IV / IO" notes="Over 5-10 min for volume. Repeat PRN." />
|
||||
<Row name="Dextrose 10%" dose={<Dose label={`${d10} mL (2 mL/kg = 0.2 g/kg)`} />} route="IV slow push" notes="For documented hypoglycemia. Then D10 infusion 4-6 mg/kg/min." />
|
||||
</DrugTable>
|
||||
) : <p className="text-xs text-destructive">Enter weight (kg) to see NRP doses.</p>}
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||||
<strong>Concentration note:</strong> NRP uses epinephrine <strong>1:10,000</strong> (0.1 mg/mL). NOT 1:1000 (1 mg/mL) — that is IM for anaphylaxis / older patients.
|
||||
</div>
|
||||
|
||||
{/* Apgar */}
|
||||
<h3 className="text-sm font-semibold mt-3">Apgar score</h3>
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-5 text-xs">
|
||||
{[
|
||||
['appearance', 'Appearance', ['Blue/pale', 'Body pink, extremities blue', 'All pink']],
|
||||
['pulse', 'Pulse', ['Absent', '<100 bpm', '≥100 bpm']],
|
||||
['grimace', 'Grimace', ['No response', 'Grimace', 'Cough/sneeze']],
|
||||
['activity', 'Activity', ['Limp', 'Some flexion', 'Active motion']],
|
||||
['respiration', 'Respiration', ['Absent', 'Slow/irregular', 'Good/crying']],
|
||||
].map(([key, lab, options]) => (
|
||||
<div key={key as string}>
|
||||
<label className={label}>{lab}</label>
|
||||
<select className={input} value={apgarScores[key as string]} onChange={(e) => setApgarScores({ ...apgarScores, [key as string]: Number(e.target.value) })} data-testid={'apgar-' + key}>
|
||||
{(options as string[]).map((o, i) => <option key={i} value={i}>{i} — {o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={'rounded-md p-3 ' + apgarColor} data-testid="apgar-result">
|
||||
<div className="text-base font-bold">Apgar: {apgarTotal}/10 — {apgarSeverity}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{apgarGuidance}</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
Fenton TR, Kim JH. BMC Pediatr 2013;13:59 · NRP 8th ed (AHA/AAP 2020) · Apgar is a description of status — <strong>never</strong> delay resuscitation while scoring.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Respiratory ─────────────────────────────────────────────
|
||||
export function RespiratoryPanel() {
|
||||
const [mode, setMode] = useState<'asthma' | 'pram' | 'croup' | 'bronch'>('asthma');
|
||||
const [weight, setWeight] = useState('');
|
||||
const wt = Number.parseFloat(weight);
|
||||
const valid = Number.isFinite(wt) && wt > 0;
|
||||
const f = (perKg: number, max: number | null, unit = 'mg') => (valid ? formatDose(wt, perKg, max, unit) : null);
|
||||
const [asthmaSev, setAsthmaSev] = useState<'mild' | 'moderate' | 'severe' | null>(null);
|
||||
|
||||
// PRAM inputs (0-12 total)
|
||||
const [pram, setPram] = useState({ spo2: 0, retractions: 0, scalene: 0, air: 0, wheeze: 0 });
|
||||
const pramTotal = Object.values(pram).reduce((s, v) => s + v, 0);
|
||||
const pramSev = pramTotal <= 3 ? 'Mild' : pramTotal <= 7 ? 'Moderate' : 'Severe';
|
||||
const pramColor = pramTotal <= 3 ? 'text-green-600 bg-green-50' : pramTotal <= 7 ? 'text-amber-600 bg-amber-50' : 'text-destructive bg-red-50';
|
||||
|
||||
// Croup / Westley (0-17)
|
||||
const [croup, setCroup] = useState({ conscious: 0, cyanosis: 0, stridor: 0, air: 0, retractions: 0 });
|
||||
const croupTotal = Object.values(croup).reduce((s, v) => s + v, 0);
|
||||
const croupSev = croupTotal <= 2 ? 'Mild' : croupTotal <= 5 ? 'Moderate' : croupTotal <= 11 ? 'Severe' : 'Impending Respiratory Failure';
|
||||
const croupColor = croupTotal <= 2 ? 'text-green-600 bg-green-50' : croupTotal <= 5 ? 'text-amber-600 bg-amber-50' : croupTotal <= 11 ? 'text-destructive bg-red-50' : 'text-red-900 bg-red-100';
|
||||
|
||||
// Bronchiolitis inputs
|
||||
const [bronch, setBronch] = useState({ age: 'gte12w', spo2: 'ok', hydration: 'ok', distress: 'mild' });
|
||||
const bronchAdmit = bronch.distress === 'severe' || bronch.spo2 === 'low' || bronch.hydration === 'poor' || bronch.age === 'lt12w';
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="bedside-panel-respiratory">
|
||||
<h2 className="text-lg font-semibold">Respiratory</h2>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(['asthma', 'pram', 'croup', 'bronch'] as const).map((m) => (
|
||||
<button key={m} type="button" onClick={() => setMode(m)} className={'px-3 py-1 rounded-full text-xs font-medium border ' + (mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted border-border')} data-testid={'resp-mode-' + m}>
|
||||
{m === 'asthma' ? 'Asthma' : m === 'pram' ? 'PRAM' : m === 'croup' ? 'Croup (Westley)' : 'Bronchiolitis'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode !== 'pram' && mode !== 'bronch' && (
|
||||
<div className="max-w-xs"><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="resp-weight" /></div>
|
||||
)}
|
||||
|
||||
{/* ASTHMA */}
|
||||
{mode === 'asthma' && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{(['mild', 'moderate', 'severe'] as const).map((s) => (
|
||||
<button key={s} type="button" onClick={() => setAsthmaSev(s)} className={'px-3 py-1 rounded text-xs font-medium border ' + (asthmaSev === s ? (s === 'mild' ? 'bg-green-600 text-white' : s === 'moderate' ? 'bg-amber-500 text-white' : 'bg-destructive text-white') : 'bg-muted')}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{asthmaSev && !valid && <p className="text-xs text-destructive">Enter weight (kg) to see doses.</p>}
|
||||
{asthmaSev === 'mild' && valid && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">Speaks in sentences, no accessory muscle use, SpO₂ ≥94%</p>
|
||||
<DrugTable>
|
||||
<Row name="Albuterol (MDI)" dose="4-8 puffs via spacer" route="Inhaled" notes="q20min × 3 doses, then q1-4h" />
|
||||
<Row name="Albuterol (neb)" dose={<Dose label={`${f(0.15, 5, 'mg')!.label} (min 2.5 mg)`} />} route="Nebulized" notes="q20min × 3 doses" />
|
||||
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="PO/IV" notes="Single dose, or 2 days" />
|
||||
<Row name="Prednisolone" dose={<Dose label={`${f(1, 60)!.label}/day`} />} route="PO" notes="Alternative: 3-5 day course" />
|
||||
</DrugTable>
|
||||
</>
|
||||
)}
|
||||
{asthmaSev === 'moderate' && valid && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">Speaks in phrases, some accessory muscle use, SpO₂ 90-93%</p>
|
||||
<DrugTable>
|
||||
<Row name="Albuterol (neb)" dose={<Dose label={`${f(0.15, 5, 'mg')!.label} (min 2.5 mg)`} />} route="Nebulized" notes="q20min × 3 doses, then continuous if needed" />
|
||||
<Row name="Ipratropium" dose={wt < 20 ? '250 mcg' : '500 mcg'} route="Nebulized" notes="q20min × 3 doses with albuterol" />
|
||||
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="PO/IV/IM" notes="Single dose" />
|
||||
<Row name="O₂ supplemental" dose="Target SpO₂ ≥94%" route="NC/mask" notes="Titrate to effect" />
|
||||
</DrugTable>
|
||||
</>
|
||||
)}
|
||||
{asthmaSev === 'severe' && valid && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">Speaks in words only, significant accessory muscle use, SpO₂ <90%. Consider ICU.</p>
|
||||
<DrugTable>
|
||||
<Row name="Albuterol continuous" dose={<Dose label={`${f(0.5, 20, 'mg')!.label}/hr`} />} route="Continuous neb" notes="Or 0.15-0.3 mg/kg q20min" />
|
||||
<Row name="Ipratropium" dose={wt < 20 ? '250 mcg' : '500 mcg'} route="Nebulized" notes="q20min × 3 doses with albuterol" />
|
||||
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="IV" notes="Or methylprednisolone 2 mg/kg IV (max 60 mg)" />
|
||||
<Row name="Magnesium sulfate" dose={<Dose label={`${f(50, 2000)!.label} IV over 20 min`} />} route="IV" notes="Single dose, monitor BP" />
|
||||
<Row name="Epinephrine (IM)" dose={<Dose label={`${f(0.01, 0.5)!.label} (1:1000)`} />} route="IM" notes="If impending arrest / no IV access" />
|
||||
<Row name="Terbutaline" dose={<Dose label={`${f(0.01, 0.4)!.label} SC/IV`} />} route="SC/IV" notes="Then 0.1-10 mcg/kg/min infusion" />
|
||||
<Row name="O₂ supplemental" dose="Target SpO₂ ≥94%" route="High flow / NIPPV" notes="Consider BiPAP/CPAP" />
|
||||
</DrugTable>
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-xs text-red-900 dark:text-red-100">
|
||||
<strong>Continuous monitoring.</strong> Consider ICU admission. If no response to magnesium → terbutaline infusion. If impending respiratory failure → intubation (ketamine preferred induction agent).
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground italic">NAEPP/GINA guidelines. Always use clinical judgment.</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PRAM */}
|
||||
{mode === 'pram' && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Pediatric Respiratory Assessment Measure (PRAM) — for asthma exacerbation severity (0-12).</p>
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2">
|
||||
{[
|
||||
['spo2', 'SpO₂', ['≥95% (0)', '92-94% (1)', '<92% (2)']],
|
||||
['retractions', 'Suprasternal retractions', ['Absent (0)', 'Present (2)']],
|
||||
['scalene', 'Scalene muscle use', ['Absent (0)', 'Present (2)']],
|
||||
['air', 'Air entry', ['Normal (0)', 'Mild ↓ at bases (1)', 'Widespread ↓ (2)', 'Absent/minimal (3)']],
|
||||
['wheeze', 'Wheezing', ['Absent (0)', 'Expiratory only (1)', 'Ins+exp (2)', 'Audible without stethoscope/silent chest (3)']],
|
||||
].map(([key, lab, options]) => (
|
||||
<div key={key as string}>
|
||||
<label className={label}>{lab}</label>
|
||||
<select className={input} value={pram[key as keyof typeof pram]} onChange={(e) => setPram({ ...pram, [key as string]: Number(e.target.value) })} data-testid={'pram-' + key}>
|
||||
{(options as string[]).map((o, i) => <option key={i} value={i}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={'rounded-md p-3 ' + pramColor} data-testid="pram-result">
|
||||
<div className="text-base font-bold">PRAM Score: {pramTotal}/12 — {pramSev}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Mild (0-3): outpatient management. Moderate (4-7): consider oral steroids + frequent bronchodilators. Severe (8-12): aggressive treatment, consider ICU.</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* CROUP */}
|
||||
{mode === 'croup' && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Westley croup score (0-17).</p>
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2">
|
||||
{[
|
||||
['conscious', 'Level of consciousness', ['Normal (0)', 'Disoriented (5)']],
|
||||
['cyanosis', 'Cyanosis', ['None (0)', 'With agitation (4)', 'At rest (5)']],
|
||||
['stridor', 'Stridor', ['None (0)', 'With agitation (1)', 'At rest (2)']],
|
||||
['air', 'Air entry', ['Normal (0)', 'Decreased (1)', 'Severely decreased (2)']],
|
||||
['retractions', 'Retractions', ['None (0)', 'Mild (1)', 'Moderate (2)', 'Severe (3)']],
|
||||
].map(([key, lab, options]) => (
|
||||
<div key={key as string}>
|
||||
<label className={label}>{lab}</label>
|
||||
<select className={input} value={croup[key as keyof typeof croup]} onChange={(e) => setCroup({ ...croup, [key as string]: Number(e.target.value) })} data-testid={'croup-' + key}>
|
||||
{(options as string[]).map((o, i) => <option key={i} value={i}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={'rounded-md p-3 ' + croupColor} data-testid="croup-result">
|
||||
<div className="text-base font-bold">Westley: {croupTotal}/17 — {croupSev}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Mild ≤2 · Moderate 3-5 · Severe 6-11 · Impending failure ≥12.</div>
|
||||
</div>
|
||||
{valid && (
|
||||
<DrugTable>
|
||||
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route={croupTotal <= 2 ? 'PO' : croupTotal <= 5 ? 'PO/IM' : 'IV/IM'} notes="Preferred corticosteroid; single dose" />
|
||||
{croupTotal > 2 && <Row name="Racemic epinephrine" dose="0.5 mL of 2.25% solution" route="Nebulized" notes="May repeat q15-20min, observe 2-4 h" />}
|
||||
{croupTotal > 2 && <Row name="Nebulized epinephrine" dose="0.5 mL/kg of 1:1000 (max 5 mL)" route="Nebulized" notes="Alternative to racemic" />}
|
||||
{croupTotal > 5 && <Row name="Heliox" dose="70:30 or 80:20" route="Face mask" notes="Consider if not responding" />}
|
||||
</DrugTable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* BRONCHIOLITIS */}
|
||||
{mode === 'bronch' && (
|
||||
<>
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 max-w-xl">
|
||||
<div><label className={label}>Age</label><select className={input} value={bronch.age} onChange={(e) => setBronch({ ...bronch, age: e.target.value })}><option value="lt12w"><12 weeks (high risk)</option><option value="gte12w">≥12 weeks</option></select></div>
|
||||
<div><label className={label}>SpO₂</label><select className={input} value={bronch.spo2} onChange={(e) => setBronch({ ...bronch, spo2: e.target.value })}><option value="ok">≥90%</option><option value="low"><90%</option></select></div>
|
||||
<div><label className={label}>Hydration</label><select className={input} value={bronch.hydration} onChange={(e) => setBronch({ ...bronch, hydration: e.target.value })}><option value="ok">Adequate</option><option value="poor">Poor oral intake</option></select></div>
|
||||
<div><label className={label}>Distress</label><select className={input} value={bronch.distress} onChange={(e) => setBronch({ ...bronch, distress: e.target.value })}><option value="mild">Mild</option><option value="moderate">Moderate</option><option value="severe">Severe</option></select></div>
|
||||
</div>
|
||||
<div className={'rounded-md p-3 ' + (bronchAdmit ? 'text-destructive bg-red-50' : 'text-green-600 bg-green-50')} data-testid="bronch-result">
|
||||
<div className="text-base font-bold">{bronchAdmit ? 'Admit / Observe' : 'Likely Safe for Discharge'}</div>
|
||||
{bronch.age === 'lt12w' && <div className="text-xs text-destructive mt-1">⚠ Age <12 weeks — high risk for apnea. Monitor closely.</div>}
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||||
<strong>NOT recommended (AAP 2014/2023):</strong> Albuterol/salbutamol (no benefit), epinephrine (no evidence), systemic corticosteroids (no benefit), antibiotics (unless bacterial co-infection), chest physiotherapy.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">AAP Clinical Practice Guideline: Management of Bronchiolitis in Infants and Children (2014, reaffirmed 2023). RSV most common (50-80%).</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ventilation (O₂ escalation + vent settings reference) ───
|
||||
export function VentilationPanel() {
|
||||
const [weight, setWeight] = useState('');
|
||||
const [age, setAge] = useState('');
|
||||
const wt = Number.parseFloat(weight);
|
||||
const ageY = Number.parseFloat(age);
|
||||
const validWt = Number.isFinite(wt) && wt > 0;
|
||||
const hfLow = validWt ? Math.round(wt * 1 * 10) / 10 : 0;
|
||||
const hfHigh = validWt ? Math.round(wt * 2 * 10) / 10 : 0;
|
||||
const tvLow = validWt ? Math.round(wt * 6 * 10) / 10 : 0;
|
||||
const tvHigh = validWt ? Math.round(wt * 8 * 10) / 10 : 0;
|
||||
const hasAge = Number.isFinite(ageY) && ageY >= 0;
|
||||
const rate = hasAge
|
||||
? ageY < 0.1 ? '30-40'
|
||||
: ageY < 1 ? '25-35'
|
||||
: ageY < 5 ? '20-25'
|
||||
: ageY < 12 ? '16-20'
|
||||
: '12-16'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="bedside-panel-ventilation">
|
||||
<h2 className="text-lg font-semibold">O₂ & Ventilation</h2>
|
||||
<div className="grid gap-2 grid-cols-2 max-w-md">
|
||||
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="vent-weight" /></div>
|
||||
<div><label className={label}>Age (years)</label><input type="number" min="0" step="0.5" className={input} value={age} onChange={(e) => setAge(e.target.value)} data-testid="vent-age" /></div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold">Target SpO₂</h3>
|
||||
<DrugTable notes>
|
||||
<Row name="Most children" dose="94-98%" route="—" notes="Normal" />
|
||||
<Row name="Bronchiolitis (AAP 2014/2023)" dose="≥90%" route="—" notes="Don't chase higher saturations" />
|
||||
<Row name="Chronic lung disease / CF" dose="90-94%" route="—" notes="Avoid hyperoxia in CO₂ retainers" />
|
||||
<Row name="Preterm neonate" dose="90-95%" route="—" notes="Minimize ROP risk" />
|
||||
<Row name="Term neonate (min of life)" dose="Per NRP ladder" route="—" notes="1 min 60-65% · 10 min 85-95%" />
|
||||
</DrugTable>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-2">Escalation ladder</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="rounded-md border-l-4 border-green-500 bg-green-50 dark:bg-green-950/30 p-3"><div className="font-semibold">1. Nasal cannula (low-flow)</div><div className="text-xs text-muted-foreground"><strong>0.5-6 L/min</strong> · FiO₂ ~24-40% · comfortable, no humidification. Good for mild hypoxia.</div></div>
|
||||
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">2. Simple face mask</div><div className="text-xs text-muted-foreground"><strong>6-10 L/min</strong> · FiO₂ 35-60%. Must keep flow >6 L/min to flush CO₂.</div></div>
|
||||
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">3. Non-rebreather mask</div><div className="text-xs text-muted-foreground"><strong>10-15 L/min</strong> · FiO₂ 60-90%. Reservoir bag must stay inflated.</div></div>
|
||||
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">4. High-flow nasal cannula (HFNC)</div><div className="text-xs text-muted-foreground"><strong>{validWt ? `1-2 L/kg/min = ${hfLow}-${hfHigh} L/min` : '1-2 L/kg/min'}</strong> · heated + humidified · FiO₂ 30-100% titratable · generates ~2-5 cmH₂O PEEP. Reassess at 1-2 h.</div></div>
|
||||
<div className="rounded-md border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">5. Non-invasive (CPAP / BiPAP)</div><div className="text-xs text-muted-foreground">CPAP 5-10 cmH₂O · BiPAP IPAP 10-14 / EPAP 5. Needs cooperative patient, intact airway reflexes, no copious secretions.</div></div>
|
||||
<div className="rounded-md border-l-4 border-destructive bg-red-100 dark:bg-red-950/40 p-3"><div className="font-semibold">6. Intubate + mechanical ventilation</div><div className="text-xs text-muted-foreground">When NIV fails, airway compromised, apnea, or GCS ≤8. See Airway tab for RSI drugs.</div></div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-2">Bag-Valve-Mask (BVM)</h3>
|
||||
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-3 text-xs space-y-1">
|
||||
<div><strong>When:</strong> apnea, bradycardia (HR <60 neonate; inadequate breathing at any age), during resuscitation.</div>
|
||||
<div><strong>Rate:</strong> Newborn 40-60/min · Infant-child 20-30/min · Adolescent 10-12/min (1 breath q5-6 sec).</div>
|
||||
<div><strong>Tidal volume:</strong> 6-8 mL/kg — gentle chest rise only. Avoid over-ventilation.</div>
|
||||
<div><strong>Technique:</strong> head tilt / jaw thrust, E-C or 2-thumb mask seal, squeeze 1 sec, release fully.</div>
|
||||
<div><strong>Not ventilating?</strong> MR SOPA — Mask reseal, Reposition airway, Suction, Open mouth, Pressure ↑, Alternative airway.</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-2">Mechanical vent — starting settings</h3>
|
||||
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
|
||||
<div><strong>Mode:</strong> Volume-control OR Pressure-control. PRVC / SIMV-PS hybrids.</div>
|
||||
<div><strong>Tidal volume:</strong> <strong>{validWt ? `${tvLow}-${tvHigh} mL` : '6-8 mL/kg'}</strong> (6-8 mL/kg). Use 4-6 mL/kg for ARDS.</div>
|
||||
<div><strong>Rate:</strong> {rate ? `${rate}/min (age ${ageY} yr)` : 'Newborn 30-40 · Infant 25-35 · Child 16-20 · Adolescent 12-16'}.</div>
|
||||
<div><strong>PEEP:</strong> start 5 cmH₂O. Increase to 8-12+ for refractory hypoxia.</div>
|
||||
<div><strong>FiO₂:</strong> start 100%, wean rapidly to lowest that maintains target SpO₂.</div>
|
||||
<div><strong>I:E ratio:</strong> 1:2 normally; 1:3-4 for obstructive disease.</div>
|
||||
<div><strong>Plateau pressure:</strong> keep <30 cmH₂O (ideally <28).</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-2">Adjusting for gas exchange</h3>
|
||||
<DrugTable notes>
|
||||
<Row name="Low SpO₂ (oxygenation)" dose="↑ FiO₂" route="—" notes="Then ↑ PEEP (recruits collapsed alveoli)" />
|
||||
<Row name="↑ PCO₂ (ventilation)" dose="↑ Rate" route="—" notes="Then ↑ Tidal volume" />
|
||||
<Row name="↓ PCO₂ (over-ventilating)" dose="↓ Rate" route="—" notes="Then ↓ Tidal volume" />
|
||||
<Row name="High peak pressure" dose="Check tube / compliance" route="—" notes="Suction, bronchodilator, lower TV" />
|
||||
<Row name="Auto-PEEP (asthma, bronch)" dose="↓ Rate, ↑ Te" route="—" notes="Disconnect + bag briefly if critical" />
|
||||
</DrugTable>
|
||||
<div className="rounded-md bg-green-50 dark:bg-green-950/30 p-3 text-xs text-green-900 dark:text-green-100">
|
||||
<strong>Mental model:</strong> Oxygenation is mostly <strong>FiO₂ + PEEP</strong>. Ventilation (CO₂) is mostly <strong>rate + tidal volume</strong>. Obstructive (asthma, bronchiolitis) → long expiratory time, permissive hypercapnia. Restrictive (ARDS) → low TV, high PEEP, permissive hypercapnia + hypoxia.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">AAP / PALS / AARC guidance.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sepsis ──────────────────────────────────────────────────
|
||||
export function SepsisPanel() {
|
||||
const [weight, setWeight] = useState('');
|
||||
const [age, setAge] = useState<'neonate' | 'infant' | 'child'>('child');
|
||||
const wt = Number.parseFloat(weight);
|
||||
const valid = Number.isFinite(wt) && wt > 0;
|
||||
const f = (perKg: number, max: number | null, unit = 'mg') => (valid ? formatDose(wt, perKg, max, unit) : null);
|
||||
const ageLbl = age === 'neonate' ? 'Neonate (0-28 d)' : age === 'infant' ? 'Young infant (29 d - 3 mo)' : 'Older child / adolescent';
|
||||
const bolus = valid ? Math.round(wt * 20) : null;
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="bedside-panel-sepsis">
|
||||
<h2 className="text-lg font-semibold">Sepsis & Fever</h2>
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 max-w-md">
|
||||
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="sepsis-weight" /></div>
|
||||
<div><label className={label}>Age band</label><select className={input} value={age} onChange={(e) => setAge(e.target.value as typeof age)} data-testid="sepsis-age"><option value="neonate">Neonate (0-28 d)</option><option value="infant">Infant (29 d - 3 mo)</option><option value="child">Older child / adolescent</option></select></div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-3 text-sm font-semibold text-destructive">
|
||||
Sepsis approach — {ageLbl}{valid ? `, ${wt} kg` : ''}
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-2">Definition — Phoenix Sepsis Criteria (JAMA 2024)</h3>
|
||||
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
|
||||
<div><strong>Sepsis</strong> = suspected or confirmed infection + Phoenix Score ≥2 (organ dysfunction across respiratory, cardiovascular, coagulation, neurological).</div>
|
||||
<div><strong>Septic shock</strong> = sepsis + cardiovascular dysfunction (vasoactive support, or ↑lactate ≥5, or ↓MAP for age).</div>
|
||||
<div className="text-muted-foreground italic">Previous SIRS-based criteria (Goldstein 2005) are now superseded.</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-2">Red flags</h3>
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-xs text-red-900 dark:text-red-100">
|
||||
Abnormal behavior / mentation · Fever + ill-appearance · Tachycardia out of proportion to fever · Prolonged cap refill (>3 s) · Cold/mottled extremities · Weak pulses or wide pulse pressure ("warm shock") · Hypotension is a <strong>LATE</strong> sign · Any immune compromise / indwelling line.
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-2">Empirical therapy — {ageLbl}</h3>
|
||||
<div className="rounded-md bg-muted/40 p-3 text-xs">
|
||||
{age === 'neonate' && <><strong>Workup (full sepsis eval):</strong> CBC+diff, CRP, blood culture, UA+urine culture (cath), <strong>LP</strong> (CSF+HSV PCR), CXR if respiratory sx, procalcitonin. <strong>Early-onset</strong> (<72 h): GBS, E. coli, Listeria. <strong>Late-onset</strong> (>72 h): CoNS, S. aureus, gram-negs, Candida.</>}
|
||||
{age === 'infant' && <><strong>Workup:</strong> Use validated rules — PECARN, Aronson, Rochester, Step-by-Step. CBC+ANC, procalcitonin/CRP, blood culture, UA+urine culture. Many warrant LP + admission + empiric abx. <strong>Coverage:</strong> GBS, E. coli, Listeria (up to ~6 wk), S. pneumo, N. meningitidis, H. flu, Salmonella.</>}
|
||||
{age === 'child' && <><strong>Recognition:</strong> Phoenix score or clinical concern + suspected infection. <strong>Workup:</strong> CBC, CRP, procalcitonin, blood cx (+site-specific), lactate, blood gas, glucose, electrolytes, coags, LP if CNS concern. Source-directed imaging.</>}
|
||||
</div>
|
||||
|
||||
{valid && (
|
||||
<DrugTable>
|
||||
{age === 'neonate' && <>
|
||||
<Row name="Ampicillin" dose={<Dose label={f(100, 2000)!.label} />} route="IV" notes="q8-12h. Covers GBS, Listeria, Enterococcus." />
|
||||
<Row name="Gentamicin" dose={<Dose label={f(4, 120)!.label} />} route="IV" notes="q24-48h. Monitor levels." />
|
||||
<Row name="Cefotaxime (add)" dose={<Dose label={f(50, 2000)!.label} />} route="IV" notes="If meningitis or gram-neg concern." />
|
||||
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="HSV risk: maternal lesions, vesicles, seizures, CSF pleocytosis." />
|
||||
</>}
|
||||
{age === 'infant' && <>
|
||||
<Row name="Ceftriaxone" dose={<Dose label={f(75, 2000)!.label} />} route="IV / IM" notes="q24h (100 mg/kg/day divided q12h for meningitis). <strong>Avoid <28 d</strong> if hyperbilirubinemia." />
|
||||
<Row name="Ampicillin" dose={<Dose label={f(100, 2000)!.label} />} route="IV" notes="If <6 wk: add for Listeria coverage." />
|
||||
<Row name="Vancomycin" dose={<Dose label={f(15, 1000)!.label} />} route="IV" notes="If severe / MRSA risk / meningitis." />
|
||||
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="<6 wk with suspicion of HSV." />
|
||||
</>}
|
||||
{age === 'child' && <>
|
||||
<Row name="Ceftriaxone" dose={<Dose label={f(50, 2000)!.label} />} route="IV" notes="q24h (100 mg/kg/day divided for meningitis)." />
|
||||
<Row name="Vancomycin" dose={<Dose label={f(15, 1000)!.label} />} route="IV" notes="q6h. If severe, indwelling line, or MRSA prevalence >10%." />
|
||||
<Row name="Piperacillin-tazobactam" dose={<Dose label={f(100, 4500)!.label} />} route="IV" notes="If intra-abdominal / neutropenic." />
|
||||
<Row name="Clindamycin" dose={<Dose label={f(10, 900)!.label} />} route="IV" notes="Adjunct for toxic shock syndrome (toxin suppression)." />
|
||||
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="If HSV CNS concern." />
|
||||
</>}
|
||||
</DrugTable>
|
||||
)}
|
||||
|
||||
<h3 className="text-sm font-semibold mt-2">First-hour bundle (SSC Peds 2020)</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">0-5 min — Recognize</div><div className="text-xs text-muted-foreground">Screen, sepsis huddle/activation, ABCs, O₂ to SpO₂ >94%, warm.</div></div>
|
||||
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">5-15 min — Access & labs</div><div className="text-xs text-muted-foreground">Two IVs or IO. Draw blood cx (ideally before abx), lactate, CBC, CMP, coags, blood gas, glucose. UA + culture. Source-specific cultures.</div></div>
|
||||
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">15-30 min — Fluids</div><div className="text-xs text-muted-foreground">{valid ? <>NS or LR <strong>{bolus} mL</strong> bolus (20 mL/kg) over 5-10 min.</> : <>NS/LR 10-20 mL/kg bolus over 5-10 min.</>} Reassess HR, perfusion, lungs, liver. Repeat up to 40-60 mL/kg; stop if crackles/hepatomegaly.</div></div>
|
||||
<div className="rounded-md border-l-4 border-green-500 bg-green-50 dark:bg-green-950/30 p-3"><div className="font-semibold">30-60 min — Antibiotics + reassess</div><div className="text-xs text-muted-foreground">Broad-spectrum empiric abx within 1 hour (≤1 h in septic shock). Recheck lactate, perfusion.</div></div>
|
||||
<div className="rounded-md border-l-4 border-destructive bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">>60 min — Fluid-refractory shock</div><div className="text-xs text-muted-foreground">Start vasoactive (<strong>epinephrine 0.05-0.3 mcg/kg/min</strong> cold / <strong>norepinephrine 0.05-0.3 mcg/kg/min</strong> warm). Central/IO access. Stress-dose hydrocortisone {valid ? <><strong>{f(2, 100)!.value} mg</strong> IV (2 mg/kg, max 100 mg)</> : '2 mg/kg IV (max 100 mg)'} if catecholamine-resistant. ICU.</div></div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-2">Resuscitation targets</h3>
|
||||
<div className="rounded-md bg-muted/40 p-3 text-xs">
|
||||
Normal mentation · Cap refill ≤2 s · Warm extremities · Strong peripheral pulses · UOP ≥1 mL/kg/hr · MAP ≥5th %ile for age (>65 mmHg adolescent) · SpO₂ ≥94% · Lactate trending down.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">Phoenix Sepsis Criteria (Schlapbach et al., JAMA 2024) · Surviving Sepsis Campaign Pediatric 2020 · AAP pediatric sepsis guidance.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Burns ───────────────────────────────────────────────────
|
||||
// Lund-Browder age-adjusted region percentages ported VERBATIM from
|
||||
// public/js/bedside/burns.js:10-30.
|
||||
const LUND_BROWDER: Array<{ key: string; label: string; vals: [number, number, number, number, number]; ageSensitive?: boolean }> = [
|
||||
{ key: 'head', label: 'Head', vals: [18, 13, 11, 9, 7], ageSensitive: true },
|
||||
{ key: 'neck', label: 'Neck', vals: [2, 2, 2, 2, 2] },
|
||||
{ key: 'ant_trunk', label: 'Anterior trunk', vals: [13, 13, 13, 13, 13] },
|
||||
{ key: 'post_trunk', label: 'Posterior trunk', vals: [13, 13, 13, 13, 13] },
|
||||
{ key: 'r_buttock', label: 'Right buttock', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||||
{ key: 'l_buttock', label: 'Left buttock', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||||
{ key: 'genital', label: 'Genitalia', vals: [1, 1, 1, 1, 1] },
|
||||
{ key: 'r_uparm', label: 'R upper arm', vals: [4, 4, 4, 4, 4] },
|
||||
{ key: 'l_uparm', label: 'L upper arm', vals: [4, 4, 4, 4, 4] },
|
||||
{ key: 'r_forearm', label: 'R forearm', vals: [3, 3, 3, 3, 3] },
|
||||
{ key: 'l_forearm', label: 'L forearm', vals: [3, 3, 3, 3, 3] },
|
||||
{ key: 'r_hand', label: 'R hand', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||||
{ key: 'l_hand', label: 'L hand', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
|
||||
{ key: 'r_thigh', label: 'R thigh', vals: [5.5, 8, 8.5, 9, 9.5], ageSensitive: true },
|
||||
{ key: 'l_thigh', label: 'L thigh', vals: [5.5, 8, 8.5, 9, 9.5], ageSensitive: true },
|
||||
{ key: 'r_leg', label: 'R lower leg', vals: [5, 5.5, 6, 6.5, 7], ageSensitive: true },
|
||||
{ key: 'l_leg', label: 'L lower leg', vals: [5, 5.5, 6, 6.5, 7], ageSensitive: true },
|
||||
{ key: 'r_foot', label: 'R foot', vals: [3.5, 3.5, 3.5, 3.5, 3.5] },
|
||||
{ key: 'l_foot', label: 'L foot', vals: [3.5, 3.5, 3.5, 3.5, 3.5] },
|
||||
];
|
||||
const AGE_BANDS: Array<{ id: 'infant' | 'young' | 'child' | 'adol' | 'adult'; label: string }> = [
|
||||
{ id: 'infant', label: 'Infant (<1 y)' },
|
||||
{ id: 'young', label: 'Young child (1-5 y)' },
|
||||
{ id: 'child', label: 'Child (5-10 y)' },
|
||||
{ id: 'adol', label: 'Adolescent (10-15 y)' },
|
||||
{ id: 'adult', label: 'Adult (>15 y)' },
|
||||
];
|
||||
|
||||
export function BurnsPanel() {
|
||||
const [weight, setWeight] = useState('');
|
||||
const [ageBand, setAgeBand] = useState<'infant' | 'young' | 'child' | 'adol' | 'adult'>('young');
|
||||
const [override, setOverride] = useState('');
|
||||
const [pct, setPct] = useState<Record<string, number>>({});
|
||||
|
||||
const ageIdx = AGE_BANDS.findIndex((a) => a.id === ageBand);
|
||||
const computedTbsa = LUND_BROWDER.reduce(
|
||||
(sum, r) => sum + r.vals[ageIdx] * Math.min(100, Math.max(0, pct[r.key] ?? 0)) / 100,
|
||||
0,
|
||||
);
|
||||
const tbsa = override.trim() ? Number(override) : Math.round(computedTbsa * 10) / 10;
|
||||
const wt = Number.parseFloat(weight);
|
||||
const validWt = Number.isFinite(wt) && wt > 0;
|
||||
const validTbsa = Number.isFinite(tbsa) && tbsa > 0;
|
||||
|
||||
const total = validWt && validTbsa ? Math.round(4 * wt * tbsa) : 0;
|
||||
const first8 = Math.round(total / 2);
|
||||
const rateFirst = Math.round(first8 / 8);
|
||||
const next16 = total - first8;
|
||||
const rateNext = Math.round(next16 / 16);
|
||||
const maint = validWt
|
||||
? Math.round(wt <= 10 ? wt * 4 : wt <= 20 ? 40 + (wt - 10) * 2 : 60 + (wt - 20))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="bedside-panel-burns">
|
||||
<h2 className="text-lg font-semibold">Burns — Lund-Browder + Parkland</h2>
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-3 max-w-xl">
|
||||
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="burn-weight" /></div>
|
||||
<div><label className={label}>Age band</label><select className={input} value={ageBand} onChange={(e) => setAgeBand(e.target.value as typeof ageBand)} data-testid="burn-age">{AGE_BANDS.map((a) => <option key={a.id} value={a.id}>{a.label}</option>)}</select></div>
|
||||
<div><label className={label}>TBSA override (%)</label><input type="number" min="0" max="100" step="1" className={input} value={override} onChange={(e) => setOverride(e.target.value)} placeholder={validTbsa ? String(tbsa) : 'auto'} data-testid="burn-tbsa" /></div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold">Body parts — % of each region burned (2° or deeper)</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{LUND_BROWDER.map((r) => {
|
||||
const max = r.vals[ageIdx];
|
||||
return (
|
||||
<div key={r.key} className="flex items-center gap-2 bg-muted/30 rounded p-2">
|
||||
<label className="flex-1 text-xs">{r.label} <span className="text-muted-foreground">({max}%{r.ageSensitive ? '*' : ''})</span></label>
|
||||
<input type="number" min="0" max="100" step="5" className="w-16 rounded border border-input bg-background px-2 py-1 text-xs text-right" value={pct[r.key] ?? 0} onChange={(e) => setPct({ ...pct, [r.key]: Number(e.target.value) })} data-testid={'burn-region-' + r.key} />
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{validTbsa && <div className="text-sm text-muted-foreground">Computed TBSA: <strong>{tbsa}%</strong></div>}
|
||||
{!validWt && <p className="text-xs text-destructive">Enter weight (kg) to see Parkland + maintenance fluids.</p>}
|
||||
{!validTbsa && validWt && <p className="text-xs text-destructive">Enter % per region or override TBSA to compute fluids.</p>}
|
||||
|
||||
{validWt && validTbsa && (
|
||||
<>
|
||||
<div className="rounded-md border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-3 text-sm font-semibold text-destructive" data-testid="burn-result">
|
||||
Burn fluid resuscitation — {wt} kg, {tbsa}% TBSA (2° or deeper)
|
||||
</div>
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-sm space-y-1">
|
||||
<div><strong>Parkland formula:</strong> 4 mL × kg × %TBSA = <strong>{total} mL LR over 24 hours</strong></div>
|
||||
<div><strong>First 8 h</strong> (from time of burn): {first8} mL (~<strong>{rateFirst} mL/hr</strong>)</div>
|
||||
<div><strong>Next 16 h:</strong> {next16} mL (~<strong>{rateNext} mL/hr</strong>)</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 p-3 text-sm">
|
||||
<strong>Plus maintenance (4-2-1):</strong> {maint} mL/hr (D5 ½NS ± 20 mEq KCl/L once UOP established). Consider dextrose in children <30 kg.
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 p-3 text-sm">
|
||||
<strong>Titrate to UOP:</strong> target 1-2 mL/kg/hr (infants / children), 0.5-1 mL/kg/hr (adolescents). <strong>Clinical response trumps formula.</strong>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className="text-sm font-semibold">Other pearls</h3>
|
||||
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
|
||||
<div><strong>Rule of palm:</strong> Patient's palm + fingers ≈ 1% TBSA — good for scattered burns.</div>
|
||||
<div><strong>First-degree burns DO NOT count</strong> toward TBSA or Parkland.</div>
|
||||
<div><strong>Analgesia:</strong> Morphine 0.05-0.1 mg/kg IV q2h, or fentanyl 1-2 mcg/kg IV q30-60 min.</div>
|
||||
<div><strong>Tetanus</strong> prophylaxis if indicated. Tdap/Td ± TIG.</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold">Burn center referral (ABA)</h3>
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||||
Partial-thickness >10% TBSA · any full-thickness · face/hands/feet/genital/perineum/major joints · electrical/chemical/inhalation · associated trauma · significant comorbidities · pediatric burns in non-pediatric center.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
ABA Advanced Burn Life Support 2018 · Parkland formula: Baxter 1968 · Lund-Browder 1944.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
366
client/src/pages/CalculatorPanels.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
// ============================================================
|
||||
// CALCULATOR PANELS — BMI / Vitals / Resus / Equipment.
|
||||
// Data ported VERBATIM from public/js/calculators.js:
|
||||
// • VITALS_DATA lines 1703-1831
|
||||
// • RESUS_MEDS lines 1873-2050
|
||||
// • EQUIP_DATA lines 2173-2228
|
||||
// BMI math + LMS table live in shared/clinical/bmi.ts, verified
|
||||
// byte-for-byte by calc-vectors.json (12 BMI cases).
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { computeBmi } from '@shared/clinical/bmi';
|
||||
import type { Sex } from '@shared/clinical/fenton';
|
||||
import { computeBp, type BpClassification } from '@shared/clinical/bp';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
const label = 'block text-xs font-medium text-muted-foreground';
|
||||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
|
||||
const btnGhost = 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted';
|
||||
const errorBox = 'rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200';
|
||||
|
||||
// ── BP Percentile (AAP 2017 Rosner splines) ────────────────
|
||||
const BP_CLASS_STYLE: Record<BpClassification, { label: string; color: string; bg: string }> = {
|
||||
normal: { label: 'Normal', color: '#10b981', bg: '#d1fae5' },
|
||||
elevated: { label: 'Elevated', color: '#f59e0b', bg: '#fef3c7' },
|
||||
stage1: { label: 'Stage 1 Hypertension', color: '#f97316', bg: '#ffedd5' },
|
||||
stage2: { label: 'Stage 2 Hypertension', color: '#ef4444', bg: '#fee2e2' },
|
||||
};
|
||||
|
||||
export function BpPanel() {
|
||||
const [ageYears, setAgeYears] = useState('');
|
||||
const [sex, setSex] = useState<'female' | 'male'>('female');
|
||||
const [heightCm, setHeightCm] = useState('');
|
||||
const [sbp, setSbp] = useState('');
|
||||
const [dbp, setDbp] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ReturnType<typeof computeBp> | null>(null);
|
||||
|
||||
function calc() {
|
||||
const a = Number.parseFloat(ageYears);
|
||||
const h = Number.parseFloat(heightCm);
|
||||
const s = Number.parseFloat(sbp);
|
||||
const d = Number.parseFloat(dbp);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(d)) {
|
||||
setError('Fill in all fields.'); setResult(null); return;
|
||||
}
|
||||
if (a < 1 || a > 17) { setError('Age must be 1-17 years.'); setResult(null); return; }
|
||||
if (h < 50 || h > 200) { setError('Height must be 50-200 cm.'); setResult(null); return; }
|
||||
setError('');
|
||||
setResult(computeBp(a, sex, h, s, d));
|
||||
}
|
||||
|
||||
const style = result ? BP_CLASS_STYLE[result.classification] : null;
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-bp">
|
||||
<h2 className="text-lg font-semibold">BP Percentile (AAP 2017)</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div><label className={label}>Age (years)</label><input type="number" min="1" max="17" step="0.1" className={input} value={ageYears} onChange={(e) => setAgeYears(e.target.value)} data-testid="bp-age" /></div>
|
||||
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as typeof sex)} data-testid="bp-sex"><option value="female">Female</option><option value="male">Male</option></select></div>
|
||||
<div><label className={label}>Height (cm)</label><input type="number" min="50" max="200" step="0.1" className={input} value={heightCm} onChange={(e) => setHeightCm(e.target.value)} data-testid="bp-height" /></div>
|
||||
<div><label className={label}>SBP (mmHg)</label><input type="number" min="50" max="220" step="1" className={input} value={sbp} onChange={(e) => setSbp(e.target.value)} data-testid="bp-sbp" /></div>
|
||||
<div><label className={label}>DBP (mmHg)</label><input type="number" min="30" max="150" step="1" className={input} value={dbp} onChange={(e) => setDbp(e.target.value)} data-testid="bp-dbp" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={calc} className={btnPrimary} data-testid="calc-bp-calculate">Calculate</button>
|
||||
<button type="button" onClick={() => { setAgeYears(''); setHeightCm(''); setSbp(''); setDbp(''); setResult(null); setError(''); }} className={btnGhost}>Clear</button>
|
||||
</div>
|
||||
{error && <div className={errorBox}>{error}</div>}
|
||||
{result && style && (
|
||||
<div
|
||||
className="rounded-lg p-4 space-y-2"
|
||||
style={{ background: style.bg, borderLeft: `4px solid ${style.color}` }}
|
||||
data-testid="calc-bp-result"
|
||||
>
|
||||
<div className="text-base font-bold" style={{ color: style.color }}>{style.label}</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Systolic</span><div className="font-semibold">{result.sysPercentile}th %ile</div><div className="text-xs text-muted-foreground">{BP_CLASS_STYLE[result.sysClass].label}</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Diastolic</span><div className="font-semibold">{result.diaPercentile}th %ile</div><div className="text-xs text-muted-foreground">{BP_CLASS_STYLE[result.diaClass].label}</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Height</span><div className="font-semibold">{result.heightPercentile.toFixed(0)}th %ile</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Overall</span><div className="font-semibold">{style.label}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
Flynn JT et al. Clinical Practice Guideline for Screening and Management of High Blood Pressure in Children and Adolescents. Pediatrics 2017;140(3):e20171904.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BMI ─────────────────────────────────────────────────────
|
||||
export function BmiPanel() {
|
||||
const [ageYr, setAgeYr] = useState('');
|
||||
const [ageMo, setAgeMo] = useState('');
|
||||
const [sex, setSex] = useState<Sex>('male');
|
||||
const [weight, setWeight] = useState('');
|
||||
const [height, setHeight] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ReturnType<typeof computeBmi> | null>(null);
|
||||
|
||||
function calc() {
|
||||
const yr = Number.parseFloat(ageYr) || 0;
|
||||
const mo = Number.parseInt(ageMo, 10) || 0;
|
||||
const age = yr + mo / 12;
|
||||
const w = Number.parseFloat(weight);
|
||||
const h = Number.parseFloat(height);
|
||||
if (!age || !Number.isFinite(w) || w <= 0 || !Number.isFinite(h) || h <= 0) {
|
||||
setError('Fill in all fields.'); setResult(null); return;
|
||||
}
|
||||
if (age < 2 || age > 20) { setError('Age must be 2-20 years.'); setResult(null); return; }
|
||||
setError('');
|
||||
setResult(computeBmi(w, h, Math.round(age * 12), sex));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-bmi">
|
||||
<h2 className="text-lg font-semibold">BMI Percentile (CDC 2000)</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:col-span-1">
|
||||
<div><label className={label}>Age (yr)</label><input type="number" min="2" max="20" step="0.1" className={input} value={ageYr} onChange={(e) => setAgeYr(e.target.value)} data-testid="bmi-age-yr" /></div>
|
||||
<div><label className={label}>Months</label><input type="number" min="0" max="11" className={input} value={ageMo} onChange={(e) => setAgeMo(e.target.value)} data-testid="bmi-age-mo" /></div>
|
||||
</div>
|
||||
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)} data-testid="bmi-sex"><option value="male">Male</option><option value="female">Female</option></select></div>
|
||||
<div><label className={label}>Weight (kg)</label><input type="number" min="1" max="200" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="bmi-weight" /></div>
|
||||
<div className="sm:col-span-1"><label className={label}>Height (cm)</label><input type="number" min="50" max="220" step="0.1" className={input} value={height} onChange={(e) => setHeight(e.target.value)} data-testid="bmi-height" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={calc} className={btnPrimary} data-testid="calc-bmi-calculate">Calculate</button>
|
||||
<button type="button" onClick={() => { setAgeYr(''); setAgeMo(''); setWeight(''); setHeight(''); setResult(null); setError(''); }} className={btnGhost}>Clear</button>
|
||||
</div>
|
||||
{error && <div className={errorBox}>{error}</div>}
|
||||
{result && (
|
||||
<div
|
||||
className="rounded-lg p-4 space-y-2"
|
||||
style={{ background: result.classification.bg, borderLeft: `4px solid ${result.classification.color}` }}
|
||||
data-testid="calc-bmi-result"
|
||||
>
|
||||
<div className="text-base font-bold" style={{ color: result.classification.color }}>{result.classification.label}</div>
|
||||
<div className="text-sm">BMI {result.bmi.toFixed(1)} kg/m² — {result.percentile}th percentile</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
<div><span className="text-xs uppercase text-muted-foreground">BMI</span><div className="font-semibold">{result.bmi.toFixed(1)}</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Percentile</span><div className="font-semibold">{result.percentile}th</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Z-Score</span><div className="font-semibold">{result.z.toFixed(2)}</div></div>
|
||||
{result.percentile >= 85 && <div><span className="text-xs uppercase text-muted-foreground">% of 95th</span><div className="font-semibold">{result.classification.pctOf95.toFixed(0)}%</div></div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground italic">CDC 2000 LMS tables · Kuczmarski et al. Vital Health Stat 11. 2002;(246).</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vitals ──────────────────────────────────────────────────
|
||||
// Data ported verbatim from calculators.js:1703-1831.
|
||||
interface VitalsEntry {
|
||||
label: string;
|
||||
hr: { awake: string; sleeping: string };
|
||||
rr: string;
|
||||
sbp: string;
|
||||
dbp: string;
|
||||
temp: string;
|
||||
weight: string;
|
||||
spo2: string;
|
||||
notes: string[];
|
||||
}
|
||||
const VITALS_DATA: Record<string, VitalsEntry> = {
|
||||
premie: { label: 'Premie', hr: { awake: '120-170', sleeping: '100-150' }, rr: '40-70', sbp: '55-75', dbp: '35-45', temp: '36.5-37.5', weight: '0.5-2.5 kg', spo2: '88-95% (target)',
|
||||
notes: ['HR and RR are highly variable and depend on gestational age', 'BP increases with gestational age and postnatal age', 'Target SpO2 88-95% to reduce retinopathy of prematurity risk', 'Temperature instability is common — use servo-controlled warmers', 'Bradycardia (<100 bpm) and apnea are common in premature infants'] },
|
||||
'0-3mo': { label: '0-3 Months', hr: { awake: '100-150', sleeping: '85-135' }, rr: '35-55', sbp: '65-85', dbp: '45-55', temp: '36.5-37.5', weight: '2.5-6 kg', spo2: '>95%',
|
||||
notes: ['HR normally increases with crying (up to 180-190 bpm) — this is physiologic', 'Periodic breathing (pauses <10 sec) is normal in neonates', 'Acrocyanosis (blue hands/feet) is normal; central cyanosis is not', 'BP is best measured in the right arm (pre-ductal) in neonates', 'Normal weight loss of 5-7% in first 3-5 days; regain by 10-14 days'] },
|
||||
'3-6mo': { label: '3-6 Months', hr: { awake: '90-120', sleeping: '75-110' }, rr: '30-45', sbp: '70-90', dbp: '50-65', temp: '36.5-37.5', weight: '5-8 kg', spo2: '>95%',
|
||||
notes: ['Expected weight gain: 20-30 g/day (150-200 g/week)', 'HR gradually decreases as vagal tone matures', 'RR >60 at rest may indicate lower respiratory tract disease', 'BP should be measured with appropriate cuff size (width 40% of arm circumference)'] },
|
||||
'6-12mo': { label: '6-12 Months', hr: { awake: '80-120', sleeping: '70-110' }, rr: '25-40', sbp: '80-100', dbp: '55-65', temp: '36.0-37.5', weight: '8-10 kg', spo2: '>95%',
|
||||
notes: ['Expected weight: triple birth weight by 12 months (~10 kg average)', 'Weight gain slows to ~10-15 g/day', 'Sinus arrhythmia (HR varies with breathing) is normal', 'Febrile tachycardia: HR increases ~10 bpm per 1 degree C above 37'] },
|
||||
'1-3yr': { label: '1-3 Years', hr: { awake: '70-110', sleeping: '60-100' }, rr: '20-30', sbp: '90-105', dbp: '55-70', temp: '36.0-37.5', weight: '10-15 kg', spo2: '>95%',
|
||||
notes: ['Expected weight gain: ~200-250 g/month (2-2.5 kg/year)', 'Tachycardia: HR >110 at rest warrants evaluation', 'Tachypnea: RR >30 at rest may indicate respiratory distress', 'BP screening begins at age 3 per AAP 2017 guidelines', 'Estimated weight: 2 x (age in years) + 8'] },
|
||||
'3-6yr': { label: '3-6 Years', hr: { awake: '65-110', sleeping: '55-100' }, rr: '20-25', sbp: '95-110', dbp: '60-75', temp: '36.0-37.5', weight: '14-20 kg', spo2: '>95%',
|
||||
notes: ['Annual BP screening recommended from age 3', 'Normal BP <90th percentile for age, sex, and height', 'Elevated BP: 90th to <95th percentile (or 120/80 if lower)', 'Estimated weight: 2 x (age in years) + 8', 'ETT size (uncuffed): (age/4) + 4'] },
|
||||
'6-12yr': { label: '6-12 Years', hr: { awake: '60-95', sleeping: '50-85' }, rr: '14-22', sbp: '100-120', dbp: '60-75', temp: '36.0-37.5', weight: '20-40 kg', spo2: '>95%',
|
||||
notes: ['Resting HR >95 or <60 warrants evaluation', 'BP should be measured at every clinical encounter', 'Stage 1 HTN: >=95th percentile on 3 separate occasions', 'Estimated weight: 3 x (age in years) + 7', 'ETT size (cuffed): (age/4) + 3.5'] },
|
||||
'>12yr': { label: '>12 Years', hr: { awake: '55-85', sleeping: '45-75' }, rr: '12-18', sbp: '110-135', dbp: '65-85', temp: '36.0-37.5', weight: '40-80 kg', spo2: '>95%',
|
||||
notes: ['Vital signs approach adult values', 'From age 13: use adult BP thresholds (AAP 2017)', 'Normal: <120/<80 mmHg; Elevated: 120-129/<80 mmHg', 'Stage 1 HTN: 130-139/80-89 mmHg; Stage 2 HTN: >=140/>=90 mmHg', 'Orthostatic vitals: measure lying, sitting, standing if dizzy', 'Athletic bradycardia (HR 45-60) may be normal in trained adolescents'] },
|
||||
};
|
||||
const VITALS_ORDER = ['premie', '0-3mo', '3-6mo', '6-12mo', '1-3yr', '3-6yr', '6-12yr', '>12yr'];
|
||||
|
||||
export function VitalsPanel() {
|
||||
const [key, setKey] = useState<string>('1-3yr');
|
||||
const v = VITALS_DATA[key];
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-vitals">
|
||||
<h2 className="text-lg font-semibold">Vital Signs by Age</h2>
|
||||
<div className="max-w-xs">
|
||||
<label className={label}>Age group</label>
|
||||
<select className={input} value={key} onChange={(e) => setKey(e.target.value)} data-testid="vitals-age-select">
|
||||
{VITALS_ORDER.map((k) => <option key={k} value={k}>{VITALS_DATA[k].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm" data-testid="vitals-result">
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Heart rate (awake)</div><div className="font-semibold">{v.hr.awake} bpm</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Heart rate (sleep)</div><div className="font-semibold">{v.hr.sleeping} bpm</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Respiratory rate</div><div className="font-semibold">{v.rr} /min</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">SpO₂</div><div className="font-semibold">{v.spo2}</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">SBP</div><div className="font-semibold">{v.sbp} mmHg</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">DBP</div><div className="font-semibold">{v.dbp} mmHg</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Temperature</div><div className="font-semibold">{v.temp} °C</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Weight</div><div className="font-semibold">{v.weight}</div></div>
|
||||
</div>
|
||||
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-3 text-xs">
|
||||
<div className="font-semibold mb-1">Clinical notes</div>
|
||||
<ul className="list-disc pl-5 space-y-0.5">{v.notes.map((n, i) => <li key={i}>{n}</li>)}</ul>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">Harriet Lane Handbook 23rd ed · PALS · AAP 2017 BP guidelines.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Resus Meds ──────────────────────────────────────────────
|
||||
// Data + math ported verbatim from calculators.js:1873-2050.
|
||||
interface ResusResult { dose: string; extra: string; max: string }
|
||||
interface ResusMed { name: string; indication: string; category: 'cardiac' | 'metabolic' | 'reversal'; route: string; calc: (w: number) => ResusResult }
|
||||
const RESUS_MEDS: ResusMed[] = [
|
||||
{ name: 'Adenosine', indication: 'SVT', category: 'cardiac', route: 'IV/IO rapid bolus',
|
||||
calc: (w) => { const d1 = +(w * 0.1).toFixed(2); const d2 = +(w * 0.2).toFixed(2); const d3 = +(w * 0.3).toFixed(2); return { dose: `${d1} mg (0.1 mg/kg)`, extra: `May repeat: ${Math.min(d2, 12)} mg (0.2 mg/kg), then ${Math.min(d3, 12)} mg (0.3 mg/kg)`, max: 'Max first dose 6 mg, max subsequent 12 mg' }; } },
|
||||
{ name: 'Amiodarone', indication: 'VT / VF', category: 'cardiac', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 5).toFixed(1); return { dose: `${Math.min(d, 300)} mg (5 mg/kg)`, extra: 'No pulse: push undiluted. Pulse: over 20-60 min. Subsequent max 150 mg.', max: 'Max first 300 mg, max total 15 mg/kg/24hr or 2200 mg' }; } },
|
||||
{ name: 'Atropine', indication: 'Bradycardia', category: 'cardiac', route: 'IV/IO/IM',
|
||||
calc: (w) => { const d = +(w * 0.02).toFixed(3); const ett = `${(w * 0.04).toFixed(3)}-${(w * 0.06).toFixed(3)}`; return { dose: `${Math.min(d, 0.5)} mg (0.02 mg/kg)`, extra: `ETT dose: ${ett} mg (0.04-0.06 mg/kg)`, max: 'Max single 0.5 mg, max total 1 mg' }; } },
|
||||
{ name: 'Calcium Chloride 10%', indication: 'Hypocalcemia / Hyperkalemia', category: 'metabolic', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 20).toFixed(0); return { dose: `${Math.min(d, 1000)} mg (20 mg/kg)`, extra: 'Give slowly. Central line preferred.', max: 'Max 1 g (1000 mg)' }; } },
|
||||
{ name: 'Calcium Gluconate 10%', indication: 'Hypocalcemia / Hyperkalemia', category: 'metabolic', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 60).toFixed(0); return { dose: `${Math.min(d, 3000)} mg (60 mg/kg)`, extra: 'Give slowly over 10-20 min with cardiac monitoring.', max: 'Max 3 g (3000 mg)' }; } },
|
||||
{ name: 'Dextrose', indication: 'Hypoglycemia', category: 'metabolic', route: 'IV',
|
||||
calc: (w) => {
|
||||
const grams = `${+(w * 0.5).toFixed(1)}-${+(w * 1).toFixed(1)}`;
|
||||
let detail = '';
|
||||
if (w < 5) detail = `D10W: ${(w * 5).toFixed(1)}-${(w * 10).toFixed(1)} mL (5-10 mL/kg)`;
|
||||
else if (w < 45) detail = `D25W: ${(w * 2).toFixed(1)}-${(w * 4).toFixed(1)} mL (2-4 mL/kg)`;
|
||||
else detail = `D50W: ${(w * 1).toFixed(1)}-${(w * 2).toFixed(1)} mL (1-2 mL/kg)`;
|
||||
return { dose: `${grams} g (0.5-1 g/kg)`, extra: detail, max: 'Max 25 g' };
|
||||
} },
|
||||
{ name: 'Epinephrine', indication: 'Pulseless arrest / Anaphylaxis', category: 'cardiac', route: 'IV/IO/IM/ETT',
|
||||
calc: (w) => { const iv = +(w * 0.01).toFixed(3); const ivVol = +(w * 0.1).toFixed(2); const ett = +(w * 0.1).toFixed(2); const im = +(w * 0.01).toFixed(3);
|
||||
return { dose: `${Math.min(iv, 1)} mg IV/IO (0.01 mg/kg of 0.1 mg/mL = ${Math.min(ivVol, 10)} mL) q3-5 min`, extra: `ETT: ${Math.min(ett, 2.5)} mg (0.1 mg/kg of 1 mg/mL). Anaphylaxis IM: ${Math.min(im, 0.5)} mg (0.01 mg/kg)`, max: 'Max IV 1 mg, max ETT 2.5 mg, max IM 0.5 mg' }; } },
|
||||
{ name: 'Hydrocortisone', indication: 'Adrenal crisis', category: 'metabolic', route: 'IV/IM/IO',
|
||||
calc: (w) => { const d = +(w * 2).toFixed(1); return { dose: `${Math.min(d, 100)} mg (2 mg/kg)`, extra: 'Stress dosing for adrenal insufficiency.', max: 'Max 100 mg' }; } },
|
||||
{ name: 'Insulin (Regular)', indication: 'Hyperkalemia', category: 'metabolic', route: 'IV',
|
||||
calc: (w) => { const d = +(w * 0.1).toFixed(2); const dex = +(w * 0.5).toFixed(1); return { dose: `${Math.min(d, 5)} units (0.1 units/kg)`, extra: `Give with ${dex} g/kg dextrose (0.5 g/kg). Monitor glucose closely.`, max: 'Max 5 units' }; } },
|
||||
{ name: 'Lidocaine', indication: 'Antiarrhythmic', category: 'cardiac', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 1).toFixed(1); const ett = `${(w * 2).toFixed(1)}-${(w * 3).toFixed(1)}`; return { dose: `${Math.min(d, 100)} mg (1 mg/kg)`, extra: `ETT: ${ett} mg (2-3 mg/kg). May repeat q5 min.`, max: 'Max 100 mg/dose, max total 3 mg/kg' }; } },
|
||||
{ name: 'Magnesium Sulfate', indication: 'Torsades de Pointes', category: 'cardiac', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 50).toFixed(0); return { dose: `${Math.min(d, 2000)} mg (50 mg/kg)`, extra: 'Give over 10-20 min (faster if pulseless).', max: 'Max 2 g (2000 mg)' }; } },
|
||||
{ name: 'Naloxone', indication: 'Opioid overdose', category: 'reversal', route: 'IV/IO/IM/IN/ETT',
|
||||
calc: (w) => { const partial = `${+(w * 0.001).toFixed(4)}-${+(w * 0.005).toFixed(4)}`; const full = +(w * 0.1).toFixed(3); return { dose: `Partial: ${partial} mg (0.001-0.005 mg/kg)`, extra: `Full reversal: ${Math.min(full, 2)} mg (0.1 mg/kg)`, max: 'Max partial first dose 0.1 mg, max full 2 mg' }; } },
|
||||
{ name: 'Sodium Bicarbonate', indication: 'Metabolic acidosis', category: 'metabolic', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 1).toFixed(1); return { dose: `${Math.min(d, 50)} mEq (1 mEq/kg)`, extra: w < 10 ? 'Dilute to 0.5 mEq/mL (use 4.2% solution) for neonates/small infants.' : 'Use 8.4% solution (1 mEq/mL).', max: 'Max 50 mEq' }; } },
|
||||
];
|
||||
const catColor: Record<ResusMed['category'], string> = { cardiac: '#ef4444', metabolic: '#3b82f6', reversal: '#10b981' };
|
||||
const catLabel: Record<ResusMed['category'], string> = { cardiac: 'Cardiac', metabolic: 'Metabolic', reversal: 'Reversal' };
|
||||
|
||||
export function ResusPanel() {
|
||||
const [weight, setWeight] = useState('');
|
||||
const wt = Number.parseFloat(weight);
|
||||
const valid = Number.isFinite(wt) && wt > 0;
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-resus">
|
||||
<h2 className="text-lg font-semibold">Resus Medications</h2>
|
||||
<div className="max-w-xs">
|
||||
<label className={label}>Weight (kg)</label>
|
||||
<input type="number" min="0.5" max="100" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="resus-weight" />
|
||||
</div>
|
||||
{!valid ? <p className="text-xs text-destructive">Enter weight (kg) to see doses.</p> : (
|
||||
<>
|
||||
<div className="text-sm font-semibold">Doses for {wt} kg patient</div>
|
||||
<div className="flex gap-3 flex-wrap text-xs">
|
||||
{(['cardiac', 'metabolic', 'reversal'] as const).map((c) => (
|
||||
<span key={c} className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full" style={{ background: catColor[c] }} />{catLabel[c]}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3" data-testid="resus-result">
|
||||
{RESUS_MEDS.map((med) => {
|
||||
const r = med.calc(wt);
|
||||
const color = catColor[med.category];
|
||||
return (
|
||||
<div key={med.name} className="rounded-lg border bg-card overflow-hidden" style={{ borderColor: color + '55' }}>
|
||||
<div className="px-3 py-2 border-b" style={{ background: color + '10', borderColor: color + '22' }}>
|
||||
<div className="text-sm font-bold" style={{ color }}>{med.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{med.indication}</div>
|
||||
</div>
|
||||
<div className="p-3 text-sm space-y-1">
|
||||
<div><strong>Dose:</strong> {r.dose}</div>
|
||||
<div className="text-xs text-muted-foreground">{r.extra}</div>
|
||||
<div className="text-xs text-muted-foreground"><strong>Max:</strong> {r.max}</div>
|
||||
<div className="text-xs text-muted-foreground"><strong>Route:</strong> {med.route}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||||
<strong>Disclaimer:</strong> Always verify doses against institutional protocols and current guidelines.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Equipment ───────────────────────────────────────────────
|
||||
// Data ported verbatim from calculators.js:2173-2228.
|
||||
interface EquipEntry {
|
||||
label: string;
|
||||
bvm: string; nasal: string; oral: string; blade: string;
|
||||
ett: string; lma: string; glidescope: string;
|
||||
iv: string; cvl: string; ngt: string; chest: string; foley: string;
|
||||
}
|
||||
const EQUIP_DATA: Record<string, EquipEntry> = {
|
||||
premie: { label: 'Premie (1-3 kg)', bvm: 'Infant', nasal: '12 Fr', oral: 'Infant', blade: 'Miller 0', ett: '2.5-3.0', lma: '1', glidescope: '1', iv: '22-24 ga', cvl: '3 Fr', ngt: '5 Fr', chest: '10-12 Fr', foley: '6 Fr' },
|
||||
newborn: { label: 'Newborn (2-4 kg)', bvm: 'Infant', nasal: '14-16 Fr', oral: 'Small 50 mm', blade: 'Miller 0', ett: '3.0-3.5', lma: '1', glidescope: '1', iv: '22-24 ga', cvl: '3-4 Fr', ngt: '5-8 Fr', chest: '10-12 Fr', foley: '6 Fr' },
|
||||
'6mo': { label: '6 months (6-8 kg)', bvm: 'Infant', nasal: '14-16 Fr', oral: 'Small 60 mm', blade: 'Miller 1', ett: '3.5', lma: '1.5', glidescope: '2', iv: '20-24 ga', cvl: '4 Fr', ngt: '8 Fr', chest: '12-18 Fr', foley: '8 Fr' },
|
||||
'1yr': { label: '1 year (10 kg)', bvm: 'Small child', nasal: '14-18 Fr', oral: 'Small 60 mm', blade: 'Miller 1 / MAC 2', ett: '4.0', lma: '2', glidescope: '2', iv: '20-24 ga', cvl: '4-5 Fr', ngt: '10 Fr', chest: '16-20 Fr', foley: '8 Fr' },
|
||||
'2-3yr': { label: '2-3 years (12-16 kg)', bvm: 'Small child', nasal: '14-18 Fr', oral: 'Small 70 mm', blade: 'Miller 1 / MAC 2', ett: '4.0-4.5', lma: '2', glidescope: '2', iv: '18-22 ga', cvl: '4-5 Fr', ngt: '10-12 Fr', chest: '16-24 Fr', foley: '8 Fr' },
|
||||
'4-6yr': { label: '4-6 years (20-25 kg)', bvm: 'Child', nasal: '16-20 Fr', oral: 'Small 70-80 mm', blade: 'Miller 2 / MAC 2', ett: '4.5-5.0', lma: '2.5', glidescope: '3', iv: '18-22 ga', cvl: '5 Fr', ngt: '12-14 Fr', chest: '20-28 Fr', foley: '8 Fr' },
|
||||
'7-10yr': { label: '7-10 years (25-35 kg)', bvm: 'Child / Small adult', nasal: '18-22 Fr', oral: 'Medium 80-90 mm', blade: 'Miller 2 / MAC 2', ett: '5.5-6.0', lma: '2.5-3', glidescope: '3', iv: '18-22 ga', cvl: '5 Fr', ngt: '12-14 Fr', chest: '20-32 Fr', foley: '8 Fr' },
|
||||
'11-15yr': { label: '11-15 years (40-50 kg)', bvm: 'Adult', nasal: '22-36 Fr', oral: 'Medium 90 mm', blade: 'Miller 2 / MAC 3', ett: '6.0-6.5', lma: '3', glidescope: '3 or 4', iv: '18-20 ga', cvl: '7 Fr', ngt: '14-18 Fr', chest: '28-38 Fr', foley: '10 Fr' },
|
||||
'16yr': { label: '16+ years (>50 kg)', bvm: 'Adult', nasal: '22-36 Fr', oral: 'Medium 90 mm', blade: 'Miller 2 / MAC 3', ett: '7.0-8.0', lma: '4', glidescope: '3 or 4', iv: '18-20 ga', cvl: '7 Fr', ngt: '14-18 Fr', chest: '28-42 Fr', foley: '12 Fr' },
|
||||
};
|
||||
const EQUIP_ORDER = ['premie', 'newborn', '6mo', '1yr', '2-3yr', '4-6yr', '7-10yr', '11-15yr', '16yr'];
|
||||
|
||||
export function EquipmentPanel() {
|
||||
const [key, setKey] = useState('1yr');
|
||||
const e = EQUIP_DATA[key];
|
||||
const rows: Array<[string, string]> = [
|
||||
['BVM', e.bvm],
|
||||
['Nasopharyngeal', e.nasal],
|
||||
['Oropharyngeal', e.oral],
|
||||
['Laryngoscope', e.blade],
|
||||
['ETT', e.ett],
|
||||
['LMA', e.lma],
|
||||
['Glidescope', e.glidescope],
|
||||
['IV', e.iv],
|
||||
['Central line', e.cvl],
|
||||
['NG tube', e.ngt],
|
||||
['Chest tube', e.chest],
|
||||
['Foley', e.foley],
|
||||
];
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-equipment">
|
||||
<h2 className="text-lg font-semibold">Equipment Sizing</h2>
|
||||
<div className="max-w-xs">
|
||||
<label className={label}>Age / weight band</label>
|
||||
<select className={input} value={key} onChange={(e2) => setKey(e2.target.value)} data-testid="equip-age-select">
|
||||
{EQUIP_ORDER.map((k) => <option key={k} value={k}>{EQUIP_DATA[k].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm" data-testid="equip-result">
|
||||
{rows.map(([lbl, val]) => (
|
||||
<div key={lbl} className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">{lbl}</div><div className="font-semibold">{val}</div></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">Harriet Lane Handbook · PALS · Broselow cross-reference.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
616
client/src/pages/Calculators.tsx
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
// ============================================================
|
||||
// CALCULATORS — incremental React port.
|
||||
// Low-risk pure formulas run here; high-risk table-driven calculators
|
||||
// stay in the vanilla viewer until legacy vectors land.
|
||||
//
|
||||
// WHY this is gated on test vectors (from the migration checkpoint):
|
||||
// • AAP 2017 BP percentile uses Rosner quantile splines with long
|
||||
// hard-coded coefficient arrays.
|
||||
// • Fenton 2013 LMS preterm growth carries 210 validated cases.
|
||||
// • AAP 2022 bilirubin phototherapy + exchange: per-week risk
|
||||
// curves, 1190 validated cases.
|
||||
// • Bhutani nomogram risk zones.
|
||||
// • APLS + Best Guess weight-for-age.
|
||||
//
|
||||
// Per the checkpoint: "An LLM will sometimes 'simplify' a long array
|
||||
// of numbers and silently break it — don't let that happen." Every
|
||||
// calculator needs a JSON vector file (~20 known inputs + expected
|
||||
// outputs captured from public/js/calculators.js) before its React
|
||||
// port lands, and the port must match every vector byte-for-byte.
|
||||
//
|
||||
// Pill order + labels match public/components/calculators.html.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
calculateGcs,
|
||||
calculateMostellerBsa,
|
||||
calculateWeightBasedDose,
|
||||
} from '@shared/clinical/calculators';
|
||||
import { classifyBhutani, classifyAapBili, type BiliRisk } from '@shared/clinical/bilirubin';
|
||||
import { fentonWeightForAge, classifySizeForAge, type Sex } from '@shared/clinical/fenton';
|
||||
import { BmiPanel, VitalsPanel, ResusPanel, EquipmentPanel, BpPanel } from './CalculatorPanels';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
|
||||
const btnGhost = 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted';
|
||||
const field = 'space-y-1';
|
||||
const label = 'block text-xs font-medium text-muted-foreground';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
const resultBox = 'rounded-lg border border-border bg-muted/40 p-4';
|
||||
const errorBox = 'rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200';
|
||||
|
||||
interface Pill {
|
||||
id: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
source: string; // where the formulas live
|
||||
ported?: boolean;
|
||||
}
|
||||
|
||||
const PILLS: Pill[] = [
|
||||
{ id: 'bp', label: 'BP Percentile', summary: 'AAP 2017 age/height/sex-adjusted BP percentiles (Rosner quantile splines).', source: 'AAP 2017 (Flynn) — Rosner splines', ported: true },
|
||||
{ id: 'bmi', label: 'BMI Percentile', summary: 'BMI-for-age (CDC 2000 z-score tables).', source: 'CDC 2000 LMS', ported: true },
|
||||
{ id: 'growth', label: 'Growth Charts', summary: 'Fenton 2013 preterm weight-for-GA with Z-score + percentile + SGA/AGA/LGA classification.', source: 'Fenton 2013 LMS', ported: true },
|
||||
{ id: 'bili', label: 'Bilirubin', summary: 'AAP 2022 phototherapy + exchange thresholds and Bhutani nomogram risk zones.', source: 'AAP 2022 (Kemper) + Bhutani 1999', ported: true },
|
||||
{ id: 'vitals', label: 'Vital Signs', summary: 'Normal HR / RR / BP ranges by age.', source: 'Harriet Lane + PALS + AHA', ported: true },
|
||||
{ id: 'bsa', label: 'Body Surface Area', summary: 'Mosteller body surface area formula.', source: 'Mosteller 1987', ported: true },
|
||||
{ id: 'dose', label: 'Weight-Based Dosing', summary: 'Generic mg/kg dosing with optional max-dose cap and concentration conversion.', source: 'Legacy calculator formula', ported: true },
|
||||
{ id: 'resus', label: 'Resus Meds', summary: 'Code-cart dosing (epinephrine, amiodarone, atropine, etc.).', source: 'PALS', ported: true },
|
||||
{ id: 'gcs', label: 'GCS', summary: 'Child/adult and infant Glasgow Coma Scale variants.', source: 'Teasdale + pediatric modification', ported: true },
|
||||
{ id: 'equipment', label: 'Equipment', summary: 'ETT size, blade, NG, Foley, suction by age/weight.', source: 'PALS + Broselow cross-reference', ported: true },
|
||||
];
|
||||
|
||||
function parseOptionalNumber(value: string): number | null {
|
||||
if (!value.trim()) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function FormField({
|
||||
id,
|
||||
labelText,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = '0.1',
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
labelText: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
min?: string;
|
||||
max?: string;
|
||||
step?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={field}>
|
||||
<label htmlFor={id} className={label}>{labelText}</label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BsaPanel() {
|
||||
const [weight, setWeight] = useState('');
|
||||
const [height, setHeight] = useState('');
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function calculate() {
|
||||
const next = calculateMostellerBsa(Number(weight), Number(height));
|
||||
if (next == null) {
|
||||
setError('Enter a valid weight and height.');
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setResult(next);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setWeight('');
|
||||
setHeight('');
|
||||
setResult(null);
|
||||
setError('');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-bsa">
|
||||
<h2 className="text-lg font-semibold">Body Surface Area</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mosteller formula: BSA (m2) = sqrt(height(cm) x weight(kg) / 3600).
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField id="react-bsa-weight" labelText="Weight (kg)" value={weight} onChange={setWeight} min="1" max="200" placeholder="20" />
|
||||
<FormField id="react-bsa-height" labelText="Height (cm)" value={height} onChange={setHeight} min="30" max="220" placeholder="110" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={calculate} className={btnPrimary} data-testid="calc-bsa-calculate">Calculate</button>
|
||||
<button type="button" onClick={clear} className={btnGhost}>Clear</button>
|
||||
</div>
|
||||
{error ? <div className={errorBox}>{error}</div> : null}
|
||||
{result == null ? null : (
|
||||
<div className={resultBox} data-testid="calc-bsa-result">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">Mosteller BSA</div>
|
||||
<div className="text-2xl font-semibold">{result.toFixed(3)} m²</div>
|
||||
<div className="text-sm text-muted-foreground">{weight} kg, {height} cm</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DosePanel() {
|
||||
const [weight, setWeight] = useState('');
|
||||
const [dosePerKg, setDosePerKg] = useState('');
|
||||
const [frequency, setFrequency] = useState('1');
|
||||
const [maxDose, setMaxDose] = useState('');
|
||||
const [concentration, setConcentration] = useState('');
|
||||
const [result, setResult] = useState<ReturnType<typeof calculateWeightBasedDose>>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function calculate() {
|
||||
const next = calculateWeightBasedDose({
|
||||
weightKg: Number(weight),
|
||||
dosePerKg: Number(dosePerKg),
|
||||
frequencyPerDay: Number(frequency),
|
||||
maxSingleDoseMg: parseOptionalNumber(maxDose),
|
||||
concentrationMgPerMl: parseOptionalNumber(concentration),
|
||||
});
|
||||
if (next == null) {
|
||||
setError('Enter a valid weight, mg/kg dose, and frequency.');
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setResult(next);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setWeight('');
|
||||
setDosePerKg('');
|
||||
setFrequency('1');
|
||||
setMaxDose('');
|
||||
setConcentration('');
|
||||
setResult(null);
|
||||
setError('');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-dose">
|
||||
<h2 className="text-lg font-semibold">Weight-Based Dosing</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generic mg/kg calculator. Always verify medication-specific dosing against formulary and local policy.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<FormField id="react-dose-weight" labelText="Patient Weight (kg)" value={weight} onChange={setWeight} min="1" max="200" placeholder="15" />
|
||||
<FormField id="react-dose-per-kg" labelText="Dose (mg/kg)" value={dosePerKg} onChange={setDosePerKg} min="0.01" step="0.01" placeholder="10" />
|
||||
<div className={field}>
|
||||
<label htmlFor="react-dose-frequency" className={label}>Frequency</label>
|
||||
<select id="react-dose-frequency" value={frequency} onChange={(event) => setFrequency(event.target.value)} className={input}>
|
||||
<option value="1">Once daily</option>
|
||||
<option value="2">Twice daily (BID)</option>
|
||||
<option value="3">Three times daily (TID)</option>
|
||||
<option value="4">Four times daily (QID)</option>
|
||||
<option value="6">Every 4 hours (Q4H)</option>
|
||||
</select>
|
||||
</div>
|
||||
<FormField id="react-dose-max" labelText="Max single dose (mg, optional)" value={maxDose} onChange={setMaxDose} min="0" step="1" placeholder="500" />
|
||||
<FormField id="react-dose-concentration" labelText="Concentration (mg/mL, optional)" value={concentration} onChange={setConcentration} min="0" placeholder="40" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={calculate} className={btnPrimary} data-testid="calc-dose-calculate">Calculate</button>
|
||||
<button type="button" onClick={clear} className={btnGhost}>Clear</button>
|
||||
</div>
|
||||
{error ? <div className={errorBox}>{error}</div> : null}
|
||||
{result == null ? null : (
|
||||
<div className={resultBox} data-testid="calc-dose-result">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">Single Dose</div>
|
||||
<div className="text-xl font-semibold">{result.singleDoseMg.toFixed(1)} mg</div>
|
||||
{result.capped ? <div className="text-xs text-red-600">Capped at max dose</div> : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">Daily Total</div>
|
||||
<div className="text-xl font-semibold">{result.dailyDoseMg.toFixed(1)} mg/day</div>
|
||||
<div className="text-xs text-muted-foreground">x {result.frequencyPerDay}/day</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">Volume</div>
|
||||
<div className="text-xl font-semibold">{result.volumeMl == null ? 'n/a' : `${result.volumeMl.toFixed(1)} mL`}</div>
|
||||
<div className="text-xs text-muted-foreground">per dose</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const GCS_OPTIONS = {
|
||||
child: {
|
||||
eye: [
|
||||
['4', '4 - Spontaneous'],
|
||||
['3', '3 - To speech'],
|
||||
['2', '2 - To pain'],
|
||||
['1', '1 - None'],
|
||||
],
|
||||
verbal: [
|
||||
['5', '5 - Oriented'],
|
||||
['4', '4 - Confused'],
|
||||
['3', '3 - Inappropriate words'],
|
||||
['2', '2 - Incomprehensible sounds'],
|
||||
['1', '1 - None'],
|
||||
],
|
||||
motor: [
|
||||
['6', '6 - Obeys commands'],
|
||||
['5', '5 - Localizes pain'],
|
||||
['4', '4 - Withdraws to pain'],
|
||||
['3', '3 - Abnormal flexion'],
|
||||
['2', '2 - Abnormal extension'],
|
||||
['1', '1 - None'],
|
||||
],
|
||||
},
|
||||
infant: {
|
||||
eye: [
|
||||
['4', '4 - Spontaneous'],
|
||||
['3', '3 - To speech/sound'],
|
||||
['2', '2 - To painful stimuli'],
|
||||
['1', '1 - None'],
|
||||
],
|
||||
verbal: [
|
||||
['5', '5 - Coos/babbles'],
|
||||
['4', '4 - Irritable cry'],
|
||||
['3', '3 - Cries to pain'],
|
||||
['2', '2 - Moans to pain'],
|
||||
['1', '1 - None'],
|
||||
],
|
||||
motor: [
|
||||
['6', '6 - Normal spontaneous movement'],
|
||||
['5', '5 - Withdraws to touch'],
|
||||
['4', '4 - Withdraws to pain'],
|
||||
['3', '3 - Abnormal flexion'],
|
||||
['2', '2 - Abnormal extension'],
|
||||
['1', '1 - None'],
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
|
||||
function GcsSelect({
|
||||
id,
|
||||
labelText,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
labelText: string;
|
||||
value: string;
|
||||
options: readonly (readonly [string, string])[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={field}>
|
||||
<label htmlFor={id} className={label}>{labelText}</label>
|
||||
<select id={id} value={value} onChange={(event) => onChange(event.target.value)} className={input}>
|
||||
{options.map(([optionValue, text]) => (
|
||||
<option key={optionValue} value={optionValue}>{text}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GcsPanel() {
|
||||
const [scale, setScale] = useState<'child' | 'infant'>('child');
|
||||
const [eye, setEye] = useState('4');
|
||||
const [verbal, setVerbal] = useState('5');
|
||||
const [motor, setMotor] = useState('6');
|
||||
const result = calculateGcs(Number(eye), Number(verbal), Number(motor));
|
||||
const options = GCS_OPTIONS[scale];
|
||||
|
||||
function switchScale(next: 'child' | 'infant') {
|
||||
setScale(next);
|
||||
setEye('4');
|
||||
setVerbal('5');
|
||||
setMotor('6');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-gcs">
|
||||
<h2 className="text-lg font-semibold">Glasgow Coma Scale</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select responses to calculate child/adult or infant-modified GCS. Total score 3-15.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchScale('child')}
|
||||
className={'px-3 py-1.5 rounded-full text-xs font-medium border ' + (scale === 'child' ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted border-border')}
|
||||
>
|
||||
Child / Adult
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchScale('infant')}
|
||||
className={'px-3 py-1.5 rounded-full text-xs font-medium border ' + (scale === 'infant' ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted border-border')}
|
||||
>
|
||||
Infant
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<GcsSelect id="react-gcs-eye" labelText="Eye Opening" value={eye} options={options.eye} onChange={setEye} />
|
||||
<GcsSelect id="react-gcs-verbal" labelText="Verbal Response" value={verbal} options={options.verbal} onChange={setVerbal} />
|
||||
<GcsSelect id="react-gcs-motor" labelText="Motor Response" value={motor} options={options.motor} onChange={setMotor} />
|
||||
</div>
|
||||
{result == null ? null : (
|
||||
<div className={resultBox} data-testid="calc-gcs-result">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">{scale === 'infant' ? 'Infant-modified GCS' : 'Child / adult GCS'}</div>
|
||||
<div className="text-3xl font-semibold">GCS: {result.total}/15</div>
|
||||
<div className="text-sm text-muted-foreground">{result.severity}</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">Interpretation: 13-15 Mild, 9-12 Moderate, 3-8 Severe/Coma.</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyPanel({ pill }: { pill: Pill }) {
|
||||
return (
|
||||
<section className={card} data-testid={'calc-panel-' + pill.id}>
|
||||
<h2 className="text-lg font-semibold">{pill.label}</h2>
|
||||
<p className="text-sm text-muted-foreground">{pill.summary}</p>
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-3 text-sm space-y-2">
|
||||
<p className="text-amber-900 dark:text-amber-100">
|
||||
<strong>Source of truth:</strong> {pill.source}.
|
||||
</p>
|
||||
<p className="text-amber-900 dark:text-amber-100">
|
||||
This calculator runs in the legacy viewer. A React port is gated on capturing test vectors
|
||||
from the vanilla implementation so the numerical output can be verified byte-for-byte —
|
||||
the migration checkpoint specifically flags this class of data as the one an LLM is most
|
||||
likely to silently simplify.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/#calculators" className={btnPrimary + ' inline-block'}>
|
||||
Open in legacy viewer
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BiliPanel() {
|
||||
const [mode, setMode] = useState<'aap' | 'bhutani'>('aap');
|
||||
const [ga, setGa] = useState('38');
|
||||
const [hours, setHours] = useState('');
|
||||
const [tsb, setTsb] = useState('');
|
||||
const [risk, setRisk] = useState<BiliRisk>('low');
|
||||
const [aapResult, setAapResult] = useState<ReturnType<typeof classifyAapBili> | null>(null);
|
||||
const [bhutResult, setBhutResult] = useState<ReturnType<typeof classifyBhutani> | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function calc() {
|
||||
const hoursNum = Number(hours);
|
||||
const tsbNum = Number(tsb);
|
||||
if (!Number.isFinite(hoursNum) || !Number.isFinite(tsbNum) || hoursNum <= 0 || tsbNum <= 0) {
|
||||
setError('Enter hours of life and TSB (mg/dL).');
|
||||
setAapResult(null);
|
||||
setBhutResult(null);
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
if (mode === 'aap') {
|
||||
const gaNum = Number(ga);
|
||||
if (!Number.isFinite(gaNum) || gaNum < 35) {
|
||||
setError('AAP 2022 thresholds apply to GA ≥35 weeks.');
|
||||
setAapResult(null);
|
||||
return;
|
||||
}
|
||||
setAapResult(classifyAapBili(gaNum, hoursNum, tsbNum, risk));
|
||||
setBhutResult(null);
|
||||
} else {
|
||||
setBhutResult(classifyBhutani(hoursNum, tsbNum));
|
||||
setAapResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
const statusColor = aapResult
|
||||
? aapResult.status === 'Above Exchange' ? 'text-red-800 bg-red-100'
|
||||
: aapResult.status === 'Above Phototherapy' ? 'text-red-700 bg-red-50'
|
||||
: 'text-green-700 bg-green-50'
|
||||
: '';
|
||||
const zoneColor = bhutResult
|
||||
? bhutResult.zone === 'High-Risk' ? 'text-red-800 bg-red-100'
|
||||
: bhutResult.zone === 'High-Intermediate' ? 'text-orange-700 bg-orange-50'
|
||||
: bhutResult.zone === 'Low-Intermediate' ? 'text-amber-700 bg-amber-50'
|
||||
: 'text-green-700 bg-green-50'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-bili">
|
||||
<h2 className="text-lg font-semibold">Bilirubin</h2>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setMode('aap')} className={'px-3 py-1 rounded text-xs font-medium ' + (mode === 'aap' ? 'bg-primary text-primary-foreground' : 'bg-muted')} data-testid="bili-mode-aap">AAP 2022 Phototherapy</button>
|
||||
<button type="button" onClick={() => setMode('bhutani')} className={'px-3 py-1 rounded text-xs font-medium ' + (mode === 'bhutani' ? 'bg-primary text-primary-foreground' : 'bg-muted')} data-testid="bili-mode-bhutani">Bhutani Nomogram</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{mode === 'aap' && (
|
||||
<>
|
||||
<div className={field}>
|
||||
<label htmlFor="bili-ga" className={label}>GA (weeks)</label>
|
||||
<select id="bili-ga" className={input} value={ga} onChange={(e) => setGa(e.target.value)}>
|
||||
{[35, 36, 37, 38, 39, 40].map((g) => <option key={g} value={g}>{g}{g === 40 ? '+' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className={field}>
|
||||
<label htmlFor="bili-risk" className={label}>Neurotoxicity risk</label>
|
||||
<select id="bili-risk" className={input} value={risk} onChange={(e) => setRisk(e.target.value as BiliRisk)}>
|
||||
<option value="low">No risk factors</option>
|
||||
<option value="medium">With risk factors</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<FormField id="bili-hours" labelText="Age (hours)" value={hours} onChange={setHours} min="0" max="336" placeholder="48" />
|
||||
<FormField id="bili-tsb" labelText="TSB (mg/dL)" value={tsb} onChange={setTsb} min="0" max="50" placeholder="15" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className={btnPrimary} onClick={calc} data-testid="calc-bili-calculate">Calculate</button>
|
||||
<button type="button" className={btnGhost} onClick={() => { setHours(''); setTsb(''); setAapResult(null); setBhutResult(null); setError(''); }}>Clear</button>
|
||||
</div>
|
||||
{error && <div className={errorBox}>{error}</div>}
|
||||
|
||||
{aapResult && (
|
||||
<div className={resultBox + ' space-y-2'} data-testid="calc-bili-aap-result">
|
||||
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + statusColor}>{aapResult.status}</div>
|
||||
<div className="text-sm">TSB {tsb} mg/dL at {hours} hours of life (GA {ga}w {risk === 'medium' ? 'with' : 'without'} risk factors)</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Phototherapy</span><div className="font-semibold">{aapResult.photoThreshold.toFixed(1)} mg/dL</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Exchange</span><div className="font-semibold text-red-800">{aapResult.exchangeThreshold.toFixed(1)} mg/dL</div></div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">AAP 2022 CPG (Kemper et al.). Always use clinical judgment.</div>
|
||||
</div>
|
||||
)}
|
||||
{bhutResult && (
|
||||
<div className={resultBox + ' space-y-2'} data-testid="calc-bili-bhutani-result">
|
||||
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + zoneColor}>{bhutResult.zone} Zone</div>
|
||||
<div className="text-sm">TSB {tsb} mg/dL at {hours} hours of life</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div><span className="text-xs uppercase text-muted-foreground">40th %ile</span><div className="font-semibold">{bhutResult.p40.toFixed(1)}</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">75th %ile</span><div className="font-semibold">{bhutResult.p75.toFixed(1)}</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">95th %ile</span><div className="font-semibold">{bhutResult.p95.toFixed(1)}</div></div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">Bhutani 1999 hour-specific risk nomogram for infants ≥35 weeks GA.</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GrowthPanel() {
|
||||
const [sex, setSex] = useState<Sex>('male');
|
||||
const [ga, setGa] = useState('');
|
||||
const [weight, setWeight] = useState('');
|
||||
const [result, setResult] = useState<ReturnType<typeof fentonWeightForAge> | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function calc() {
|
||||
const gaNum = Number(ga);
|
||||
const wtNum = Number(weight);
|
||||
if (!Number.isFinite(gaNum) || !Number.isFinite(wtNum) || gaNum < 22 || gaNum > 50 || wtNum <= 0) {
|
||||
setError('Enter GA (22-50 weeks) and weight (grams).');
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setResult(fentonWeightForAge(gaNum, wtNum, sex));
|
||||
}
|
||||
|
||||
const classification = result ? classifySizeForAge(result.percentile) : null;
|
||||
const classColor = classification === 'SGA' ? 'text-orange-700 bg-orange-50'
|
||||
: classification === 'LGA' ? 'text-amber-700 bg-amber-50'
|
||||
: 'text-green-700 bg-green-50';
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-growth">
|
||||
<h2 className="text-lg font-semibold">Fenton 2013 Preterm Growth</h2>
|
||||
<p className="text-sm text-muted-foreground">Weight-for-gestational-age Z-score + percentile + SGA/AGA/LGA classification.</p>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className={field}>
|
||||
<label htmlFor="fenton-sex" className={label}>Sex</label>
|
||||
<select id="fenton-sex" className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)}>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
<FormField id="fenton-ga" labelText="GA (weeks)" value={ga} onChange={setGa} min="22" max="50" step="0.1" placeholder="32" />
|
||||
<FormField id="fenton-weight" labelText="Weight (g)" value={weight} onChange={setWeight} min="200" max="7000" step="10" placeholder="1500" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className={btnPrimary} onClick={calc} data-testid="calc-fenton-calculate">Calculate</button>
|
||||
<button type="button" className={btnGhost} onClick={() => { setGa(''); setWeight(''); setResult(null); setError(''); }}>Clear</button>
|
||||
</div>
|
||||
{error && <div className={errorBox}>{error}</div>}
|
||||
{result && classification && (
|
||||
<div className={resultBox + ' space-y-2'} data-testid="calc-fenton-result">
|
||||
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + classColor}>{classification}</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Percentile</span><div className="font-semibold">{result.percentile.toFixed(1)}%</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Z-score</span><div className="font-semibold">{result.z.toFixed(2)}</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Median (M)</span><div className="font-semibold">{Math.round(result.M)} g</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">L / S</span><div className="font-mono text-xs">{result.L.toFixed(3)} / {result.S.toFixed(3)}</div></div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">Fenton TR, Kim JH. Systematic review — revised Fenton growth chart for preterm infants. BMC Pediatr 2013;13:59.</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivePanel({ pill }: { pill: Pill }) {
|
||||
if (pill.id === 'bsa') return <BsaPanel />;
|
||||
if (pill.id === 'dose') return <DosePanel />;
|
||||
if (pill.id === 'gcs') return <GcsPanel />;
|
||||
if (pill.id === 'bili') return <BiliPanel />;
|
||||
if (pill.id === 'growth') return <GrowthPanel />;
|
||||
if (pill.id === 'bmi') return <BmiPanel />;
|
||||
if (pill.id === 'vitals') return <VitalsPanel />;
|
||||
if (pill.id === 'resus') return <ResusPanel />;
|
||||
if (pill.id === 'equipment') return <EquipmentPanel />;
|
||||
if (pill.id === 'bp') return <BpPanel />;
|
||||
return <LegacyPanel pill={pill} />;
|
||||
}
|
||||
|
||||
export default function Calculators() {
|
||||
const [active, setActive] = useState<string>(PILLS[0].id);
|
||||
const pill = PILLS.find((p) => p.id === active) ?? PILLS[0];
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Calculators</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pediatric calculators — BP percentiles, bilirubin thresholds, growth, dosing, equipment sizing.
|
||||
Simple pure-formula calculators run in React now; high-risk table-driven calculators remain
|
||||
legacy-gated until vectors are captured.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2" data-testid="calc-subnav">
|
||||
{PILLS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setActive(p.id)}
|
||||
className={
|
||||
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
|
||||
(active === p.id
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted hover:bg-muted/80 border-border')
|
||||
}
|
||||
data-testid={'calc-pill-' + p.id}
|
||||
>
|
||||
{p.label}{p.ported ? <span className="ml-1 text-[10px] opacity-80">React</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ActivePanel pill={pill} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
client/src/pages/Catchup.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// ============================================================
|
||||
// CATCH-UP SCHEDULE — CDC catch-up immunization tables from
|
||||
// GET /api/schedule-data.
|
||||
// ============================================================
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface CatchUpSeries { dose: number | string; minimumAge?: string; minimumIntervalToPrev?: string; notes?: string }
|
||||
interface CatchUpEntry {
|
||||
minimumAgeForDose1?: string;
|
||||
series?: CatchUpSeries[];
|
||||
catchUpNotes?: string | string[];
|
||||
}
|
||||
interface ScheduleData {
|
||||
catchUpSchedule: Record<string, CatchUpEntry>;
|
||||
vaccineFullNames: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function Catchup() {
|
||||
const { data, isLoading, error } = useQuery<ScheduleData>({
|
||||
queryKey: ['schedule-data'],
|
||||
queryFn: () => api.get<ScheduleData>('/api/schedule-data'),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-6 text-sm text-muted-foreground">Loading…</div>;
|
||||
if (error) return <div className="p-6 text-sm text-destructive">{(error as Error).message}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Catch-Up Schedule</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
CDC 2025 catch-up immunization schedule — minimum ages and intervals per vaccine.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{Object.entries(data.catchUpSchedule).map(([key, v]) => {
|
||||
const fullName = data.vaccineFullNames[key] || key;
|
||||
const notes = v.catchUpNotes
|
||||
? (Array.isArray(v.catchUpNotes) ? v.catchUpNotes : [v.catchUpNotes])
|
||||
: [];
|
||||
return (
|
||||
<section key={key} className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<header className="px-4 py-2 border-b border-border bg-muted/40 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">{fullName}</h2>
|
||||
{v.minimumAgeForDose1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Min age dose 1: <strong>{v.minimumAgeForDose1}</strong>
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{v.series && v.series.length > 0 && (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/20">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">Dose</th>
|
||||
<th className="text-left px-3 py-2">Min age</th>
|
||||
<th className="text-left px-3 py-2">Min interval from prev</th>
|
||||
<th className="text-left px-3 py-2">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{v.series.map((s) => (
|
||||
<tr key={String(s.dose)} className="border-t border-border">
|
||||
<td className="px-3 py-2 font-semibold">Dose {s.dose}</td>
|
||||
<td className="px-3 py-2">{s.minimumAge || '—'}</td>
|
||||
<td className="px-3 py-2">{s.minimumIntervalToPrev || '—'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{s.notes || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{notes.length > 0 && (
|
||||
<ul className="list-disc pl-8 py-2 text-xs text-muted-foreground space-y-1">
|
||||
{notes.map((n, i) => <li key={i}>{n}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
client/src/pages/ChartReview.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// ============================================================
|
||||
// CHART REVIEW — /api/generate-chart-review
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import type { ChartReviewOk } from '@/shared/types';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
import EditableResult from '@/components/EditableResult';
|
||||
|
||||
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
|
||||
const TYPE = 'chart' as const;
|
||||
|
||||
interface VisitInput { date: string; content: string; labs: string }
|
||||
|
||||
function emptyVisit(): VisitInput { return { date: '', content: '', labs: '' }; }
|
||||
|
||||
export default function ChartReview() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [type, setType] = useState<ReviewType>('outpatient');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [pmh, setPmh] = useState('');
|
||||
const [visits, setVisits] = useState<VisitInput[]>([emptyVisit()]);
|
||||
const [additionalInstructions, setAdditionalInstructions] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
const generate = useMutation<ChartReviewOk, Error, any>({
|
||||
mutationFn: (body) => api.post<ChartReviewOk>('/api/generate-chart-review', body),
|
||||
onSuccess: (data) => setResult(data.review),
|
||||
});
|
||||
|
||||
function updateVisit(i: number, patch: Partial<VisitInput>) {
|
||||
setVisits((vs) => vs.map((v, idx) => (idx === i ? { ...v, ...patch } : v)));
|
||||
}
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setResult(null);
|
||||
const filled = visits.filter((v) => v.content.trim());
|
||||
generate.mutate({
|
||||
type,
|
||||
patientAge, patientGender, pmh,
|
||||
visits: type === 'outpatient' ? filled : undefined,
|
||||
subspecialty: type === 'subspecialty' ? filled : undefined,
|
||||
edVisits: type === 'ed' ? filled : undefined,
|
||||
additionalInstructions: additionalInstructions || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
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">Chart Review</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Past visits → summary for pre-charting.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={JSON.stringify(visits)} generatedNote={result || ''}
|
||||
partialData={{ type, age: patientAge, gender: patientGender, pmh, additionalInstructions }}
|
||||
onLoad={(enc) => {
|
||||
try {
|
||||
const parsedVisits = enc.transcript ? JSON.parse(enc.transcript) as VisitInput[] : [emptyVisit()];
|
||||
setVisits(parsedVisits.length ? parsedVisits : [emptyVisit()]);
|
||||
} catch { setVisits([emptyVisit()]); }
|
||||
setResult(enc.generated_note || null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.type) setType(pd.type);
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.pmh) setPmh(pd.pmh);
|
||||
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => {
|
||||
setVisits([emptyVisit()]); setResult(null);
|
||||
setType('outpatient'); setPatientAge(''); setPatientGender('');
|
||||
setPmh(''); setAdditionalInstructions('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Review type</span>
|
||||
<select className={input} value={type} onChange={(e) => setType(e.target.value as ReviewType)}>
|
||||
<option value="outpatient">Outpatient</option>
|
||||
<option value="subspecialty">Subspecialty</option>
|
||||
<option value="ed">ED</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
|
||||
<input className={input} 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">PMH</span>
|
||||
<input className={input} value={pmh} onChange={(e) => setPmh(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">Visits</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisits((v) => [...v, emptyVisit()])}
|
||||
className="text-xs rounded-md border border-border px-2 py-1"
|
||||
>
|
||||
+ Add visit
|
||||
</button>
|
||||
</div>
|
||||
{visits.map((v, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-3 space-y-2 bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className={input + ' max-w-xs'}
|
||||
value={v.date}
|
||||
onChange={(e) => updateVisit(i, { date: e.target.value })}
|
||||
/>
|
||||
{visits.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisits(vs => vs.filter((_, idx) => idx !== i))}
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
className={input + ' min-h-[100px] font-mono text-sm'}
|
||||
placeholder="Visit note content — paste here."
|
||||
value={v.content}
|
||||
onChange={(e) => updateVisit(i, { content: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
className={input + ' min-h-[60px] font-mono text-xs'}
|
||||
placeholder="Labs from this visit (optional)"
|
||||
value={v.labs}
|
||||
onChange={(e) => updateVisit(i, { labs: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Additional instructions</span>
|
||||
<textarea
|
||||
className={input + ' min-h-[60px] text-sm'}
|
||||
placeholder="e.g. 'Focus on thyroid management', 'Highlight medication changes'"
|
||||
value={additionalInstructions}
|
||||
onChange={(e) => setAdditionalInstructions(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generate.isPending || !visits.some((v) => v.content.trim())}
|
||||
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{generate.isPending ? 'Generating…' : 'Generate Chart Review'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result !== null && (
|
||||
<EditableResult
|
||||
text={result}
|
||||
onChange={setResult}
|
||||
section={null}
|
||||
title="Chart Review"
|
||||
exportLabel="chart-review"
|
||||
exportType="chart-review"
|
||||
sourceContext={visits.map((v) => v.content).filter(Boolean).join('\n\n')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
client/src/pages/Cms.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// ============================================================
|
||||
// CMS — Learning Hub Content Manager. Faithful port of vanilla
|
||||
// public/components/cms.html + the loadCms / loadCmsContent / etc.
|
||||
// section of public/js/learningHub.js (@be14578).
|
||||
//
|
||||
// Server gates this with moderatorMiddleware (admin OR moderator),
|
||||
// so the route is mounted unconditionally and the server returns
|
||||
// 403 to non-moderators. The sidebar nav link is gated by
|
||||
// `me.user.role` to keep it hidden from clinicians.
|
||||
//
|
||||
// Sub-components live under src/pages/cms/:
|
||||
// StatsBar — 6-cell metrics summary
|
||||
// CategoriesPanel — category list + add/delete + filters
|
||||
// ContentList — table + toolbar (new article/quiz/pearl/presentation)
|
||||
// ContentEditor — title/category/type/body + per-quiz QuestionsEditor
|
||||
//
|
||||
// What's intentionally NOT in this first cut (each of these is a
|
||||
// follow-up commit if Daniel actually starts using them):
|
||||
// • AI generation panel (vanilla `lh-ai-panel`)
|
||||
// • WebDAV file picker for AI sources
|
||||
// • Drag-and-drop file upload for AI ingest
|
||||
// • Rich-text body editor (Quill toolbar) — body is a textarea
|
||||
// • Slide editor for presentations — body holds JSON for now
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import StatsBar from './cms/StatsBar';
|
||||
import CategoriesPanel from './cms/CategoriesPanel';
|
||||
import ContentList from './cms/ContentList';
|
||||
import ContentEditor from './cms/ContentEditor';
|
||||
import type { ContentType } from './cms/cms-types';
|
||||
|
||||
type View = { kind: 'list' } | { kind: 'edit'; id: number | null; type: ContentType };
|
||||
|
||||
export default function Cms() {
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'published' | 'draft'>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<number | 'all'>('all');
|
||||
const [view, setView] = useState<View>({ kind: 'list' });
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Content Manager</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create and manage Learning Hub content, quizzes, and categories.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<StatsBar />
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<CategoriesPanel
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilter={setStatusFilter}
|
||||
categoryFilter={categoryFilter}
|
||||
onCategoryFilter={setCategoryFilter}
|
||||
/>
|
||||
|
||||
{view.kind === 'list' ? (
|
||||
<ContentList
|
||||
statusFilter={statusFilter}
|
||||
categoryFilter={categoryFilter}
|
||||
onEdit={(id) => setView({ kind: 'edit', id, type: 'article' })}
|
||||
onCreate={(type) => setView({ kind: 'edit', id: null, type })}
|
||||
/>
|
||||
) : (
|
||||
<ContentEditor
|
||||
id={view.id}
|
||||
initialType={view.type}
|
||||
onClose={() => setView({ kind: 'list' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
client/src/pages/Dictation.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// ============================================================
|
||||
// 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';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
import EditableResult from '@/components/EditableResult';
|
||||
|
||||
type Setting = 'outpatient' | 'inpatient';
|
||||
const TYPE = 'dictation' as const;
|
||||
|
||||
export default function Dictation() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [setting, setSetting] = useState<Setting>('outpatient');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [interim, setInterim] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [recError, setRecError] = 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: (interim || transcript).trim(), 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('');
|
||||
setInterim('');
|
||||
setResult(null);
|
||||
setValidationError(null);
|
||||
}
|
||||
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
const displayedTranscript = interim || transcript;
|
||||
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={transcript} generatedNote={result || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, setting }}
|
||||
onLoad={(enc) => {
|
||||
setTranscript(enc.transcript || '');
|
||||
setInterim('');
|
||||
setResult(enc.generated_note || null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.setting) setSetting(pd.setting);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => { clear(); setPatientAge(''); setPatientGender(''); setSetting('outpatient'); }}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<Recorder
|
||||
module="dictation"
|
||||
onTranscript={(text, meta) => {
|
||||
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
|
||||
setInterim('');
|
||||
setRecError(null);
|
||||
}}
|
||||
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
|
||||
onError={(msg) => setRecError(msg)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</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="Click Start recording, or type / paste your dictation here."
|
||||
value={displayedTranscript}
|
||||
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
|
||||
/>
|
||||
</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 || !displayedTranscript.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 !== null && (
|
||||
<EditableResult
|
||||
text={result}
|
||||
onChange={setResult}
|
||||
section="hpi"
|
||||
title="Generated HPI"
|
||||
exportLabel="hpi-dictation"
|
||||
exportType="hpi"
|
||||
sourceContext={displayedTranscript}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
client/src/pages/Encounter.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// ============================================================
|
||||
// ENCOUNTER — live encounter → HPI via /api/generate-hpi-encounter.
|
||||
// Full port — mic recorder + Web Speech live preview + transcribe
|
||||
// + save/resume across sign-outs (mirrors public/js/liveEncounter.js
|
||||
// + encounters.js).
|
||||
// ============================================================
|
||||
|
||||
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';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
import EditableResult from '@/components/EditableResult';
|
||||
|
||||
type Setting = 'outpatient' | 'inpatient';
|
||||
const TYPE = 'encounter' as const;
|
||||
|
||||
export default function Encounter() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [setting, setSetting] = useState<Setting>('outpatient');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [interim, setInterim] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [recError, setRecError] = useState<string | null>(null);
|
||||
|
||||
const generate = useMutation<HpiOk, Error, HpiEncounterRequest>({
|
||||
mutationFn: (body) => api.post<HpiOk>('/api/generate-hpi-encounter', body),
|
||||
onSuccess: (data) => setResult(data.hpi),
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setValidationError(null);
|
||||
const body: HpiEncounterRequest = { transcript: (interim || transcript).trim(), 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);
|
||||
}
|
||||
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
const displayedTranscript = interim || transcript;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Live Encounter → HPI</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Record or paste an encounter transcript; generate a structured HPI.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={transcript} generatedNote={result || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, setting }}
|
||||
onLoad={(enc) => {
|
||||
setTranscript(enc.transcript || '');
|
||||
setInterim('');
|
||||
setResult(enc.generated_note || null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.setting) setSetting(pd.setting);
|
||||
} catch { /* ignore malformed partial */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => { setTranscript(''); setInterim(''); setResult(null); setPatientAge(''); setPatientGender(''); setSetting('outpatient'); }}
|
||||
/>
|
||||
|
||||
<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. 5 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">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>
|
||||
|
||||
<Recorder
|
||||
module="encounter"
|
||||
onTranscript={(text, meta) => {
|
||||
// appended=true means the live preview text is being kept;
|
||||
// appended=false means we got a fresh transcription that
|
||||
// should replace what's there.
|
||||
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
|
||||
setInterim('');
|
||||
setRecError(null);
|
||||
}}
|
||||
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
|
||||
onError={(msg) => setRecError(msg)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</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-[220px] font-mono text-sm'}
|
||||
placeholder="Click Start recording, or type / paste an encounter transcript here."
|
||||
value={displayedTranscript}
|
||||
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
|
||||
/>
|
||||
</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 || !displayedTranscript.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>
|
||||
</form>
|
||||
|
||||
{result !== null && (
|
||||
<EditableResult
|
||||
text={result}
|
||||
onChange={setResult}
|
||||
section="encounter"
|
||||
title="Generated HPI"
|
||||
exportLabel="hpi-encounter"
|
||||
exportType="hpi"
|
||||
sourceContext={displayedTranscript}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
client/src/pages/Extensions.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// ============================================================
|
||||
// EXTENSIONS — first tab ported from vanilla JS to React.
|
||||
// Read-only list view with a simple add form. The old vanilla
|
||||
// version has richer UI (trash, restore, purge, search) — this
|
||||
// minimum-viable port proves the migration pipeline works:
|
||||
// shared types + api wrapper + React Query + Tailwind shadcn.
|
||||
// The full CRUD UI lands in a follow-up when polish time arrives.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import type { ExtensionsListOk, Extension } from '@/shared/types';
|
||||
import { ExtensionCreateSchema, type ExtensionCreate } from '@/shared/schemas';
|
||||
|
||||
function ExtensionRow({ ext }: { ext: Extension }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{ext.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{ext.location}</div>
|
||||
</div>
|
||||
<div className="font-mono text-sm">{ext.number}</div>
|
||||
<div className="text-xs uppercase text-muted-foreground w-20 text-right">
|
||||
{ext.type}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddForm({ onDone }: { onDone: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [form, setForm] = useState<ExtensionCreate>({
|
||||
location: '',
|
||||
name: '',
|
||||
number: '',
|
||||
type: 'extension',
|
||||
notes: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: ExtensionCreate) => api.post<{ id: number }>('/api/extensions', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['extensions'] });
|
||||
onDone();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const parsed = ExtensionCreateSchema.safeParse(form);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(parsed.data);
|
||||
}
|
||||
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3 p-4 bg-muted/40 rounded-lg border border-border">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
className={input}
|
||||
placeholder="Location (e.g. Main Hospital)"
|
||||
value={form.location}
|
||||
onChange={e => setForm({ ...form, location: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className={input}
|
||||
placeholder="Name / department"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className={input}
|
||||
placeholder="Number"
|
||||
value={form.number}
|
||||
onChange={e => setForm({ ...form, number: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className={input}
|
||||
value={form.type}
|
||||
onChange={e => setForm({ ...form, type: e.target.value as 'extension' | 'pager' })}
|
||||
>
|
||||
<option value="extension">Extension</option>
|
||||
<option value="pager">Pager</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={onDone} className="rounded-md border border-border px-4 py-2 text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Extensions() {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const { data, isLoading, error } = useQuery<ExtensionsListOk>({
|
||||
queryKey: ['extensions'],
|
||||
queryFn: () => api.get<ExtensionsListOk>('/api/extensions'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Pagers & Extensions</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Per-user directory. This React port is the migration proof-of-life.
|
||||
</p>
|
||||
</div>
|
||||
{!adding && (
|
||||
<button
|
||||
onClick={() => setAdding(true)}
|
||||
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{adding && <AddForm onDone={() => setAdding(false)} />}
|
||||
|
||||
{isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
|
||||
{error && <div className="text-sm text-destructive">{(error as Error).message}</div>}
|
||||
{data && data.items.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground italic py-8 text-center">
|
||||
No extensions yet. Click Add to create the first one.
|
||||
</div>
|
||||
)}
|
||||
{data && data.items.length > 0 && (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
{data.items.map((ext: Extension) => <ExtensionRow key={ext.id} ext={ext} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
198
client/src/pages/HospitalCourse.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// ============================================================
|
||||
// HOSPITAL COURSE — /api/generate-hospital-course
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import type { HospitalCourseOk } from '@/shared/types';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
import EditableResult from '@/components/EditableResult';
|
||||
|
||||
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
|
||||
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
|
||||
const TYPE = 'hospital' as const;
|
||||
|
||||
interface NoteEntry { date: string; type: string; content: string }
|
||||
|
||||
export default function HospitalCourse() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [recError, setRecError] = useState<string | null>(null);
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [pmh, setPmh] = useState('');
|
||||
const [setting, setSetting] = useState<SettingKind>('floor');
|
||||
const [los, setLos] = useState('');
|
||||
const [format, setFormat] = useState<FormatKind>('auto');
|
||||
const [hAndPContent, setHAndPContent] = useState('');
|
||||
const [notesText, setNotesText] = useState('');
|
||||
const [additionalInstructions, setAdditionalInstructions] = useState('');
|
||||
const [result, setResult] = useState<{ hospitalCourse: string; format: string } | null>(null);
|
||||
|
||||
const generate = useMutation<HospitalCourseOk, Error, any>({
|
||||
mutationFn: (body) => api.post<HospitalCourseOk>('/api/generate-hospital-course', body),
|
||||
onSuccess: (data) => setResult({ hospitalCourse: data.hospitalCourse, format: data.format || 'auto' }),
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setResult(null);
|
||||
// Notes textarea: one blank-line-separated note per block. First
|
||||
// line of each block is used as the date if it looks like one,
|
||||
// rest becomes content.
|
||||
const notes: NoteEntry[] = notesText
|
||||
.split(/\n\s*\n/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block, i) => ({ date: `Day ${i + 1}`, type: 'Progress Note', content: block }));
|
||||
generate.mutate({
|
||||
notes,
|
||||
hAndP: hAndPContent ? { date: 'Admission', content: hAndPContent } : undefined,
|
||||
patientAge, patientGender, pmh, setting,
|
||||
los: los ? parseInt(los) : undefined,
|
||||
formatPreference: format,
|
||||
additionalInstructions: additionalInstructions || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
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">Hospital Course</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Progress notes + H&P → hospital course summary (prose, day-by-day, or organ-system format).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={notesText} generatedNote={result?.hospitalCourse || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, pmh, setting, los, format, hAndPContent, additionalInstructions }}
|
||||
onLoad={(enc) => {
|
||||
setNotesText(enc.transcript || '');
|
||||
setResult(enc.generated_note ? { hospitalCourse: enc.generated_note, format: 'auto' } : null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.pmh) setPmh(pd.pmh);
|
||||
if (pd?.setting) setSetting(pd.setting);
|
||||
if (pd?.los) setLos(pd.los);
|
||||
if (pd?.format) setFormat(pd.format);
|
||||
if (pd?.hAndPContent) setHAndPContent(pd.hAndPContent);
|
||||
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => {
|
||||
setNotesText(''); setResult(null);
|
||||
setPatientAge(''); setPatientGender(''); setPmh('');
|
||||
setSetting('floor'); setLos(''); setFormat('auto');
|
||||
setHAndPContent(''); setAdditionalInstructions('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<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} 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 SettingKind)}>
|
||||
<option value="floor">Floor</option>
|
||||
<option value="picu">PICU</option>
|
||||
<option value="nicu">NICU</option>
|
||||
<option value="psych">Psych</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex flex-col gap-1 col-span-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">PMH</span>
|
||||
<input className={input} placeholder="e.g. Asthma, hypothyroidism" value={pmh} onChange={(e) => setPmh(e.target.value)} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">LOS (days)</span>
|
||||
<input className={input} type="number" value={los} onChange={(e) => setLos(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Format</span>
|
||||
<select className={input} value={format} onChange={(e) => setFormat(e.target.value as FormatKind)}>
|
||||
<option value="auto">Auto (infer from setting + LOS)</option>
|
||||
<option value="prose">Prose summary</option>
|
||||
<option value="dayByDay">Day-by-day</option>
|
||||
<option value="organSystem">Organ-system (ICU)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">H&P</span>
|
||||
<textarea className={input + ' min-h-[120px] font-mono text-sm'} value={hAndPContent} onChange={(e) => setHAndPContent(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<Recorder
|
||||
module="hospital"
|
||||
onTranscript={(text, meta) => {
|
||||
if (meta.appended) {
|
||||
setNotesText((prev) => prev ? prev + '\n\n' + text : text);
|
||||
} else {
|
||||
setNotesText((prev) => prev ? prev + '\n\n' + text : text);
|
||||
}
|
||||
setRecError(null);
|
||||
}}
|
||||
onError={(msg) => setRecError(msg)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</div>}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Progress notes <span className="normal-case font-normal text-muted-foreground">(separate each note with a blank line)</span>
|
||||
</span>
|
||||
<textarea className={input + ' min-h-[200px] font-mono text-sm'} value={notesText} onChange={(e) => setNotesText(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>
|
||||
<textarea className={input + ' min-h-[60px] text-sm'} value={additionalInstructions} onChange={(e) => setAdditionalInstructions(e.target.value)} />
|
||||
</label>
|
||||
|
||||
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generate.isPending || !notesText.trim()}
|
||||
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{generate.isPending ? 'Generating…' : 'Generate Hospital Course'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result && (
|
||||
<EditableResult
|
||||
text={result.hospitalCourse}
|
||||
onChange={(t) => setResult({ hospitalCourse: t, format: result.format })}
|
||||
section={null}
|
||||
title={'Hospital Course (' + result.format + ')'}
|
||||
exportLabel="hospital-course"
|
||||
exportType="hospital-course"
|
||||
sourceContext={notesText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
482
client/src/pages/Learning.tsx
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
// ============================================================
|
||||
// LEARNING HUB — pediatric education, pearls, and self-assessment
|
||||
// quizzes.
|
||||
//
|
||||
// • Search box (keyword, posts to /api/learning/search)
|
||||
// • Category pills (/api/learning/categories) filter the feed
|
||||
// • Feed list (/api/learning/feed or /category/:slug depending on filter)
|
||||
// • Viewer — rich HTML body rendered with sanitizeHtml() wrapper
|
||||
// so admin-authored content displays formatting safely.
|
||||
// • Slide viewer for content_type === 'presentation' — fetches
|
||||
// pre-rendered HTML from /api/learning/content/:slug/slides.
|
||||
// • Quiz (single / multi / true_false) + results with explanations
|
||||
// • Per-user progress list (last 5 attempts)
|
||||
//
|
||||
// Endpoints all live in src/routes/learningHub.ts at /api/learning/*.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { sanitizeHtml } from '@/lib/sanitize';
|
||||
import type {
|
||||
LearningCategoriesOk,
|
||||
LearningCategory,
|
||||
LearningFeedListOk,
|
||||
LearningFeedRow,
|
||||
LearningContentOk,
|
||||
LearningContentFull,
|
||||
LearningQuestion,
|
||||
QuizAnswer,
|
||||
QuizSubmitOk,
|
||||
LearningSlidesOk,
|
||||
} from '@/shared/types';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const pill = 'px-3 py-1 rounded-full text-xs font-medium border transition-colors cursor-pointer';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
|
||||
const btnGhost = 'rounded-md border border-border px-3 py-2 text-sm disabled:opacity-50';
|
||||
|
||||
function typeBadge(t: string) {
|
||||
switch (t) {
|
||||
case 'quiz': return 'Quiz';
|
||||
case 'pearl': return 'Pearl';
|
||||
case 'presentation': return 'Slides';
|
||||
default: return 'Article';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feed ────────────────────────────────────────────────────
|
||||
function FeedCard({ row, onOpen }: { row: LearningFeedRow; onOpen: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full text-left rounded-lg border border-border bg-card hover:bg-muted/60 p-4 transition-colors"
|
||||
data-testid={'lh-feed-item-' + row.slug}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground uppercase tracking-wide mb-1">
|
||||
<span className="font-semibold">{typeBadge(row.content_type)}</span>
|
||||
{row.category_name && <span>· {row.category_name}</span>}
|
||||
{row.question_count ? <span>· {row.question_count} Q</span> : null}
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{row.title}</div>
|
||||
{row.subject && <div className="text-xs text-muted-foreground mt-0.5 truncate">{row.subject}</div>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Feed({
|
||||
filter,
|
||||
query,
|
||||
onOpen,
|
||||
}: {
|
||||
filter: string; // category slug or '' for all
|
||||
query: string;
|
||||
onOpen: (slug: string) => void;
|
||||
}) {
|
||||
const key: unknown[] =
|
||||
query
|
||||
? ['learning-search', query]
|
||||
: filter
|
||||
? ['learning-category', filter]
|
||||
: ['learning-feed'];
|
||||
|
||||
const { data, isLoading, error } = useQuery<LearningFeedListOk>({
|
||||
queryKey: key,
|
||||
queryFn: () => {
|
||||
if (query) return api.get<LearningFeedListOk>('/api/learning/search?q=' + encodeURIComponent(query));
|
||||
if (filter)
|
||||
return api.get<LearningFeedListOk & { category?: LearningCategory }>(
|
||||
'/api/learning/category/' + encodeURIComponent(filter),
|
||||
);
|
||||
return api.get<LearningFeedListOk>('/api/learning/feed?limit=30');
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading…</div>;
|
||||
if (error) return <div className="text-sm text-destructive">{(error as Error).message}</div>;
|
||||
const rows = data?.content || [];
|
||||
if (rows.length === 0)
|
||||
return <div className="text-sm text-muted-foreground italic py-4">No content found.</div>;
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3" data-testid="lh-feed">
|
||||
{rows.map((r) => <FeedCard key={r.id} row={r} onOpen={() => onOpen(r.slug)} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Viewer + Quiz ───────────────────────────────────────────
|
||||
type AnswerMap = Record<number, { optionId?: number; optionIds: Set<number> }>;
|
||||
function emptyAnswers(questions: LearningQuestion[]): AnswerMap {
|
||||
const m: AnswerMap = {};
|
||||
for (const q of questions) m[q.id] = { optionIds: new Set() };
|
||||
return m;
|
||||
}
|
||||
|
||||
function Quiz({
|
||||
content,
|
||||
onReset,
|
||||
}: {
|
||||
content: LearningContentFull;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [answers, setAnswers] = useState<AnswerMap>(() => emptyAnswers(content.questions));
|
||||
const [result, setResult] = useState<QuizSubmitOk | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: (body: { contentId: number; answers: QuizAnswer[] }) =>
|
||||
api.post<QuizSubmitOk>('/api/learning/submit-quiz', body),
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
// Refresh progress list the next time the viewer opens.
|
||||
qc.invalidateQueries({ queryKey: ['learning-content', content.slug] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message || 'Submit failed'),
|
||||
});
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const payload: QuizAnswer[] = content.questions.map((q) => {
|
||||
const a = answers[q.id];
|
||||
if (q.question_type === 'multi') {
|
||||
return { questionId: q.id, optionIds: Array.from(a?.optionIds || []) };
|
||||
}
|
||||
return { questionId: q.id, optionId: a?.optionId ?? null };
|
||||
});
|
||||
submit.mutate({ contentId: content.id, answers: payload });
|
||||
}
|
||||
|
||||
function selectSingle(q: LearningQuestion, optionId: number) {
|
||||
setAnswers((prev) => ({ ...prev, [q.id]: { optionId, optionIds: new Set() } }));
|
||||
}
|
||||
function toggleMulti(q: LearningQuestion, optionId: number) {
|
||||
setAnswers((prev) => {
|
||||
const s = new Set(prev[q.id]?.optionIds || []);
|
||||
if (s.has(optionId)) s.delete(optionId);
|
||||
else s.add(optionId);
|
||||
return { ...prev, [q.id]: { optionIds: s } };
|
||||
});
|
||||
}
|
||||
|
||||
if (result) {
|
||||
const color =
|
||||
result.percentage >= 80 ? 'bg-green-600'
|
||||
: result.percentage >= 50 ? 'bg-amber-500'
|
||||
: 'bg-destructive';
|
||||
return (
|
||||
<section className={card} data-testid="lh-quiz-results">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-base font-semibold">Results</h3>
|
||||
<span
|
||||
className={'px-2 py-0.5 rounded text-xs font-semibold text-white ' + color}
|
||||
data-testid="lh-quiz-score"
|
||||
>
|
||||
{result.score}/{result.total} ({result.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{result.results.map((r, idx) => (
|
||||
<div key={r.questionId} className="rounded-md border border-border p-3 bg-muted/30">
|
||||
<div className="text-sm font-medium">
|
||||
<span className={r.isCorrect ? 'text-green-600' : 'text-destructive'}>
|
||||
{r.isCorrect ? '✓' : '✗'}
|
||||
</span>{' '}
|
||||
Q{idx + 1}: {r.questionText}
|
||||
</div>
|
||||
{!r.isCorrect && r.correctOptionText && (
|
||||
<div className="text-xs text-green-700 mt-1">
|
||||
<strong>Correct:</strong> {r.correctOptionText}
|
||||
</div>
|
||||
)}
|
||||
{!r.isCorrect && r.selectedExplanation && (
|
||||
<div className="text-xs text-destructive mt-1">
|
||||
<strong>Why incorrect:</strong> {r.selectedExplanation}
|
||||
</div>
|
||||
)}
|
||||
{r.generalExplanation && (
|
||||
<div className="text-xs text-muted-foreground mt-1">{r.generalExplanation}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={btnGhost}
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setAnswers(emptyAnswers(content.questions));
|
||||
}}
|
||||
>
|
||||
Retake
|
||||
</button>
|
||||
<button type="button" className={btnPrimary} onClick={onReset}>Back to Feed</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="lh-quiz">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">Quiz</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{content.questions.length} question{content.questions.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{content.questions.map((q, idx) => {
|
||||
const isMulti = q.question_type === 'multi';
|
||||
const typeLabel =
|
||||
q.question_type === 'true_false' ? 'True / False'
|
||||
: isMulti ? 'Multiple Select'
|
||||
: 'Single Choice';
|
||||
return (
|
||||
<div key={q.id} className="rounded-md border border-border p-3 space-y-2 bg-muted/30">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-semibold">Q{idx + 1}</span>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium">{q.question_text}</div>
|
||||
{isMulti && (
|
||||
<div className="text-xs text-muted-foreground italic">Select all that apply</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{q.options.map((opt) => {
|
||||
const a = answers[q.id];
|
||||
const checked = isMulti
|
||||
? a?.optionIds.has(opt.id) === true
|
||||
: a?.optionId === opt.id;
|
||||
return (
|
||||
<label
|
||||
key={opt.id}
|
||||
className="flex items-start gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-2 py-1"
|
||||
>
|
||||
<input
|
||||
type={isMulti ? 'checkbox' : 'radio'}
|
||||
name={'q-' + q.id}
|
||||
checked={checked}
|
||||
onChange={() =>
|
||||
isMulti ? toggleMulti(q, opt.id) : selectSingle(q, opt.id)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>{opt.option_text}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className={btnPrimary}
|
||||
disabled={submit.isPending}
|
||||
data-testid="btn-lh-submit-quiz"
|
||||
>
|
||||
{submit.isPending ? 'Submitting…' : 'Submit Answers'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Fetches pre-rendered Marp slides from the server and renders them
|
||||
// one at a time with keyboard navigation. Slides arrive as <section>…
|
||||
// elements already processed by the server-side Marp instance, so we
|
||||
// run them through sanitizeHtml before injecting.
|
||||
function SlideViewer({ slug, title }: { slug: string; title: string }) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { data, isLoading, error } = useQuery<LearningSlidesOk>({
|
||||
queryKey: ['learning-slides', slug],
|
||||
queryFn: () => api.get<LearningSlidesOk>('/api/learning/content/' + encodeURIComponent(slug) + '/slides'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!data) return;
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown') setIdx((i) => Math.min(i + 1, data.slides.length - 1));
|
||||
if (e.key === 'ArrowLeft' || e.key === 'PageUp') setIdx((i) => Math.max(0, i - 1));
|
||||
if (e.key === 'Escape' && fullscreen) setFullscreen(false);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [data, fullscreen]);
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading slides…</div>;
|
||||
if (error) return <div className="text-sm text-destructive">Failed to load slides: {(error as Error).message}</div>;
|
||||
if (!data || data.slides.length === 0) return <div className="text-sm text-muted-foreground">No slides in this presentation.</div>;
|
||||
|
||||
const sanitizedCss = data.css ? sanitizeHtml('<style>' + data.css + '</style>') : '';
|
||||
const slide = sanitizeHtml(data.slides[idx] || '');
|
||||
const containerClass = fullscreen
|
||||
? 'fixed inset-0 z-50 bg-background flex flex-col'
|
||||
: 'rounded-lg border border-border bg-white dark:bg-black flex flex-col';
|
||||
|
||||
return (
|
||||
<div className={containerClass} data-testid="lh-slides">
|
||||
{sanitizedCss && <div dangerouslySetInnerHTML={{ __html: sanitizedCss }} />}
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2 text-xs">
|
||||
<span className="text-muted-foreground truncate">📊 {title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{idx + 1} / {data.slides.length}</span>
|
||||
<button type="button" onClick={() => setFullscreen(!fullscreen)} className="px-2 py-1 rounded bg-muted text-xs" data-testid="lh-slides-fullscreen">
|
||||
{fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 flex items-center justify-center" style={{ minHeight: fullscreen ? undefined : '480px' }}>
|
||||
<div dangerouslySetInnerHTML={{ __html: slide }} data-testid="lh-slide-current" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
||||
<button type="button" onClick={() => setIdx((i) => Math.max(0, i - 1))} disabled={idx === 0} className={btnGhost} data-testid="lh-slides-prev">← Previous</button>
|
||||
<span className="text-xs text-muted-foreground">← → to navigate</span>
|
||||
<button type="button" onClick={() => setIdx((i) => Math.min(i + 1, data.slides.length - 1))} disabled={idx >= data.slides.length - 1} className={btnGhost} data-testid="lh-slides-next">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentViewer({ slug, onBack }: { slug: string; onBack: () => void }) {
|
||||
const { data, isLoading, error } = useQuery<LearningContentOk>({
|
||||
queryKey: ['learning-content', slug],
|
||||
queryFn: () => api.get<LearningContentOk>('/api/learning/content/' + encodeURIComponent(slug)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading…</div>;
|
||||
if (error) return <div className="text-sm text-destructive">{(error as Error).message}</div>;
|
||||
if (!data) return null;
|
||||
const c = data.content;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button type="button" className={btnGhost} onClick={onBack} data-testid="btn-lh-back">
|
||||
← Back to Feed
|
||||
</button>
|
||||
|
||||
<section className={card} data-testid="lh-viewer">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold" data-testid="lh-viewer-title">{c.title}</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{typeBadge(c.content_type)}
|
||||
{c.category_name ? ' · ' + c.category_name : ''}
|
||||
{c.author_name ? ' · ' + c.author_name : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{c.content_type === 'presentation' ? (
|
||||
<SlideViewer slug={c.slug} title={c.title} />
|
||||
) : (
|
||||
<div
|
||||
className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none"
|
||||
data-testid="lh-viewer-body"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(c.body || '') }}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{c.progress && c.progress.length > 0 && (
|
||||
<section className={card}>
|
||||
<h3 className="text-base font-semibold">Your past attempts</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
{c.progress.map((p, i) => {
|
||||
const pct = p.total > 0 ? Math.round((p.score / p.total) * 100) : 0;
|
||||
const color = pct >= 70 ? 'text-green-600' : 'text-amber-600';
|
||||
return (
|
||||
<div key={i} className="flex justify-between border-b border-border py-1">
|
||||
<span>{new Date(p.completed_at).toLocaleDateString()}</span>
|
||||
<span className={'font-semibold ' + color}>
|
||||
{p.score}/{p.total} ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{c.questions && c.questions.length > 0 && <Quiz content={c} onReset={onBack} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page shell ───────────────────────────────────────────────
|
||||
export default function Learning() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [activeSlug, setActiveSlug] = useState<string | null>(null);
|
||||
|
||||
const { data: cats } = useQuery<LearningCategoriesOk>({
|
||||
queryKey: ['learning-categories'],
|
||||
queryFn: () => api.get<LearningCategoriesOk>('/api/learning/categories'),
|
||||
});
|
||||
|
||||
if (activeSlug) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<ContentViewer slug={activeSlug} onBack={() => setActiveSlug(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Learning Hub</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pediatric education, clinical pearls, and self-assessment quizzes.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className={card}>
|
||||
<input
|
||||
type="search"
|
||||
className={input}
|
||||
placeholder="Search topics, subjects…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
data-testid="lh-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2" data-testid="lh-categories">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter('')}
|
||||
className={
|
||||
pill +
|
||||
(filter === '' ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80')
|
||||
}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{cats?.categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setFilter(cat.slug)}
|
||||
className={
|
||||
pill +
|
||||
(filter === cat.slug
|
||||
? ' bg-primary text-primary-foreground border-primary'
|
||||
: ' bg-muted hover:bg-muted/80')
|
||||
}
|
||||
data-testid={'lh-cat-' + cat.slug}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Feed filter={filter} query={query.trim()} onOpen={(slug) => setActiveSlug(slug)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
471
client/src/pages/PeGuide.tsx
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
// ============================================================
|
||||
// PHYSICAL EXAM GUIDE — full React port.
|
||||
//
|
||||
// Renders:
|
||||
// • Age-group + system pills (6 × 4 = 24 combinations)
|
||||
// • System overview banner
|
||||
// • CV system extras: APTM legend, cardiac sounds, innocent murmurs
|
||||
// • Resp system extras: respiratory sounds library
|
||||
// • Collapsible grading-scales reference (system-scoped)
|
||||
// • Component checklist with per-step normal / abnormal / (unset)
|
||||
// toggle, abnormal-hints hint list, pearl + significance callouts
|
||||
// • Patient age / gender + model inputs
|
||||
// • Generate Exam Report → POST /api/generate-pe-narrative
|
||||
//
|
||||
// PE_DATA is the full hierarchy ported verbatim from vanilla
|
||||
// peGuide.js (see client/src/data/pe-data.ts). Clinical reference
|
||||
// libraries (scales, APTM, sound files) live in pe-guide.ts.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import type { PeNarrativeOk } from '@/shared/types';
|
||||
import {
|
||||
PE_DATA,
|
||||
AGE_GROUP_ORDER,
|
||||
SYSTEM_ORDER,
|
||||
SYSTEM_LABELS,
|
||||
type PeComponent,
|
||||
type PeStep,
|
||||
} from '@/data/pe-data';
|
||||
import {
|
||||
SCALES,
|
||||
SYSTEM_SCALES,
|
||||
APTM_LEGEND,
|
||||
INNOCENT_MURMURS,
|
||||
RESP_SOUNDS,
|
||||
CARDIAC_SOUNDS,
|
||||
type ScaleDef,
|
||||
type SoundEntry,
|
||||
} from '@/data/pe-guide';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const pill = 'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors cursor-pointer';
|
||||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50';
|
||||
const btnGhost = 'rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
|
||||
type StepStatus = 'normal' | 'abnormal' | null;
|
||||
|
||||
// Key used to identify a step in the status map across age-group / system.
|
||||
function stepKey(age: string, sys: string, componentIdx: number, stepIdx: number) {
|
||||
return `${age}/${sys}/${componentIdx}/${stepIdx}`;
|
||||
}
|
||||
|
||||
function ScaleCard({ id, scale }: { id: string; scale: ScaleDef }) {
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-background p-3" data-testid={'scale-' + id}>
|
||||
<h4 className="text-sm font-semibold mb-2">{scale.title}</h4>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{scale.rows.map(([labelText, desc], i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
<td className="py-1 pr-3 font-mono font-semibold whitespace-nowrap">{labelText}</td>
|
||||
<td className="py-1 text-muted-foreground">{desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SoundCard({ entry }: { entry: SoundEntry }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background p-3 space-y-2" data-testid={'sound-' + entry.key}>
|
||||
<div className="text-sm font-semibold">{entry.title}</div>
|
||||
<audio controls preload="none" className="w-full">
|
||||
<source src={entry.src} />
|
||||
</audio>
|
||||
<div className="text-xs space-y-0.5 text-muted-foreground">
|
||||
<div><span className="font-semibold">Where:</span> {entry.where}</div>
|
||||
{entry.rate && <div><span className="font-semibold">Rate:</span> {entry.rate}</div>}
|
||||
<div><span className="font-semibold">Features:</span> {entry.features}</div>
|
||||
<div><span className="font-semibold">Clinical:</span> {entry.clinical}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
step,
|
||||
status,
|
||||
onStatus,
|
||||
}: {
|
||||
step: PeStep;
|
||||
status: StepStatus;
|
||||
onStatus: (next: StepStatus) => void;
|
||||
}) {
|
||||
const base = 'text-xs font-medium px-2 py-1 rounded border';
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-2 border-b border-border last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{step.label}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
<span className="font-semibold uppercase tracking-wide">Method:</span> {step.method}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-semibold uppercase tracking-wide">Normal:</span> {step.normal}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatus(status === 'normal' ? null : 'normal')}
|
||||
className={
|
||||
base + ' ' +
|
||||
(status === 'normal'
|
||||
? 'bg-green-600 text-white border-green-600'
|
||||
: 'border-green-600 text-green-700 hover:bg-green-50 dark:hover:bg-green-950/30')
|
||||
}
|
||||
>
|
||||
Normal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatus(status === 'abnormal' ? null : 'abnormal')}
|
||||
className={
|
||||
base + ' ' +
|
||||
(status === 'abnormal'
|
||||
? 'bg-destructive text-white border-destructive'
|
||||
: 'border-destructive text-destructive hover:bg-red-50 dark:hover:bg-red-950/30')
|
||||
}
|
||||
>
|
||||
Abnormal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentCard({
|
||||
age,
|
||||
sys,
|
||||
idx,
|
||||
comp,
|
||||
getStatus,
|
||||
setStatus,
|
||||
}: {
|
||||
age: string;
|
||||
sys: string;
|
||||
idx: number;
|
||||
comp: PeComponent;
|
||||
getStatus: (k: string) => StepStatus;
|
||||
setStatus: (k: string, next: StepStatus) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={card} data-testid={`pe-component-${age}-${sys}-${idx}`}>
|
||||
<h3 className="text-base font-semibold">{comp.name}</h3>
|
||||
<div>
|
||||
{comp.steps.map((step, si) => {
|
||||
const k = stepKey(age, sys, idx, si);
|
||||
return (
|
||||
<StepRow
|
||||
key={si}
|
||||
step={step}
|
||||
status={getStatus(k)}
|
||||
onStatus={(next) => setStatus(k, next)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{comp.abnormalHints.length > 0 && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-destructive mb-1">Watch for</div>
|
||||
<ul className="list-disc pl-5 text-xs text-red-900 dark:text-red-200 space-y-0.5">
|
||||
{comp.abnormalHints.map((h, hi) => <li key={hi}>{h}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{comp.pearl && (
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-300 dark:border-amber-800 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||||
<span className="font-semibold uppercase tracking-wide">Pearl:</span> {comp.pearl}
|
||||
</div>
|
||||
)}
|
||||
{comp.significance && (
|
||||
<div className="rounded-md bg-sky-50 dark:bg-sky-950/30 border border-sky-200 dark:border-sky-900 p-3 text-xs text-sky-900 dark:text-sky-100">
|
||||
<span className="font-semibold uppercase tracking-wide">Significance:</span> {comp.significance}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PeGuide() {
|
||||
const [age, setAge] = useState<(typeof AGE_GROUP_ORDER)[number]>('toddler');
|
||||
const [sys, setSys] = useState<(typeof SYSTEM_ORDER)[number]>('msk');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [format, setFormat] = useState<'narrative' | 'list'>('narrative');
|
||||
const [statusMap, setStatusMap] = useState<Record<string, StepStatus>>({});
|
||||
const [narrative, setNarrative] = useState<string | null>(null);
|
||||
|
||||
const group = PE_DATA[age];
|
||||
const section = group[sys];
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: (body: unknown) => api.post<PeNarrativeOk>('/api/generate-pe-narrative', body),
|
||||
onSuccess: (data) => setNarrative(data.narrative),
|
||||
onError: (e: Error) => setNarrative('Generation failed: ' + e.message),
|
||||
});
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let normal = 0, abnormal = 0, notAssessed = 0;
|
||||
section.components.forEach((c, ci) =>
|
||||
c.steps.forEach((_, si) => {
|
||||
const k = stepKey(age, sys, ci, si);
|
||||
const v = statusMap[k] ?? null;
|
||||
if (v === 'normal') normal++;
|
||||
else if (v === 'abnormal') abnormal++;
|
||||
else notAssessed++;
|
||||
}),
|
||||
);
|
||||
return { normal, abnormal, notAssessed };
|
||||
}, [age, sys, section, statusMap]);
|
||||
|
||||
function reset() {
|
||||
// Only clear the current system's entries, not all state.
|
||||
setStatusMap((prev) => {
|
||||
const next = { ...prev };
|
||||
section.components.forEach((c, ci) =>
|
||||
c.steps.forEach((_, si) => { delete next[stepKey(age, sys, ci, si)]; }),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
setNarrative(null);
|
||||
}
|
||||
|
||||
function setAllNormal() {
|
||||
setStatusMap((prev) => {
|
||||
const next = { ...prev };
|
||||
section.components.forEach((c, ci) =>
|
||||
c.steps.forEach((_, si) => { next[stepKey(age, sys, ci, si)] = 'normal'; }),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function onGenerate() {
|
||||
setNarrative(null);
|
||||
const steps: Array<{ component: string; label: string; method: string; normal: string; status: StepStatus; note?: string }> = [];
|
||||
section.components.forEach((c, ci) =>
|
||||
c.steps.forEach((st, si) => {
|
||||
steps.push({
|
||||
component: c.name,
|
||||
label: st.label,
|
||||
method: st.method,
|
||||
normal: st.normal,
|
||||
status: statusMap[stepKey(age, sys, ci, si)] ?? null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
generate.mutate({
|
||||
steps,
|
||||
ageGroup: age,
|
||||
system: sys,
|
||||
patientAge: patientAge || undefined,
|
||||
patientGender: patientGender || undefined,
|
||||
format,
|
||||
});
|
||||
}
|
||||
|
||||
const totalAssessed = summary.normal + summary.abnormal;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-5">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Physical Exam Guide</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Age-group and system-specific exam checklist with abnormal-finding hints. Toggle normal / abnormal
|
||||
on each step, then generate a narrative for your note.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Age-group pills */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Age group</div>
|
||||
<div className="flex flex-wrap gap-2" data-testid="pe-age-group-pills">
|
||||
{AGE_GROUP_ORDER.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => setAge(g)}
|
||||
className={pill + (age === g ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80 border-border')}
|
||||
data-testid={'pe-age-' + g}
|
||||
>
|
||||
{PE_DATA[g].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System pills */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">System</div>
|
||||
<div className="flex flex-wrap gap-2" data-testid="pe-system-pills">
|
||||
{SYSTEM_ORDER.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSys(s)}
|
||||
className={pill + (sys === s ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80 border-border')}
|
||||
data-testid={'pe-system-' + s}
|
||||
>
|
||||
{SYSTEM_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
<section className={card + ' border-l-4 border-l-primary'} data-testid="pe-overview">
|
||||
<h2 className="text-lg font-semibold">{group.label} — {SYSTEM_LABELS[sys]}</h2>
|
||||
<p className="text-sm text-muted-foreground">{section.overview}</p>
|
||||
</section>
|
||||
|
||||
{/* System-specific references */}
|
||||
{sys === 'cv' && (
|
||||
<section className={card} data-testid="pe-cv-aptm">
|
||||
<h3 className="text-base font-semibold">Auscultation landmarks (APTM + Erb's)</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{APTM_LEGEND.map((p) => (
|
||||
<div key={p.letter} className="flex gap-3 items-start rounded-md border border-border p-3">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white flex-shrink-0"
|
||||
style={{ background: p.color }}
|
||||
>
|
||||
{p.letter}
|
||||
</div>
|
||||
<div className="min-w-0 text-sm">
|
||||
<div className="font-semibold">{p.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.location}</div>
|
||||
<div className="text-xs mt-1"><strong>Listen for:</strong> {p.listen}</div>
|
||||
{p.innocent && <div className="text-xs text-green-700 dark:text-green-300 mt-1"><em>Innocent:</em> {p.innocent}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold mt-3">Cardiac sounds library</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{CARDIAC_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold mt-3">Classic innocent murmurs</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{INNOCENT_MURMURS.map((m) => (
|
||||
<div key={m.name} className="rounded-md border border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30 p-3 text-sm space-y-1">
|
||||
<div className="font-semibold">{m.name}</div>
|
||||
<div className="text-xs text-muted-foreground">Age: {m.age} · Location: {m.location}</div>
|
||||
<div className="text-xs"><strong>Sound:</strong> {m.character}</div>
|
||||
<div className="text-xs"><strong>Confirm innocent:</strong> {m.confirm}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{sys === 'resp' && (
|
||||
<section className={card} data-testid="pe-resp-sounds">
|
||||
<h3 className="text-base font-semibold">Respiratory sounds library</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{RESP_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Grading scales (system-scoped, collapsible) */}
|
||||
{SYSTEM_SCALES[sys] && SYSTEM_SCALES[sys].length > 0 && (
|
||||
<details className={card} data-testid="pe-scales">
|
||||
<summary className="cursor-pointer font-semibold text-sm">Grading scales & reference</summary>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
{SYSTEM_SCALES[sys].map((sk: string) => {
|
||||
const scale = SCALES[sk];
|
||||
if (!scale) return null;
|
||||
return <ScaleCard key={sk} id={sk} scale={scale} />;
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Checklist */}
|
||||
<section className="space-y-3" data-testid="pe-checklist">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold">Exam checklist</h2>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-3">
|
||||
<span className="text-green-600">{summary.normal} normal</span>
|
||||
<span className="text-destructive">{summary.abnormal} abnormal</span>
|
||||
<span>{summary.notAssessed} not assessed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={setAllNormal} className={btnGhost} data-testid="btn-pe-all-normal">
|
||||
Mark all normal
|
||||
</button>
|
||||
<button type="button" onClick={reset} className={btnGhost} data-testid="btn-pe-reset">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{section.components.map((c, ci) => (
|
||||
<ComponentCard
|
||||
key={ci}
|
||||
age={age}
|
||||
sys={sys}
|
||||
idx={ci}
|
||||
comp={c}
|
||||
getStatus={(k) => statusMap[k] ?? null}
|
||||
setStatus={(k, next) => setStatusMap((prev) => ({ ...prev, [k]: next }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Generate narrative */}
|
||||
<section className={card} data-testid="pe-generate">
|
||||
<h2 className="text-lg font-semibold">Generate Exam Report</h2>
|
||||
<p className="text-sm text-muted-foreground">Uses the statuses above + optional patient context.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<input
|
||||
className={input}
|
||||
placeholder="Patient age (e.g. 3y)"
|
||||
value={patientAge}
|
||||
onChange={(e) => setPatientAge(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={input}
|
||||
placeholder="Patient gender (optional)"
|
||||
value={patientGender}
|
||||
onChange={(e) => setPatientGender(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className={input}
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as 'narrative' | 'list')}
|
||||
>
|
||||
<option value="narrative">Narrative</option>
|
||||
<option value="list">List</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={btnPrimary}
|
||||
onClick={onGenerate}
|
||||
disabled={generate.isPending || totalAssessed === 0}
|
||||
data-testid="btn-pe-generate"
|
||||
>
|
||||
{generate.isPending ? 'Generating…' : 'Generate Exam Report'}
|
||||
</button>
|
||||
{totalAssessed === 0 && (
|
||||
<span className="text-xs text-muted-foreground">Mark at least one step before generating.</span>
|
||||
)}
|
||||
</div>
|
||||
{narrative && (
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3 whitespace-pre-wrap text-sm" data-testid="pe-narrative">
|
||||
{narrative}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
client/src/pages/ResetPassword.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// ============================================================
|
||||
// RESET PASSWORD — landing for the email link (?token=xxx).
|
||||
// POSTs /api/auth/reset-password with {token, newPassword}.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
|
||||
const card = 'rounded-2xl border border-border bg-card p-6 shadow-lg space-y-4 w-full max-w-md';
|
||||
const btnPrimary = 'w-full rounded-md bg-primary text-primary-foreground px-4 py-3 text-sm font-semibold disabled:opacity-60';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
const label = 'block text-xs font-semibold text-muted-foreground mb-1';
|
||||
const msgOk = 'rounded-md bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900 p-3 text-sm text-green-800 dark:text-green-100';
|
||||
const msgErr = 'rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 p-3 text-sm text-red-800 dark:text-red-100';
|
||||
const msgInfo = 'rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 p-3 text-sm text-amber-900 dark:text-amber-100';
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [params] = useSearchParams();
|
||||
const nav = useNavigate();
|
||||
const token = params.get('token') || '';
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [ok, setOk] = useState('');
|
||||
const [warn, setWarn] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr(''); setOk(''); setWarn('');
|
||||
if (!token) { setErr('Missing reset token. Open the reset link from your email.'); return; }
|
||||
if (newPassword.length < 8) { setErr('Password must be 8+ characters'); return; }
|
||||
if (newPassword !== confirm) { setErr('Passwords do not match'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.post<{ passwordWarning?: string }>('/api/auth/reset-password', { token, newPassword });
|
||||
setOk('Password reset. You can now sign in.');
|
||||
if (r.passwordWarning) setWarn(r.passwordWarning);
|
||||
setTimeout(() => nav('/auth', { replace: true }), 2500);
|
||||
} catch (e) {
|
||||
setErr((e as ApiError).message || 'Reset failed. The link may have expired.');
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900 flex items-center justify-center p-4">
|
||||
<div className={card}>
|
||||
<header className="text-center space-y-1">
|
||||
<div className="text-4xl">🔑</div>
|
||||
<h1 className="text-xl font-bold">Set a new password</h1>
|
||||
<p className="text-xs text-muted-foreground">Choose a password at least 8 characters long.</p>
|
||||
</header>
|
||||
{err && <div className={msgErr}>{err}</div>}
|
||||
{ok && <div className={msgOk}>{ok}</div>}
|
||||
{warn && <div className={msgInfo}>{warn}</div>}
|
||||
<form onSubmit={submit} className="space-y-3" data-testid="reset-password-form">
|
||||
<div><label className={label}>New password</label><input type="password" required minLength={8} className={input} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoFocus data-testid="reset-new" /></div>
|
||||
<div><label className={label}>Confirm new password</label><input type="password" required minLength={8} className={input} value={confirm} onChange={(e) => setConfirm(e.target.value)} data-testid="reset-confirm" /></div>
|
||||
<button type="submit" className={btnPrimary} disabled={busy || !token} data-testid="reset-submit">
|
||||
{busy ? 'Resetting…' : 'Reset password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1789
client/src/pages/Settings.tsx
Normal file
224
client/src/pages/SickVisit.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// ============================================================
|
||||
// SICK VISIT — /api/sick-visit/note
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import type { VisitNoteOk } from '@/shared/types';
|
||||
import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
import EditableResult from '@/components/EditableResult';
|
||||
import RosPeTable, { rosAllWnl, rosClear } from '@/components/RosPeTable';
|
||||
import DxPicker from '@/components/DxPicker';
|
||||
import {
|
||||
ROS_SYSTEMS,
|
||||
PE_SYSTEMS,
|
||||
formatRosForAI,
|
||||
formatDxForAI,
|
||||
type RosData,
|
||||
type DxEntry,
|
||||
} from '@shared/clinical/ros-pe-dx';
|
||||
|
||||
const TYPE = 'sickvisit' as const;
|
||||
|
||||
export default function SickVisit() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [chiefComplaint, setChiefComplaint] = useState('');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [interim, setInterim] = useState('');
|
||||
const [rosData, setRosData] = useState<RosData>({});
|
||||
const [peData, setPeData] = useState<RosData>({});
|
||||
const [diagnoses, setDiagnoses] = useState<DxEntry[]>([]);
|
||||
const [dxFreetext, setDxFreetext] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [recError, setRecError] = useState<string | null>(null);
|
||||
|
||||
const generate = useMutation<VisitNoteOk, Error, SickVisitRequest>({
|
||||
mutationFn: (body) => api.post<VisitNoteOk>('/api/sick-visit/note', body),
|
||||
onSuccess: (data) => setResult(data.note),
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setValidationError(null);
|
||||
const rosText = formatRosForAI(ROS_SYSTEMS, rosData, 'Review of Systems');
|
||||
const peText = formatRosForAI(PE_SYSTEMS, peData, 'Physical Examination');
|
||||
const dxText = formatDxForAI(diagnoses, dxFreetext);
|
||||
const body: SickVisitRequest = {
|
||||
patientAge, patientGender, chiefComplaint,
|
||||
transcript: (interim || transcript).trim(),
|
||||
ros: rosText || undefined,
|
||||
physicalExam: peText || undefined,
|
||||
diagnoses: dxText || undefined,
|
||||
};
|
||||
const parsed = SickVisitRequestSchema.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';
|
||||
const displayedTranscript = interim || transcript;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Sick Visit</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chief complaint + transcript → structured sick-visit note.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={transcript} generatedNote={result || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, chiefComplaint, rosData, peData, diagnoses, dxFreetext }}
|
||||
onLoad={(enc) => {
|
||||
setTranscript(enc.transcript || '');
|
||||
setInterim('');
|
||||
setResult(enc.generated_note || null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.chiefComplaint) setChiefComplaint(pd.chiefComplaint);
|
||||
if (pd?.rosData) setRosData(pd.rosData);
|
||||
if (pd?.peData) setPeData(pd.peData);
|
||||
if (pd?.diagnoses) setDiagnoses(pd.diagnoses);
|
||||
if (pd?.dxFreetext) setDxFreetext(pd.dxFreetext);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => {
|
||||
setTranscript(''); setInterim(''); setResult(null); setValidationError(null);
|
||||
setPatientAge(''); setPatientGender(''); setChiefComplaint('');
|
||||
setRosData({}); setPeData({}); setDiagnoses([]); setDxFreetext('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<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. 4 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 col-span-3 md:col-span-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Chief complaint</span>
|
||||
<input className={input} placeholder="e.g. Fever x 2 days" value={chiefComplaint} onChange={(e) => setChiefComplaint(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Recorder
|
||||
module="sickvisit"
|
||||
onTranscript={(text, meta) => {
|
||||
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
|
||||
setInterim('');
|
||||
setRecError(null);
|
||||
}}
|
||||
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
|
||||
onError={(msg) => setRecError(msg)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</div>}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript / dictation</span>
|
||||
<textarea
|
||||
className={input + ' min-h-[200px] font-mono text-sm'}
|
||||
placeholder="Click Start recording, or type / paste encounter narrative."
|
||||
value={displayedTranscript}
|
||||
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">Review of Systems</span>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => setRosData(rosAllWnl(ROS_SYSTEMS, rosData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">✓ All WNL</button>
|
||||
<button type="button" onClick={() => setRosData(rosClear(rosData, ROS_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<RosPeTable
|
||||
systems={ROS_SYSTEMS}
|
||||
data={rosData}
|
||||
onChange={setRosData}
|
||||
btnLabels={{ wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' }}
|
||||
testIdPrefix="sv-ros"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">Physical Examination</span>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => setPeData(rosAllWnl(PE_SYSTEMS, peData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">✓ All Normal</button>
|
||||
<button type="button" onClick={() => setPeData(rosClear(peData, PE_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<RosPeTable
|
||||
systems={PE_SYSTEMS}
|
||||
data={peData}
|
||||
onChange={setPeData}
|
||||
btnLabels={{ wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' }}
|
||||
testIdPrefix="sv-pe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">Diagnoses (ICD-10)</span>
|
||||
<DxPicker value={diagnoses} onChange={setDiagnoses} testIdPrefix="sv-dx" />
|
||||
<label className="block">
|
||||
<span className="text-[11px] text-muted-foreground">Additional free-text diagnosis / note (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={dxFreetext}
|
||||
onChange={(e) => setDxFreetext(e.target.value)}
|
||||
className={input + ' text-sm'}
|
||||
placeholder="e.g. Follow up in 48 hours if not improving"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{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 || !chiefComplaint.trim()}
|
||||
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{generate.isPending ? 'Generating…' : 'Generate Note'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result !== null && (
|
||||
<EditableResult
|
||||
text={result}
|
||||
onChange={setResult}
|
||||
section="sickvisit"
|
||||
title="Sick Visit Note"
|
||||
exportLabel="sick-visit-note"
|
||||
exportType="sick-visit"
|
||||
sourceContext={displayedTranscript}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
client/src/pages/Soap.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// ============================================================
|
||||
// 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';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
import EditableResult from '@/components/EditableResult';
|
||||
|
||||
type SoapType = 'full' | 'subjective';
|
||||
const TYPE = 'soap' as const;
|
||||
|
||||
export default function Soap() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [type, setType] = useState<SoapType>('full');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [interim, setInterim] = useState('');
|
||||
const [additionalInstructions, setAdditionalInstructions] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [recError, setRecError] = 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: (interim || transcript).trim(), 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';
|
||||
const displayedTranscript = interim || transcript;
|
||||
|
||||
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>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={transcript} generatedNote={result || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, type, additionalInstructions }}
|
||||
onLoad={(enc) => {
|
||||
setTranscript(enc.transcript || '');
|
||||
setInterim('');
|
||||
setResult(enc.generated_note || null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.type) setType(pd.type);
|
||||
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => {
|
||||
setTranscript(''); setInterim(''); setResult(null); setValidationError(null);
|
||||
setPatientAge(''); setPatientGender(''); setType('full'); setAdditionalInstructions('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<Recorder
|
||||
module="soap"
|
||||
onTranscript={(text, meta) => {
|
||||
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
|
||||
setInterim('');
|
||||
setRecError(null);
|
||||
}}
|
||||
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
|
||||
onError={(msg) => setRecError(msg)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</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="Click Start recording, or type / paste the encounter transcript."
|
||||
value={displayedTranscript}
|
||||
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
|
||||
/>
|
||||
</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 || !displayedTranscript.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 !== null && (
|
||||
<EditableResult
|
||||
text={result}
|
||||
onChange={setResult}
|
||||
section="soap"
|
||||
title="Generated SOAP"
|
||||
exportLabel="soap-note"
|
||||
exportType="soap"
|
||||
sourceContext={displayedTranscript}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
client/src/pages/VaxSchedule.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// ============================================================
|
||||
// VACCINE SCHEDULE — full AAP/ACIP table, sourced live from
|
||||
// GET /api/schedule-data.
|
||||
// ============================================================
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface VisitAge { id: string; label: string; era: string }
|
||||
interface VaccineDose { vaccine: string; dose?: number | string; notes?: string }
|
||||
interface ScheduleData {
|
||||
visitAges: VisitAge[];
|
||||
periodicity: Record<string, { vaccines?: VaccineDose[] }>;
|
||||
vaccineFullNames: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function VaxSchedule() {
|
||||
const { data, isLoading, error } = useQuery<ScheduleData>({
|
||||
queryKey: ['schedule-data'],
|
||||
queryFn: () => api.get<ScheduleData>('/api/schedule-data'),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-6 text-sm text-muted-foreground">Loading schedule…</div>;
|
||||
if (error) return <div className="p-6 text-sm text-destructive">{(error as Error).message}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
const visitsWithVax = data.visitAges.filter((v) => data.periodicity[v.id]?.vaccines?.length);
|
||||
|
||||
const vaxKeys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
visitsWithVax.forEach((v) => {
|
||||
data.periodicity[v.id].vaccines!.forEach((dose) => {
|
||||
if (!seen.has(dose.vaccine)) { seen.add(dose.vaccine); vaxKeys.push(dose.vaccine); }
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-full mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Vaccine Schedule</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AAP/ACIP 2025 complete immunization schedule (0–18 years).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-auto bg-card">
|
||||
<table className="text-xs">
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
<th className="text-left font-semibold px-3 py-2 border-b border-border min-w-[180px] sticky left-0 bg-muted">
|
||||
Vaccine
|
||||
</th>
|
||||
{visitsWithVax.map((v) => (
|
||||
<th key={v.id} className="px-2 py-2 border-b border-border text-center whitespace-nowrap">
|
||||
{v.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vaxKeys.map((key) => (
|
||||
<tr key={key} className="even:bg-muted/20">
|
||||
<td className="px-3 py-2 border-b border-border font-medium sticky left-0 bg-card">
|
||||
{data.vaccineFullNames[key] || key}
|
||||
</td>
|
||||
{visitsWithVax.map((v) => {
|
||||
const vaxList = data.periodicity[v.id].vaccines || [];
|
||||
const match = vaxList.find((d) => d.vaccine === key);
|
||||
if (!match) return <td key={v.id} className="border-b border-border" />;
|
||||
const label = typeof match.dose === 'number' ? '#' + match.dose : (match.dose || '•');
|
||||
return (
|
||||
<td
|
||||
key={v.id}
|
||||
className="border-b border-border text-center bg-primary/10 font-mono text-[11px]"
|
||||
title={match.notes || `${key} dose ${match.dose}`}
|
||||
>
|
||||
{label}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hover any filled cell for notes. Sources: AAP/Bright Futures (Feb 2025), CDC Child & Adolescent Immunization Schedule (2025).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
client/src/pages/WellVisit.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// ============================================================
|
||||
// WELL VISIT — sub-tab shell. Mirrors public/components/wellvisit.html:
|
||||
// • By Visit Age — AAP Bright Futures recs per visit
|
||||
// • Milestones — developmental checklist → AI narrative
|
||||
// • SSHADESS — psychosocial screening (12+ only)
|
||||
// • Visit Note — final preventive-care note generator
|
||||
//
|
||||
// SSHADESS pill is hidden for under-12 visits to match vanilla.
|
||||
// Each panel lazy-loads so the initial WellVisit bundle stays small.
|
||||
// ============================================================
|
||||
|
||||
import { Suspense, lazy, useState } from 'react';
|
||||
|
||||
const ByVisitAge = lazy(() => import('./wellvisit/ByVisitAge'));
|
||||
const Milestones = lazy(() => import('./wellvisit/Milestones'));
|
||||
const Shadess = lazy(() => import('./wellvisit/Shadess'));
|
||||
const VisitNote = lazy(() => import('./wellvisit/VisitNote'));
|
||||
|
||||
type SubTab = 'byvisit' | 'milestones' | 'shadess' | 'note';
|
||||
|
||||
const TABS: { id: SubTab; icon: string; label: string }[] = [
|
||||
{ id: 'byvisit', icon: '👶', label: 'By Visit Age' },
|
||||
{ id: 'milestones', icon: '🍼', label: 'Milestones' },
|
||||
{ id: 'shadess', icon: '🧠', label: 'SSHADESS (12+)' },
|
||||
{ id: 'note', icon: '📄', label: 'Visit Note' },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'ped_wellvisit_subtab';
|
||||
|
||||
function loadSubTab(): SubTab {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v === 'byvisit' || v === 'milestones' || v === 'shadess' || v === 'note') return v;
|
||||
} catch { /* ignore */ }
|
||||
return 'byvisit';
|
||||
}
|
||||
|
||||
export default function WellVisit() {
|
||||
const [active, setActive] = useState<SubTab>(loadSubTab);
|
||||
|
||||
function pick(tab: SubTab) {
|
||||
setActive(tab);
|
||||
try { localStorage.setItem(STORAGE_KEY, tab); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Well Visit / Preventive Care</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AAP 2025 Bright Futures periodicity — vaccines, screenings, billing codes, milestones, SSHADESS, and the encounter note.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2" data-testid="wellvisit-subnav">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => pick(t.id)}
|
||||
className={
|
||||
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
|
||||
(active === t.id
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted hover:bg-muted/80 border-border')
|
||||
}
|
||||
data-testid={'wellvisit-pill-' + t.id}
|
||||
>
|
||||
<span className="mr-1" aria-hidden>{t.icon}</span>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading…</div>}>
|
||||
{active === 'byvisit' && <ByVisitAge />}
|
||||
{active === 'milestones' && <Milestones />}
|
||||
{active === 'shadess' && <Shadess />}
|
||||
{active === 'note' && <VisitNote />}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
client/src/pages/cms/AiGenerator.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
// ============================================================
|
||||
// AiGenerator — generate Learning Hub content via AI from
|
||||
// 1. a topic description
|
||||
// 2. one or more uploaded files (PDF, DOCX, TXT, …)
|
||||
// 3. a Nextcloud/WebDAV file the user picks from a browser
|
||||
//
|
||||
// Faithful port of public/js/learningHub.js (@be14578) functions
|
||||
// openAiPanel / updateAiOptions / runAiGenerate / applyAiContent /
|
||||
// browseWebdav. Posts to the existing /api/admin/learning/
|
||||
// ai-generate endpoint as multipart/form-data; the response payload
|
||||
// (title, subject, body, questions) is handed back to the parent
|
||||
// via `onGenerated` so the Content Editor can apply it.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import type { CmsQuestion, ContentType } from './cms-types';
|
||||
|
||||
interface Props {
|
||||
contentType: ContentType;
|
||||
onChangeType: (t: ContentType) => void;
|
||||
onGenerated: (payload: GeneratedPayload, contentType: ContentType) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export interface GeneratedPayload {
|
||||
title?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
marpMarkdown?: string;
|
||||
questions?: CmsQuestion[];
|
||||
}
|
||||
|
||||
interface MeOk { success: true; user: { nextcloud_url?: string | null } }
|
||||
interface WebdavItem { path: string; name: string; isDir: boolean; contentType?: string; size?: number }
|
||||
interface WebdavOk { success: true; path: string; parentPath: string; items: WebdavItem[] }
|
||||
|
||||
const input = 'rounded-md border border-input bg-background px-2 py-1 text-sm';
|
||||
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
|
||||
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
|
||||
|
||||
type Tab = 'topic' | 'upload' | 'webdav';
|
||||
|
||||
export default function AiGenerator(props: Props) {
|
||||
const [tab, setTab] = useState<Tab>('topic');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [uploadCtx, setUploadCtx] = useState('');
|
||||
const [webdavCtx, setWebdavCtx] = useState('');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [refinement, setRefinement] = useState('');
|
||||
const [wordCount, setWordCount] = useState('');
|
||||
const [slideCount, setSlideCount] = useState('');
|
||||
const [genQuestions, setGenQuestions] = useState(false);
|
||||
const [qCount, setQCount] = useState(5);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const me = useQuery<MeOk>({
|
||||
queryKey: ['auth-me'],
|
||||
queryFn: () => api.get<MeOk>('/api/auth/me'),
|
||||
});
|
||||
const webdavConnected = !!me.data?.user?.nextcloud_url;
|
||||
|
||||
const showWords = props.contentType !== 'quiz' && props.contentType !== 'presentation';
|
||||
const showSlides = props.contentType === 'presentation';
|
||||
const quizAlwaysOn = props.contentType === 'quiz';
|
||||
const showQCount = quizAlwaysOn || genQuestions;
|
||||
|
||||
useEffect(() => {
|
||||
if (quizAlwaysOn) setGenQuestions(true);
|
||||
}, [quizAlwaysOn]);
|
||||
|
||||
async function run() {
|
||||
setErr(null);
|
||||
const fd = new FormData();
|
||||
fd.append('contentType', props.contentType);
|
||||
fd.append('questionCount', String(showQCount ? qCount : 0));
|
||||
if (refinement.trim()) fd.append('refinement', refinement.trim());
|
||||
if (showWords && wordCount) fd.append('wordCount', wordCount);
|
||||
if (showSlides && slideCount) fd.append('slideCount', slideCount);
|
||||
|
||||
if (tab === 'topic') {
|
||||
if (!topic.trim()) { setErr('Describe what you want AI to create'); return; }
|
||||
fd.append('topic', topic.trim());
|
||||
} else if (tab === 'upload') {
|
||||
if (files.length === 0) { setErr('Select at least one file'); return; }
|
||||
for (const f of files) fd.append('files', f);
|
||||
if (uploadCtx.trim()) fd.append('topic', uploadCtx.trim());
|
||||
} else {
|
||||
// webdav
|
||||
if (!selectedPath) { setErr('Select a file from Nextcloud'); return; }
|
||||
fd.append('webdavPath', selectedPath);
|
||||
if (webdavCtx.trim()) fd.append('topic', webdavCtx.trim());
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await fetch('/api/admin/learning/ai-generate', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!data.success) throw new Error(data.error || 'Generation failed');
|
||||
// Presentation returns marpMarkdown + optional questions directly;
|
||||
// articles/pearls/quizzes return everything nested under `content`.
|
||||
const payload: GeneratedPayload = data.contentType === 'presentation'
|
||||
? { marpMarkdown: data.marpMarkdown, questions: data.questions }
|
||||
: (data.content || {});
|
||||
props.onGenerated(payload, props.contentType);
|
||||
} catch (e) {
|
||||
setErr((e as ApiError).message || 'Generation failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// WebDAV browser state (only used in the webdav tab)
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [webdavData, setWebdavData] = useState<WebdavOk | null>(null);
|
||||
const [webdavErr, setWebdavErr] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState('');
|
||||
const [selectedName, setSelectedName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'webdav' || !webdavConnected) return;
|
||||
setWebdavErr(null);
|
||||
api.get<WebdavOk>('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(currentPath))
|
||||
.then(setWebdavData)
|
||||
.catch((e) => setWebdavErr((e as ApiError).message || 'WebDAV browse failed'));
|
||||
}, [tab, currentPath, webdavConnected]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 space-y-3" data-testid="cms-ai-panel">
|
||||
<div className="flex items-center gap-2">
|
||||
<strong className="text-sm">✨ Generate with AI</strong>
|
||||
<button type="button" onClick={props.onCancel} className={btn + ' ml-auto'}>✕ Close</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Type</span>
|
||||
<select
|
||||
className={input + ' w-full'}
|
||||
value={props.contentType}
|
||||
onChange={(e) => props.onChangeType(e.target.value as ContentType)}
|
||||
data-testid="cms-ai-ctype"
|
||||
>
|
||||
<option value="article">Article</option>
|
||||
<option value="pearl">Pearl</option>
|
||||
<option value="quiz">Quiz</option>
|
||||
<option value="presentation">Presentation</option>
|
||||
</select>
|
||||
</label>
|
||||
{showWords && (
|
||||
<label className="block">
|
||||
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Target word count (optional)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
value={wordCount}
|
||||
onChange={(e) => setWordCount(e.target.value)}
|
||||
className={input + ' w-full'}
|
||||
placeholder="e.g. 800"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{showSlides && (
|
||||
<label className="block">
|
||||
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Slide count</span>
|
||||
<input
|
||||
type="number"
|
||||
min={3}
|
||||
max={40}
|
||||
value={slideCount}
|
||||
onChange={(e) => setSlideCount(e.target.value)}
|
||||
className={input + ' w-full'}
|
||||
placeholder="e.g. 10"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!quizAlwaysOn && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={genQuestions}
|
||||
onChange={(e) => setGenQuestions(e.target.checked)}
|
||||
/>
|
||||
Also generate quiz questions
|
||||
</label>
|
||||
)}
|
||||
{showQCount && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span>Question count:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={qCount}
|
||||
onChange={(e) => setQCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||
className={input + ' w-20'}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
<button type="button" onClick={() => setTab('topic')}
|
||||
className={'px-3 py-1.5 text-xs border-b-2 -mb-px ' + (tab === 'topic' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:bg-muted')}>
|
||||
Topic
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab('upload')}
|
||||
className={'px-3 py-1.5 text-xs border-b-2 -mb-px ' + (tab === 'upload' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:bg-muted')}>
|
||||
Upload files
|
||||
</button>
|
||||
{webdavConnected && (
|
||||
<button type="button" onClick={() => setTab('webdav')}
|
||||
className={'px-3 py-1.5 text-xs border-b-2 -mb-px ' + (tab === 'webdav' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:bg-muted')}>
|
||||
Nextcloud
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === 'topic' && (
|
||||
<textarea
|
||||
className={input + ' w-full min-h-[100px] font-mono text-sm'}
|
||||
placeholder="Describe what you want AI to create (e.g. 'Bronchiolitis management for attending teaching rounds, high school education level')"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
data-testid="cms-ai-topic"
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'upload' && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.docx,.odt,.rtf,.txt,.md,.html,.htm,.pptx,.epub,image/*"
|
||||
onChange={(e) => setFiles(Array.from(e.target.files || []))}
|
||||
className="text-xs"
|
||||
data-testid="cms-ai-files"
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<ul className="text-xs text-muted-foreground">
|
||||
{files.map((f, i) => <li key={i}>📄 {f.name} <span className="opacity-60">({Math.round(f.size / 1024)} KB)</span></li>)}
|
||||
</ul>
|
||||
)}
|
||||
<textarea
|
||||
className={input + ' w-full min-h-[60px] text-sm'}
|
||||
placeholder="Optional: extra context / framing for the AI"
|
||||
value={uploadCtx}
|
||||
onChange={(e) => setUploadCtx(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'webdav' && (
|
||||
<div className="space-y-2">
|
||||
{webdavErr && <div className="text-sm text-destructive">{webdavErr}</div>}
|
||||
{webdavData && (
|
||||
<div className="rounded-md border border-border bg-background">
|
||||
<div className="px-2 py-1 flex items-center gap-2 text-xs border-b border-border bg-muted/40">
|
||||
<span className="font-mono text-muted-foreground">{webdavData.path}</span>
|
||||
{currentPath !== '/' && (
|
||||
<button type="button" onClick={() => setCurrentPath(webdavData.parentPath)} className={btn + ' ml-auto'}>⬆ Up</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{webdavData.items.length === 0 && <div className="px-2 py-3 text-xs text-muted-foreground italic">Empty folder.</div>}
|
||||
{webdavData.items.map((it) => (
|
||||
<button
|
||||
key={it.path}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (it.isDir) { setCurrentPath(it.path); setSelectedPath(''); setSelectedName(''); }
|
||||
else { setSelectedPath(it.path); setSelectedName(it.name); }
|
||||
}}
|
||||
className={
|
||||
'w-full text-left px-2 py-1.5 text-sm flex items-center gap-2 border-b border-border last:border-0 hover:bg-muted ' +
|
||||
(selectedPath === it.path ? 'bg-primary/10' : '')
|
||||
}
|
||||
>
|
||||
<span>{it.isDir ? '📁' : '📄'}</span>
|
||||
<span className="truncate">{it.name}</span>
|
||||
{!it.isDir && typeof it.size === 'number' && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">{Math.round((it.size || 0) / 1024)} KB</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedPath && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Selected: <span className="font-semibold">{selectedName}</span>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className={input + ' w-full min-h-[60px] text-sm'}
|
||||
placeholder="Optional: extra context / framing for the AI"
|
||||
value={webdavCtx}
|
||||
onChange={(e) => setWebdavCtx(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Refinement (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={refinement}
|
||||
onChange={(e) => setRefinement(e.target.value)}
|
||||
className={input + ' w-full'}
|
||||
placeholder="e.g. 'Keep it under 500 words', 'Focus on outpatient management'"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{err && <div className="text-sm text-destructive">{err}</div>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={run} disabled={busy} className={btnPrimary} data-testid="cms-ai-run">
|
||||
{busy ? '⌛ Generating…' : '✨ Generate content'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
client/src/pages/cms/CategoriesPanel.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// CMS categories sidebar — list, add, delete, plus the status +
|
||||
// category filters that drive the content list. Mirrors the vanilla
|
||||
// cms-sidebar / cms-add-cat / cms-filter-status / cms-filter-category
|
||||
// from public/components/cms.html.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
import type { CmsCategory } from './cms-types';
|
||||
|
||||
interface CategoriesOk { success: true; categories: CmsCategory[] }
|
||||
|
||||
interface Props {
|
||||
statusFilter: 'all' | 'published' | 'draft';
|
||||
onStatusFilter: (s: 'all' | 'published' | 'draft') => void;
|
||||
categoryFilter: number | 'all';
|
||||
onCategoryFilter: (id: number | 'all') => void;
|
||||
}
|
||||
|
||||
const sm = 'rounded-md border border-input bg-background px-2 py-1 text-xs';
|
||||
|
||||
export default function CategoriesPanel(props: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<CmsCategory | null>(null);
|
||||
|
||||
const { data } = useQuery<CategoriesOk>({
|
||||
queryKey: ['cms-categories'],
|
||||
queryFn: () => api.get<CategoriesOk>('/api/learning-admin/categories'),
|
||||
});
|
||||
|
||||
const addCat = useMutation<{ success: true; id: number }, Error, string>({
|
||||
mutationFn: (n) => api.post('/api/learning-admin/categories', { name: n }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['cms-categories'] });
|
||||
qc.invalidateQueries({ queryKey: ['cms-stats'] });
|
||||
setName('');
|
||||
},
|
||||
onError: (e) => setErr((e as ApiError).message || 'Failed'),
|
||||
});
|
||||
|
||||
const delCat = useMutation<{ success: true }, Error, number>({
|
||||
mutationFn: (id) => api.delete('/api/learning-admin/categories/' + id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['cms-categories'] });
|
||||
qc.invalidateQueries({ queryKey: ['cms-content'] });
|
||||
qc.invalidateQueries({ queryKey: ['cms-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
if (!name.trim()) return;
|
||||
addCat.mutate(name.trim());
|
||||
}
|
||||
|
||||
const cats = data?.categories || [];
|
||||
|
||||
return (
|
||||
<aside className="w-64 shrink-0 space-y-3" data-testid="cms-sidebar">
|
||||
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Categories</div>
|
||||
<ul className="space-y-1 max-h-64 overflow-auto">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onCategoryFilter('all')}
|
||||
className={'w-full text-left text-sm px-2 py-1 rounded ' +
|
||||
(props.categoryFilter === 'all' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')
|
||||
}
|
||||
>All categories</button>
|
||||
</li>
|
||||
{cats.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onCategoryFilter(c.id)}
|
||||
className={'flex-1 text-left text-sm px-2 py-1 rounded ' +
|
||||
(props.categoryFilter === c.id ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')
|
||||
}
|
||||
data-testid={'cms-cat-' + c.id}
|
||||
>
|
||||
{c.name}
|
||||
{typeof c.content_count === 'number' && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">({c.content_count})</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(c)}
|
||||
className="text-xs text-destructive hover:text-red-700 px-1"
|
||||
title="Delete category"
|
||||
>×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={submit} className="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New category…"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={sm + ' flex-1'}
|
||||
data-testid="cms-new-cat-name"
|
||||
/>
|
||||
<button type="submit" disabled={addCat.isPending} className="rounded-md bg-primary text-primary-foreground px-2 py-1 text-xs">
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
{err && <div className="text-xs text-destructive">{err}</div>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Filter</div>
|
||||
<select
|
||||
className={sm + ' w-full'}
|
||||
value={props.statusFilter}
|
||||
onChange={(e) => props.onStatusFilter(e.target.value as 'all' | 'published' | 'draft')}
|
||||
data-testid="cms-filter-status"
|
||||
>
|
||||
<option value="all">All status</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Drafts</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!pendingDelete}
|
||||
title="Delete category?"
|
||||
body={pendingDelete ? 'Delete "' + pendingDelete.name + '"? Its content moves to uncategorized.' : ''}
|
||||
confirmText="Delete"
|
||||
danger
|
||||
busy={delCat.isPending}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={() => {
|
||||
if (pendingDelete) {
|
||||
delCat.mutate(pendingDelete.id, { onSettled: () => setPendingDelete(null) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
248
client/src/pages/cms/ContentEditor.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
// CMS content editor — title / category / type / subject / body /
|
||||
// published toggle. For quizzes, also embeds QuestionsEditor.
|
||||
//
|
||||
// New content: passes `null` id and POSTs on save. Existing content:
|
||||
// passes id and PUTs. Both refresh the content list query.
|
||||
//
|
||||
// Body is a plain textarea — no rich editor in this first React port.
|
||||
// (Vanilla used a Quill-ish toolbar; that lands as a follow-up if
|
||||
// users actually start using it.)
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import RichTextEditor from '@/components/RichTextEditor';
|
||||
import type { CmsCategory, CmsContentDetail, CmsQuestion, ContentType } from './cms-types';
|
||||
import QuestionsEditor from './QuestionsEditor';
|
||||
import SlideEditor from './SlideEditor';
|
||||
import AiGenerator, { type GeneratedPayload } from './AiGenerator';
|
||||
|
||||
interface CategoriesOk { success: true; categories: CmsCategory[] }
|
||||
interface ContentDetailOk { success: true; content: CmsContentDetail }
|
||||
|
||||
interface Props {
|
||||
id: number | null; // null = creating new
|
||||
initialType: ContentType; // for creates — pre-selects the type
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
|
||||
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
|
||||
|
||||
export default function ContentEditor({ id, initialType, onClose }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const isNew = id == null;
|
||||
const [title, setTitle] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<number | ''>('');
|
||||
const [contentType, setContentType] = useState<ContentType>(initialType);
|
||||
const [published, setPublished] = useState(false);
|
||||
const [questions, setQuestions] = useState<CmsQuestion[]>([]);
|
||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
|
||||
function applyAi(payload: GeneratedPayload, ctype: ContentType) {
|
||||
setContentType(ctype);
|
||||
// Presentation → body is the Marp markdown (becomes the slide-editor
|
||||
// source after split by \n---\n).
|
||||
if (ctype === 'presentation' && payload.marpMarkdown) {
|
||||
setBody(payload.marpMarkdown);
|
||||
// Extract title from first # heading so the editor can save immediately.
|
||||
const titleMatch = payload.marpMarkdown.match(/^#\s+(.+)$/m);
|
||||
if (titleMatch) setTitle(titleMatch[1]);
|
||||
} else {
|
||||
if (payload.title) setTitle(payload.title);
|
||||
if (payload.subject !== undefined) setSubject(payload.subject);
|
||||
if (payload.body !== undefined) setBody(payload.body);
|
||||
}
|
||||
if (payload.questions && payload.questions.length) {
|
||||
setQuestions(payload.questions);
|
||||
}
|
||||
setAiOpen(false);
|
||||
setMsg({ kind: 'ok', text: 'Content generated — review, then save.' });
|
||||
}
|
||||
|
||||
const cats = useQuery<CategoriesOk>({
|
||||
queryKey: ['cms-categories'],
|
||||
queryFn: () => api.get<CategoriesOk>('/api/learning-admin/categories'),
|
||||
});
|
||||
|
||||
const detail = useQuery<ContentDetailOk>({
|
||||
queryKey: ['cms-content-detail', id],
|
||||
queryFn: () => api.get<ContentDetailOk>('/api/learning-admin/content/' + id),
|
||||
enabled: !isNew,
|
||||
});
|
||||
|
||||
// Hydrate state from server when editing an existing item.
|
||||
useEffect(() => {
|
||||
if (!detail.data?.content) return;
|
||||
const c = detail.data.content;
|
||||
setTitle(c.title);
|
||||
setSubject(c.subject || '');
|
||||
setBody(c.body || '');
|
||||
setCategoryId(c.category_id ?? '');
|
||||
setContentType(c.content_type);
|
||||
setPublished(!!c.published);
|
||||
setQuestions(c.questions || []);
|
||||
}, [detail.data]);
|
||||
|
||||
const save = useMutation<{ success: true; id: number }, Error, void>({
|
||||
mutationFn: async () => {
|
||||
const body_ = {
|
||||
title, subject, body,
|
||||
category_id: categoryId === '' ? null : categoryId,
|
||||
content_type: contentType,
|
||||
published,
|
||||
};
|
||||
if (isNew) {
|
||||
return api.post<{ success: true; id: number }>('/api/learning-admin/content', body_);
|
||||
}
|
||||
await api.put<{ success: true }>('/api/learning-admin/content/' + id, body_);
|
||||
return { success: true as const, id: id! };
|
||||
},
|
||||
onSuccess: (d) => {
|
||||
qc.invalidateQueries({ queryKey: ['cms-content'] });
|
||||
qc.invalidateQueries({ queryKey: ['cms-stats'] });
|
||||
qc.invalidateQueries({ queryKey: ['cms-content-detail', d.id] });
|
||||
setMsg({ kind: 'ok', text: isNew ? 'Created — switch to the list to add questions' : 'Saved' });
|
||||
},
|
||||
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Save failed' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-3 min-w-0" data-testid="cms-editor">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">
|
||||
{isNew ? 'New ' + contentType : 'Edit ' + contentType}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setAiOpen((v) => !v)} className={btnPrimary} data-testid="cms-open-ai">
|
||||
✨ {aiOpen ? 'Hide AI' : 'Generate with AI'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className={btn}>← Back to list</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aiOpen && (
|
||||
<AiGenerator
|
||||
contentType={contentType}
|
||||
onChangeType={setContentType}
|
||||
onGenerated={applyAi}
|
||||
onCancel={() => setAiOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<label className="block">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Title</span>
|
||||
<input
|
||||
type="text"
|
||||
className={input + ' w-full'}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
data-testid="cms-edit-title"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="block">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Type</span>
|
||||
<select
|
||||
className={input + ' w-full'}
|
||||
value={contentType}
|
||||
onChange={(e) => setContentType(e.target.value as ContentType)}
|
||||
data-testid="cms-edit-type"
|
||||
>
|
||||
<option value="article">Article</option>
|
||||
<option value="pearl">Pearl</option>
|
||||
<option value="quiz">Quiz</option>
|
||||
<option value="presentation">Presentation</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Category</span>
|
||||
<select
|
||||
className={input + ' w-full'}
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
data-testid="cms-edit-category"
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{(cats.data?.categories || []).map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-end gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={published}
|
||||
onChange={(e) => setPublished(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
data-testid="cms-edit-published"
|
||||
/>
|
||||
<span className="text-sm">Published</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Subject (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
className={input + ' w-full'}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="e.g. Asthma, Bronchiolitis"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Body
|
||||
</span>
|
||||
{contentType === 'presentation' ? (
|
||||
<SlideEditor value={body} onChange={setBody} />
|
||||
) : (
|
||||
<RichTextEditor
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
variant="default"
|
||||
minHeight="min-h-[320px]"
|
||||
placeholder="Write the content body…"
|
||||
testId="cms-edit-body"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending || !title.trim()}
|
||||
className={btnPrimary}
|
||||
data-testid="cms-save"
|
||||
>
|
||||
{save.isPending ? 'Saving…' : (isNew ? 'Create' : 'Save changes')}
|
||||
</button>
|
||||
{msg && (
|
||||
<span className={'text-xs ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isNew && contentType === 'quiz' && (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<QuestionsEditor
|
||||
contentId={id!}
|
||||
questions={questions}
|
||||
onChange={setQuestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
client/src/pages/cms/ContentList.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// CMS content list — table of articles/pearls/quizzes/presentations
|
||||
// with toolbar (new article / new quiz / new pearl / new presentation
|
||||
// + search), per-row publish-toggle / edit / delete. Mirrors the
|
||||
// vanilla #lh-cms-content-list table.
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
import type { CmsContentRow, ContentType } from './cms-types';
|
||||
|
||||
interface ContentListOk { success: true; content: CmsContentRow[] }
|
||||
|
||||
interface Props {
|
||||
statusFilter: 'all' | 'published' | 'draft';
|
||||
categoryFilter: number | 'all';
|
||||
onEdit: (id: number) => void;
|
||||
onCreate: (type: ContentType) => void;
|
||||
}
|
||||
|
||||
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
|
||||
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
|
||||
const tag = 'text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border';
|
||||
|
||||
const TYPE_TAG: Record<ContentType, string> = {
|
||||
article: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
quiz: 'bg-amber-100 text-amber-800 border-amber-300',
|
||||
pearl: 'bg-purple-100 text-purple-700 border-purple-300',
|
||||
presentation: 'bg-emerald-100 text-emerald-800 border-emerald-300',
|
||||
};
|
||||
|
||||
export default function ContentList(props: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [pendingDelete, setPendingDelete] = useState<CmsContentRow | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery<ContentListOk>({
|
||||
queryKey: ['cms-content'],
|
||||
queryFn: () => api.get<ContentListOk>('/api/learning-admin/content'),
|
||||
});
|
||||
|
||||
const togglePublish = useMutation<{ success: true }, Error, { id: number; published: boolean }>({
|
||||
mutationFn: ({ id, published }) => api.put('/api/learning-admin/content/' + id, { published }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['cms-content'] });
|
||||
qc.invalidateQueries({ queryKey: ['cms-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const del = useMutation<{ success: true }, Error, number>({
|
||||
mutationFn: (id) => api.delete('/api/learning-admin/content/' + id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['cms-content'] });
|
||||
qc.invalidateQueries({ queryKey: ['cms-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const rows = data?.content || [];
|
||||
return rows.filter((r) => {
|
||||
if (props.statusFilter === 'published' && !r.published) return false;
|
||||
if (props.statusFilter === 'draft' && r.published) return false;
|
||||
if (props.categoryFilter !== 'all' && r.category_id !== props.categoryFilter) return false;
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase();
|
||||
const hay = (r.title + ' ' + (r.subject || '') + ' ' + (r.category_name || '')).toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [data, props.statusFilter, props.categoryFilter, search]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-3 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={() => props.onCreate('article')} className={btnPrimary} data-testid="cms-new-article">
|
||||
+ Article
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onCreate('quiz')} className={btn} data-testid="cms-new-quiz">
|
||||
+ Quiz
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onCreate('pearl')} className={btn} data-testid="cms-new-pearl">
|
||||
+ Pearl
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onCreate('presentation')} className={btn} data-testid="cms-new-presentation">
|
||||
+ Presentation
|
||||
</button>
|
||||
<div className="ml-auto">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search content…"
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-xs min-w-[220px]"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
data-testid="cms-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_120px_90px_90px_120px_120px] gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border">
|
||||
<span>Title</span>
|
||||
<span>Category</span>
|
||||
<span>Type</span>
|
||||
<span>Status</span>
|
||||
<span>Updated</span>
|
||||
<span className="text-right">Actions</span>
|
||||
</div>
|
||||
{isLoading && <div className="p-6 text-sm text-muted-foreground text-center">Loading…</div>}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="p-6 text-sm text-muted-foreground italic text-center">No content matches.</div>
|
||||
)}
|
||||
{filtered.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="grid grid-cols-[1fr_120px_90px_90px_120px_120px] gap-2 px-3 py-2 text-sm items-center border-b border-border last:border-0 hover:bg-muted/30"
|
||||
data-testid={'cms-row-' + r.id}
|
||||
>
|
||||
<button type="button" onClick={() => props.onEdit(r.id)} className="text-left truncate hover:underline">
|
||||
<strong>{r.title}</strong>
|
||||
{r.subject && <span className="ml-2 text-xs text-muted-foreground truncate">· {r.subject}</span>}
|
||||
{r.content_type === 'quiz' && typeof r.question_count === 'number' && (
|
||||
<span className="ml-2 text-[10px] text-muted-foreground">{r.question_count} Q</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground truncate">{r.category_name || '—'}</span>
|
||||
<span className={tag + ' ' + TYPE_TAG[r.content_type]}>{r.content_type}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePublish.mutate({ id: r.id, published: !r.published })}
|
||||
className={tag + ' ' + (r.published ? 'bg-green-100 text-green-700 border-green-300' : 'bg-muted text-muted-foreground border-border')}
|
||||
title="Toggle published"
|
||||
>
|
||||
{r.published ? 'Published' : 'Draft'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">{new Date(r.updated_at).toLocaleDateString()}</span>
|
||||
<div className="flex justify-end gap-1">
|
||||
<button type="button" onClick={() => props.onEdit(r.id)} className={btn}>Edit</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(r)}
|
||||
className={btn + ' text-destructive'}
|
||||
>Del</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!pendingDelete}
|
||||
title="Delete content?"
|
||||
body={pendingDelete ? 'Delete "' + pendingDelete.title + '"? This cannot be undone.' : ''}
|
||||
confirmText="Delete"
|
||||
danger
|
||||
busy={del.isPending}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={() => {
|
||||
if (pendingDelete) {
|
||||
del.mutate(pendingDelete.id, { onSettled: () => setPendingDelete(null) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||