Feat: admin CMS, save/resume encounters, user templates, SSHADESS, well visit notes, sick visit, ROS/PE checklists, ICD-10 diagnoses
- Admin CMS: announcements, feature flags, email templates, AI prompts, SMTP, model management, reset-to-defaults - Save/resume encounter progress with 7-day auto-expiry and patient labels - Inline searchable load popover (replaces settings-modal redirect) - User memory/template system (physical exam, ROS, encounter format, etc.) - SSHADESS psychosocial screening form (8 domains, listen-in recording) - Well visit note generation with vitals, vaccines, screenings, SSHADESS - Interactive screenings/vaccines status buttons (Done/Refused/Already Given/N/A) - ROS 15-system checklist + PE 17-system checklist in well visit note tab - ICD-10 diagnoses tag picker (28 common pediatric codes + free text) - Simple sick visit tab: chief complaint, inferred ROS (4) + PE (7), diagnoses, generate note - Settings converted from modal to full-page tab - Fix: /api/models moved before authenticated routes (was returning 401 → empty model selector) - Fix: getUserMemoryContext always fetches from API regardless of cache state - Pause/resume recording on all recording tabs - Site-level SMTP config stored in DB; ElevenLabs TTS with browser fallback
This commit is contained in:
parent
17b06b1eb2
commit
7719e564be
24 changed files with 4381 additions and 81 deletions
41
docker-compose.local.yml
Normal file
41
docker-compose.local.yml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
services:
|
||||
pediatric-scribe:
|
||||
build: .
|
||||
ports:
|
||||
- "3552:3000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- scribe-logs:/app/data/logs
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
container_name: pediatric-ai-scribe
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: pedscribe
|
||||
POSTGRES_USER: pedscribe
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe_secret_change_me}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
container_name: pedscribe-db
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pedscribe"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
scribe-logs:
|
||||
|
|
@ -345,3 +345,98 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
|||
.wv-sched-has{background:var(--green-light);color:#065f46;font-weight:700;border-radius:4px;cursor:help;}
|
||||
.wv-sched-legend{font-size:11px;color:var(--g400);margin-top:10px;display:flex;align-items:center;gap:6px;}
|
||||
.wv-sched-leg-dot{display:inline-block;width:12px;height:12px;background:var(--green-light);border-radius:2px;}
|
||||
|
||||
/* Announcement Banner */
|
||||
.announcement-banner{display:flex;align-items:center;gap:10px;padding:10px 16px;font-size:13px;font-weight:500;border-bottom:1px solid transparent;}
|
||||
.announcement-banner.hidden{display:none;}
|
||||
.announcement-banner.ann-info{background:#eff6ff;color:#1d4ed8;border-color:#bfdbfe;}
|
||||
.announcement-banner.ann-warning{background:#fffbeb;color:#92400e;border-color:#fcd34d;}
|
||||
.announcement-banner.ann-error{background:#fef2f2;color:#991b1b;border-color:#fca5a5;}
|
||||
.announcement-banner.ann-success{background:#f0fdf4;color:#166534;border-color:#86efac;}
|
||||
.announcement-banner span{flex:1;}
|
||||
.announcement-close{background:none;border:none;cursor:pointer;opacity:0.6;font-size:14px;padding:0 4px;color:inherit;}
|
||||
.announcement-close:hover{opacity:1;}
|
||||
|
||||
/* Save bar (encounter label + save/load) */
|
||||
.save-bar{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--g50);border:1px solid var(--g200);border-radius:var(--radius);margin-bottom:10px;flex-wrap:wrap;position:relative;}
|
||||
.save-label-input{flex:1;min-width:160px;font-size:13px;padding:5px 8px;border:1px solid var(--g200);border-radius:6px;background:white;}
|
||||
.save-label-input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 2px rgba(37,99,235,.12);}
|
||||
|
||||
/* Saved encounters list */
|
||||
.saved-enc-item{display:flex;align-items:center;gap:8px;padding:8px 10px;background:var(--g50);border:1px solid var(--g200);border-radius:8px;font-size:13px;}
|
||||
.saved-enc-label{flex:1;font-weight:600;color:var(--g800);}
|
||||
.saved-enc-meta{font-size:11px;color:var(--g500);}
|
||||
.saved-enc-type{font-size:11px;background:var(--blue-light);color:var(--blue);padding:1px 6px;border-radius:10px;font-weight:600;}
|
||||
|
||||
/* Memory list item */
|
||||
.mem-item{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;background:var(--g50);border:1px solid var(--g200);border-radius:8px;}
|
||||
.mem-item-info{flex:1;}
|
||||
.mem-item-name{font-weight:600;font-size:13px;color:var(--g800);}
|
||||
.mem-item-cat{font-size:11px;background:var(--green-light);color:#166534;padding:1px 6px;border-radius:10px;font-weight:600;}
|
||||
.mem-item-preview{font-size:12px;color:var(--g500);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:320px;}
|
||||
|
||||
/* Models list in admin CMS */
|
||||
.model-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--g100);}
|
||||
.model-row:last-child{border-bottom:none;}
|
||||
.model-row-name{flex:1;font-size:13px;font-weight:500;color:var(--g800);}
|
||||
.model-row-cost{font-size:12px;color:var(--g500);}
|
||||
.model-row-tag{font-size:10px;background:var(--g100);color:var(--g600);padding:1px 5px;border-radius:8px;}
|
||||
.model-row-toggle{font-size:12px;}
|
||||
|
||||
/* SSHADESS form */
|
||||
.shadess-domain{margin-bottom:12px;padding:10px 12px;border-radius:8px;background:white;box-shadow:var(--shadow);}
|
||||
.shadess-domain-header{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;}
|
||||
.shadess-domain-body{display:flex;flex-direction:column;gap:6px;}
|
||||
.shadess-q-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:3px 0;}
|
||||
.shadess-q-text{font-size:13px;color:var(--g700);flex:1;min-width:200px;}
|
||||
.shadess-concern-badge{font-size:11px;background:var(--red-light);color:var(--red);padding:2px 8px;border-radius:10px;font-weight:600;}
|
||||
.shadess-flag-btn{font-size:11px;padding:2px 6px;}
|
||||
|
||||
/* Settings page */
|
||||
.settings-page{display:flex;flex-direction:column;gap:16px;max-width:800px;}
|
||||
.settings-page .settings-section{padding:20px;}
|
||||
.settings-page .settings-section h3{font-size:16px;font-weight:700;color:var(--g800);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
|
||||
.settings-page .settings-section p{font-size:13px;color:var(--g600);margin-bottom:12px;}
|
||||
.settings-page .hipaa-info ul{margin:10px 0 10px 20px;font-size:13px;}
|
||||
.settings-page .hipaa-info li{margin-bottom:4px;}
|
||||
|
||||
/* Inline load popover — compact dropdown, right-aligned under Load button */
|
||||
.enc-load-popover{position:absolute;right:0;top:calc(100% + 4px);width:360px;max-width:calc(100vw - 24px);background:white;border:1px solid var(--g300);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.15);z-index:600;display:flex;flex-direction:column;}
|
||||
.enc-load-popover-inner{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--g200);}
|
||||
.enc-load-search{flex:1;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;}
|
||||
.enc-load-search:focus{outline:none;border-color:var(--blue);}
|
||||
.enc-pop-list{overflow-y:auto;max-height:260px;}
|
||||
.enc-pop-item{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;border-bottom:1px solid var(--g100);}
|
||||
.enc-pop-item:hover{background:var(--g50);}
|
||||
.enc-pop-item:last-child{border-bottom:none;}
|
||||
|
||||
/* ===== ROS / PE / DX — Tasks 2, 3, 4, 6 ===== */
|
||||
.ros-system-row{display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--g100);}
|
||||
.ros-system-row:last-child{border-bottom:none;}
|
||||
.ros-system-name{flex:1;font-size:13px;color:var(--g700);}
|
||||
.ros-status-btn{font-size:11px;padding:2px 8px;border-radius:12px;border:1px solid var(--g300);background:white;cursor:pointer;transition:all 0.15s;font-family:inherit;}
|
||||
.ros-status-btn:hover{background:var(--g100);}
|
||||
.ros-status-btn.wnl{background:var(--green-light);color:#166534;border-color:var(--green);}
|
||||
.ros-status-btn.abnormal{background:var(--red-light);color:#991b1b;border-color:var(--red);}
|
||||
.ros-status-btn.notrev{background:var(--g100);color:var(--g500);border-color:var(--g300);}
|
||||
.ros-note-input{font-size:12px;padding:3px 8px;border:1px solid var(--g300);border-radius:6px;width:180px;display:none;font-family:inherit;}
|
||||
.ros-note-input.visible{display:inline-block;}
|
||||
.dx-tag{display:inline-flex;align-items:center;gap:4px;background:var(--blue-light);color:#1d4ed8;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;margin:2px;}
|
||||
.dx-tag .dx-remove{cursor:pointer;opacity:0.7;font-size:14px;line-height:1;}
|
||||
.dx-tag .dx-remove:hover{opacity:1;}
|
||||
.dx-chip{cursor:pointer;padding:3px 10px;background:var(--g100);border:1px solid var(--g200);border-radius:12px;font-size:11px;color:var(--g700);display:inline-block;margin:2px;transition:all 0.15s;}
|
||||
.dx-chip:hover{background:var(--blue-light);color:var(--blue);border-color:var(--blue);}
|
||||
.sv-section-card{background:white;border-radius:var(--radius);border:1px solid var(--g200);padding:0;margin-bottom:10px;box-shadow:var(--shadow);}
|
||||
.sv-section-card .card-header{padding:10px 16px;border-bottom:1px solid var(--g100);display:flex;align-items:center;gap:8px;}
|
||||
.sv-section-card .card-header h3{font-size:14px;font-weight:600;color:var(--g800);display:flex;align-items:center;gap:6px;}
|
||||
.sv-section-card .card-body{padding:10px 16px;}
|
||||
.visit-status-btn{font-size:11px;padding:2px 8px;border-radius:10px;border:1px solid var(--g300);background:white;cursor:pointer;transition:all 0.15s;font-family:inherit;}
|
||||
.visit-status-btn.given,.visit-status-btn.done{background:var(--green-light);color:#166534;border-color:var(--green);}
|
||||
.visit-status-btn.refused{background:var(--red-light);color:#991b1b;border-color:var(--red);}
|
||||
.visit-status-btn.deferred,.visit-status-btn.not-due{background:var(--amber-light);color:#92400e;border-color:var(--amber);}
|
||||
.visit-status-btn.already-done{background:var(--blue-light);color:#1e40af;border-color:var(--blue);}
|
||||
@media(max-width:640px){
|
||||
.ros-system-row{flex-wrap:wrap;}
|
||||
.ros-note-input{width:100%;}
|
||||
.dx-chip{font-size:10px;}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,13 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ANNOUNCEMENT BANNER -->
|
||||
<div id="announcement-banner" class="announcement-banner hidden" role="alert">
|
||||
<i id="announcement-icon" class="fas fa-info-circle"></i>
|
||||
<span id="announcement-text"></span>
|
||||
<button class="announcement-close" onclick="this.parentElement.classList.add('hidden')" aria-label="Dismiss"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
|
||||
<!-- TAB NAVIGATION -->
|
||||
<nav class="tab-nav">
|
||||
<button class="tab-btn active" data-tab="encounter">
|
||||
|
|
@ -158,6 +165,14 @@
|
|||
<i class="fas fa-calendar-check"></i>
|
||||
<span>Well Visit</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="sickvisit">
|
||||
<i class="fas fa-thermometer-half"></i>
|
||||
<span>Sick Visit</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="settings">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button class="tab-btn hidden" data-tab="admin" id="admin-tab-btn">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<span>Admin</span>
|
||||
|
|
@ -196,9 +211,27 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar" id="enc-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="enc-label" class="save-label-input" placeholder="Patient label (e.g. John D, Visit #42)">
|
||||
</div>
|
||||
<button id="btn-enc-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-enc-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
</div>
|
||||
<div id="enc-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="enc-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="enc-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="record-controls">
|
||||
<button id="enc-record-btn" class="record-btn"><i class="fas fa-microphone"></i><span>Start Recording</span></button>
|
||||
<button id="enc-pause-btn" class="btn-sm btn-ghost hidden" style="margin-left:8px;"><i class="fas fa-pause"></i> Pause</button>
|
||||
<div id="enc-recording-indicator" class="recording-indicator hidden"><div class="pulse-dot"></div><span>Recording... <span id="enc-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -253,9 +286,27 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar" id="dict-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="dict-label" class="save-label-input" placeholder="Patient label (e.g. John D, Visit #42)">
|
||||
</div>
|
||||
<button id="btn-dict-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-dict-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
</div>
|
||||
<div id="dict-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="dict-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="dict-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="record-controls">
|
||||
<button id="dict-record-btn" class="record-btn btn-purple"><i class="fas fa-microphone"></i><span>Start Dictation</span></button>
|
||||
<button id="dict-pause-btn" class="btn-sm btn-ghost hidden" style="margin-left:8px;"><i class="fas fa-pause"></i> Pause</button>
|
||||
<div id="dict-recording-indicator" class="recording-indicator hidden"><div class="pulse-dot pulse-purple"></div><span>Dictating... <span id="dict-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -712,6 +763,190 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Announcement Banner ──────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-bullhorn"></i> Announcement Banner</h3></div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<label style="font-size:13px;font-weight:600;">Show banner:</label>
|
||||
<select id="cms-ann-enabled" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="false">Hidden</option>
|
||||
<option value="true">Visible</option>
|
||||
</select>
|
||||
<select id="cms-ann-type" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="info">Info (blue)</option>
|
||||
<option value="warning">Warning (amber)</option>
|
||||
<option value="error">Error (red)</option>
|
||||
<option value="success">Success (green)</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea id="cms-ann-text" rows="2" style="font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;" placeholder="Announcement message..."></textarea>
|
||||
<div>
|
||||
<button id="btn-save-announcement" class="btn-sm btn-primary">Save & Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Feature Flags ─────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-toggle-on"></i> Feature Flags</h3></div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:10px;" id="cms-feature-flags">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Read Aloud (TTS):</label>
|
||||
<select id="cms-flag-read-aloud" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Nextcloud Integration:</label>
|
||||
<select id="cms-flag-nextcloud" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button id="btn-save-flags" class="btn-sm btn-primary">Save Flags</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Email Templates ───────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-envelope"></i> Email Templates</h3></div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:16px;">
|
||||
<!-- Email template selector -->
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<label style="font-size:13px;font-weight:600;">Template:</label>
|
||||
<select id="cms-email-template" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="verify">Email Verification</option>
|
||||
<option value="reset">Password Reset</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:4px;display:block;">Subject</label>
|
||||
<input type="text" id="cms-email-subject" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="Email subject...">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:4px;display:block;">Body (plain text — a button link will be appended automatically)</label>
|
||||
<textarea id="cms-email-body" rows="4" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Email body text..."></textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button id="btn-save-email" class="btn-sm btn-primary">Save Template</button>
|
||||
<button id="btn-test-email" class="btn-sm btn-ghost"><i class="fas fa-paper-plane"></i> Send Test</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: AI Prompts ────────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-robot"></i> AI Prompts</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Changes take effect immediately, no restart needed</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<label style="font-size:13px;font-weight:600;">Prompt:</label>
|
||||
<select id="cms-prompt-select" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;max-width:320px;"></select>
|
||||
</div>
|
||||
<textarea id="cms-prompt-text" rows="10" style="width:100%;font-size:12px;font-family:monospace;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Loading..."></textarea>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button id="btn-save-prompt" class="btn-sm btn-primary">Save Prompt</button>
|
||||
<button id="btn-reset-prompt" class="btn-sm btn-ghost"><i class="fas fa-rotate-left"></i> Reset to Default</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: SMTP Email Server ─────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-server"></i> SMTP Email Server</h3>
|
||||
<span id="smtp-source-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">SMTP Host</label>
|
||||
<input type="text" id="cms-smtp-host" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="smtp.gmail.com">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Port</label>
|
||||
<input type="number" id="cms-smtp-port" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="587" value="587">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Username / Email</label>
|
||||
<input type="text" id="cms-smtp-user" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="user@example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Password / App Password</label>
|
||||
<input type="password" id="cms-smtp-pass" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="Leave blank to keep current">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">From Address</label>
|
||||
<input type="text" id="cms-smtp-from" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="Pediatric Scribe <noreply@example.com>">
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:8px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;">TLS/SSL (port 465):</label>
|
||||
<select id="cms-smtp-secure" style="font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="false">STARTTLS (587)</option>
|
||||
<option value="true">SSL/TLS (465)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button id="btn-save-smtp" class="btn-sm btn-primary">Save SMTP</button>
|
||||
<button id="btn-clear-smtp" class="btn-sm btn-ghost"><i class="fas fa-trash"></i> Clear DB (use env)</button>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0;"><i class="fas fa-info-circle"></i> Environment variables take precedence over DB settings. Storing password in DB is convenient but consider using env vars for production security.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Models Management ─────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-microchip"></i> AI Models</h3>
|
||||
<button id="btn-add-custom-model" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add Custom</button>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:10px;">
|
||||
<div id="cms-models-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading models...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Saved Encounters (Admin View) ─────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-database"></i> Saved Encounters (Site-wide)</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Auto-deleted after <span id="admin-auto-delete-days">7</span> days</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<label style="font-size:13px;font-weight:600;">Auto-delete after:</label>
|
||||
<select id="cms-auto-delete-days" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="1">1 day</option>
|
||||
<option value="3">3 days</option>
|
||||
<option value="7" selected>7 days</option>
|
||||
<option value="14">14 days</option>
|
||||
<option value="30">30 days</option>
|
||||
</select>
|
||||
<button id="btn-save-auto-delete" class="btn-sm btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
|
||||
<div class="card" style="border:1px solid var(--red-light);">
|
||||
<div class="card-header"><h3 style="color:var(--red);"><i class="fas fa-triangle-exclamation"></i> Reset Settings</h3></div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:10px;">
|
||||
<p style="font-size:13px;color:var(--g600);margin:0;">Reset all announcement, feature flag, and email settings back to factory defaults. SMTP configuration and custom models are preserved.</p>
|
||||
<div>
|
||||
<button id="btn-reset-all-defaults" class="btn-sm" style="background:var(--red);color:white;border:none;border-radius:6px;padding:6px 14px;font-size:13px;cursor:pointer;"><i class="fas fa-rotate-left"></i> Reset All to Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ===== TAB: WELL VISIT / PREVENTIVE CARE ===== -->
|
||||
|
|
@ -732,6 +967,12 @@
|
|||
<button class="wv-subtab-btn" data-subtab="catchup">
|
||||
<i class="fas fa-rotate"></i> Catch-Up Schedule
|
||||
</button>
|
||||
<button class="wv-subtab-btn" data-subtab="shadess">
|
||||
<i class="fas fa-brain"></i> SSHADESS (12+)
|
||||
</button>
|
||||
<button class="wv-subtab-btn" data-subtab="note">
|
||||
<i class="fas fa-file-medical"></i> Visit Note
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- By Visit sub-panel -->
|
||||
|
|
@ -755,20 +996,297 @@
|
|||
<p class="wv-loading">Loading catch-up schedule...</p>
|
||||
</div>
|
||||
|
||||
<!-- SSHADESS sub-panel (age 12+) -->
|
||||
<div id="wv-panel-shadess" class="wv-subpanel hidden">
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-brain"></i> SSHADESS Psychosocial Screening</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Recommended age 12 and older</span>
|
||||
</div>
|
||||
<div style="padding:12px 16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;border-bottom:1px solid var(--g100);">
|
||||
<div class="demo-field"><label>Patient Age</label><input type="text" id="shadess-age" placeholder="e.g., 14 years" style="width:120px;"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="shadess-gender"><option value="">Select</option><option>Male</option><option>Female</option><option>Non-binary/Other</option></select></div>
|
||||
<div class="demo-field demo-field-model"><label><i class="fas fa-robot"></i> Model</label><select class="tab-model-select"></select></div>
|
||||
<div style="margin-left:auto;display:flex;gap:8px;">
|
||||
<button id="shadess-record-btn" class="btn-sm btn-ghost"><i class="fas fa-microphone"></i> Listen In</button>
|
||||
<button id="shadess-pause-btn" class="btn-sm btn-ghost hidden"><i class="fas fa-pause"></i> Pause</button>
|
||||
<div id="shadess-rec-indicator" class="recording-indicator hidden" style="font-size:12px;"><div class="pulse-dot"></div><span>Recording... <span id="shadess-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Domains will be rendered by JS -->
|
||||
<div id="shadess-domains" style="padding:8px 0;"></div>
|
||||
<div style="padding:12px 16px;display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid var(--g100);">
|
||||
<button id="btn-shadess-generate" class="btn-generate" style="flex:1;"><i class="fas fa-wand-magic-sparkles"></i> Generate SSHADESS Assessment</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SSHADESS Output -->
|
||||
<div id="shadess-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> SSHADESS Assessment</h3>
|
||||
<div class="output-actions">
|
||||
<span id="shadess-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-ghost" id="btn-shadess-new-patient"><i class="fas fa-rotate-left"></i> New Patient</button>
|
||||
<button class="btn-sm btn-primary" onclick="copyText('shadess-result-text')"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" onclick="speakText('shadess-result-text')"><i class="fas fa-volume-high"></i> Read</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="shadess-result-text" class="output-text" contenteditable="true"></div>
|
||||
<div style="padding:10px 16px;font-size:12px;color:var(--g500);">
|
||||
<i class="fas fa-info-circle"></i> Use "Copy" to paste this into the Visit Note tab for a complete encounter note.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visit Note sub-panel -->
|
||||
<div id="wv-panel-note" class="wv-subpanel hidden">
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar" id="wv-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="wv-label" class="save-label-input" placeholder="Patient label (e.g. Jane D, Visit #7)">
|
||||
</div>
|
||||
<button id="btn-wv-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-wv-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
</div>
|
||||
<div id="wv-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="wv-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="wv-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header"><h3><i class="fas fa-stethoscope"></i> Visit Details</h3></div>
|
||||
<div style="padding:12px 16px;display:flex;flex-wrap:wrap;gap:10px;">
|
||||
<div class="demo-field"><label>Visit Age</label><input type="text" id="wv-note-age" placeholder="e.g., 9 years"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="wv-note-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
|
||||
<div class="demo-field demo-field-model"><label><i class="fas fa-robot"></i> Model</label><select class="tab-model-select"></select></div>
|
||||
<div style="width:100%;display:flex;gap:8px;align-items:center;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);">Note style:</label>
|
||||
<select id="wv-note-style" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="full">Full Encounter Note</option>
|
||||
<option value="short">Brief SOAP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-ruler-combined"></i> Vitals & Measurements</h3>
|
||||
</div>
|
||||
<div style="padding:12px 16px;">
|
||||
<textarea id="wv-vitals" rows="3" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="e.g., Temp 98.6, HR 78, RR 18, BP 106/68, O2 99% Ht: 135cm (50th%), Wt: 32kg (55th%), BMI: 17.6 (60th%)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-microphone"></i> Encounter Recording / Dictation</h3>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="wv-record-btn" class="btn-sm btn-ghost"><i class="fas fa-microphone"></i> Listen In</button>
|
||||
<button id="wv-pause-btn" class="btn-sm btn-ghost hidden"><i class="fas fa-pause"></i> Pause</button>
|
||||
<div id="wv-rec-indicator" class="recording-indicator hidden" style="font-size:12px;"><div class="pulse-dot"></div><span>Recording... <span id="wv-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 16px;">
|
||||
<div id="wv-transcript" class="editable-box" contenteditable="true" style="min-height:80px;" data-placeholder="Transcript of the encounter appears here. You can also type or paste directly. Physician can dictate: 'All physical exam normal aside from...'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header"><h3><i class="fas fa-syringe"></i> Screenings & Vaccines</h3></div>
|
||||
<div style="padding:12px 16px;display:flex;flex-direction:column;gap:8px;">
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Screenings completed today:</label>
|
||||
<textarea id="wv-screenings" rows="2" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="e.g., Depression screen (PHQ-A): negative, Vision screen: 20/20 bilateral, Lead level: 2 ug/dL"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Vaccines given today:</label>
|
||||
<textarea id="wv-vaccines" rows="2" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="e.g., Tdap #1, Meningococcal (MenACWY) #1, HPV #1"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSHADESS carry-over -->
|
||||
<div class="card" style="margin-bottom:10px;" id="wv-shadess-card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-brain"></i> SSHADESS Assessment</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">From SSHADESS tab (age 12+)</span>
|
||||
</div>
|
||||
<div style="padding:8px 16px;">
|
||||
<textarea id="wv-shadess-text" rows="4" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Paste SSHADESS assessment here, or it auto-fills from the SSHADESS tab..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ROS Card -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3><i class="fas fa-list-check"></i> Review of Systems (ROS)</h3>
|
||||
<button id="wv-ros-all-wnl" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All WNL</button>
|
||||
</div>
|
||||
<div id="wv-ros-container" style="padding:8px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Exam Card -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3><i class="fas fa-stethoscope"></i> Physical Examination (PE)</h3>
|
||||
<button id="wv-pe-all-normal" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All Normal</button>
|
||||
</div>
|
||||
<div id="wv-pe-container" style="padding:8px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Diagnoses Card -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header"><h3><i class="fas fa-tag"></i> Diagnoses (ICD-10)</h3></div>
|
||||
<div id="wv-dx-container" style="padding:12px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<button id="btn-wv-generate" class="btn-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate Well Visit Note</button>
|
||||
|
||||
<div id="wv-note-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> Well Visit Note</h3>
|
||||
<div class="output-actions">
|
||||
<span id="wv-note-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-ghost" id="btn-wv-new-patient"><i class="fas fa-rotate-left"></i> New Patient</button>
|
||||
<button class="btn-sm btn-primary" onclick="copyText('wv-note-text')"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" onclick="speakText('wv-note-text')"><i class="fas fa-volume-high"></i> Read</button>
|
||||
<button class="btn-sm btn-ghost" onclick="exportToNextcloud('wv-note-text','well-visit')"><i class="fas fa-cloud-arrow-up"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wv-note-text" class="output-text" contenteditable="true"></div>
|
||||
<div class="refine-bar">
|
||||
<input type="text" id="wv-refine-input" class="refine-input" placeholder="Tell AI to modify (e.g., 'add that patient has asthma', 'expand the plan section')">
|
||||
<button class="btn-sm btn-primary" id="wv-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||
<button class="btn-sm btn-ghost" id="wv-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- SETTINGS MODAL -->
|
||||
<div id="settings-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2><i class="fas fa-cog"></i> Settings</h2>
|
||||
<button class="modal-close" id="close-settings"><i class="fas fa-times"></i></button>
|
||||
<!-- ===== TAB: SICK VISIT ===== -->
|
||||
<section id="sickvisit-tab" class="tab-content hidden">
|
||||
<div class="module-header">
|
||||
<h2><i class="fas fa-thermometer-half"></i> Sick Visit Note</h2>
|
||||
<p>Quick sick visit documentation with auto-suggested ROS and PE systems</p>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-bar" id="sick-save-bar">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||
<input type="text" id="sick-label" class="save-label-input" placeholder="Patient label (e.g. John D, 5y fever)">
|
||||
</div>
|
||||
<button id="btn-sick-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||
<button id="btn-sick-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||
</div>
|
||||
<div id="sick-load-popover" class="enc-load-popover hidden">
|
||||
<div class="enc-load-popover-inner">
|
||||
<input type="text" id="sick-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="sick-pop-list" class="enc-pop-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Demographics -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header"><h3><i class="fas fa-user"></i> Patient Info</h3></div>
|
||||
<div style="padding:12px 16px;display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;">
|
||||
<div class="demo-field"><label>Age</label><input type="text" id="sick-age" placeholder="e.g., 5 years" style="width:110px;"></div>
|
||||
<div class="demo-field"><label>Gender</label><select id="sick-gender"><option value="">Select</option><option>Male</option><option>Female</option><option>Non-binary/Other</option></select></div>
|
||||
<div class="demo-field" style="flex:1;min-width:200px;"><label>Chief Complaint</label><input type="text" id="sick-cc" placeholder="e.g., fever, ear pain, cough" style="width:100%;"></div>
|
||||
<div class="demo-field demo-field-model"><label><i class="fas fa-robot"></i> Model</label><select id="sick-model-select" class="tab-model-select"></select></div>
|
||||
<button id="btn-sick-infer" class="btn-sm btn-primary" style="align-self:flex-end;"><i class="fas fa-wand-magic-sparkles"></i> Update ROS/PE</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recording -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-microphone"></i> Encounter Recording / Dictation</h3>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="sick-record-btn" class="btn-sm btn-ghost"><i class="fas fa-microphone"></i> Listen In</button>
|
||||
<button id="sick-pause-btn" class="btn-sm btn-ghost hidden"><i class="fas fa-pause"></i> Pause</button>
|
||||
<div id="sick-rec-indicator" class="recording-indicator hidden" style="font-size:12px;"><div class="pulse-dot"></div><span>Recording... <span id="sick-timer">00:00</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 16px;">
|
||||
<div id="sick-transcript" class="editable-box" contenteditable="true" style="min-height:80px;" data-placeholder="Transcript appears here. Physician can dictate the HPI and encounter details."></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ROS (4 systems auto-inferred) -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3><i class="fas fa-list-check"></i> Review of Systems</h3>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="sick-ros-all-wnl" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All WNL</button>
|
||||
<select id="sick-ros-add" class="btn-sm" style="font-size:11px;padding:3px 6px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="">+ Add system</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sick-ros-container" style="padding:8px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- PE (7 systems auto-inferred) -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3><i class="fas fa-stethoscope"></i> Physical Examination</h3>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="sick-pe-all-normal" class="btn-sm btn-ghost" style="font-size:11px;"><i class="fas fa-check-double"></i> All Normal</button>
|
||||
<select id="sick-pe-add" class="btn-sm" style="font-size:11px;padding:3px 6px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="">+ Add system</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sick-pe-container" style="padding:8px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Diagnoses -->
|
||||
<div class="card" style="margin-bottom:10px;">
|
||||
<div class="card-header"><h3><i class="fas fa-tag"></i> Diagnoses (ICD-10)</h3></div>
|
||||
<div id="sick-dx-container" style="padding:12px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<button id="btn-sick-generate" class="btn-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate Sick Visit Note</button>
|
||||
|
||||
<!-- Output -->
|
||||
<div id="sick-note-output" class="card output-card hidden">
|
||||
<div class="card-header output-header">
|
||||
<h3><i class="fas fa-file-medical"></i> Sick Visit Note</h3>
|
||||
<div class="output-actions">
|
||||
<span id="sick-note-model-tag" class="model-tag"></span>
|
||||
<button class="btn-sm btn-ghost" id="btn-sick-new-patient"><i class="fas fa-rotate-left"></i> New Patient</button>
|
||||
<button class="btn-sm btn-primary" onclick="copyText('sick-note-text')"><i class="fas fa-copy"></i> Copy</button>
|
||||
<button class="btn-sm btn-ghost" onclick="speakText('sick-note-text')"><i class="fas fa-volume-high"></i> Read</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sick-note-text" class="output-text" contenteditable="true"></div>
|
||||
<div class="refine-bar">
|
||||
<input type="text" id="sick-refine-input" class="refine-input" placeholder="Tell AI to modify (e.g., 'add that patient received amoxicillin')">
|
||||
<button class="btn-sm btn-primary" id="sick-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||
<button class="btn-sm btn-ghost" id="sick-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== TAB: SETTINGS ===== -->
|
||||
<section id="settings-tab" class="tab-content hidden">
|
||||
<div class="module-header">
|
||||
<h2><i class="fas fa-cog"></i> Settings</h2>
|
||||
<p>Account security, integrations, and personal templates</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-page">
|
||||
|
||||
<!-- 2FA -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-shield-halved"></i> Two-Factor Authentication</h3>
|
||||
<p id="2fa-status">Status: Loading...</p>
|
||||
<button id="btn-setup-2fa" class="btn-sm btn-primary">Enable 2FA</button>
|
||||
|
|
@ -786,7 +1304,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Nextcloud -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-cloud"></i> Nextcloud Integration</h3>
|
||||
<p>Export generated documents to your Nextcloud.</p>
|
||||
<div id="nc-status">Not connected</div>
|
||||
|
|
@ -807,8 +1325,41 @@
|
|||
<button id="btn-nc-disconnect" class="btn-sm btn-ghost hidden">Disconnect</button>
|
||||
</div>
|
||||
|
||||
<!-- My Templates / Memories -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-book-medical"></i> My Templates</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes. You can reference them by saying "use my normal physical exam" in dictation.</p>
|
||||
<div style="margin-bottom:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<select id="mem-category" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="physical_exam">Physical Exam Template</option>
|
||||
<option value="ros">Review of Systems Template</option>
|
||||
<option value="encounter_format">Encounter Note Format</option>
|
||||
<option value="family_history">Family History Format</option>
|
||||
<option value="assessment_plan">Assessment & Plan Format</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)">
|
||||
</div>
|
||||
<textarea id="mem-content" rows="5" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL. Ears: TMs clear. Throat: clear..."></textarea>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;">
|
||||
<button id="btn-mem-save" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add Template</button>
|
||||
</div>
|
||||
<div id="mem-list" style="margin-top:12px;display:flex;flex-direction:column;gap:6px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading templates...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Encounters -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-floppy-disk"></i> Saved Encounters</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Encounters are automatically deleted after 7 days per site policy.</p>
|
||||
<div id="saved-enc-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<p style="color:var(--g400);font-size:13px;">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HIPAA -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-lock"></i> HIPAA & Privacy</h3>
|
||||
<div class="hipaa-info">
|
||||
<p><strong>Current Status:</strong> This tool processes data through third-party AI APIs.</p>
|
||||
|
|
@ -823,9 +1374,14 @@
|
|||
<p><strong>Recommendation:</strong> Do not enter real PHI until your organization has executed BAAs with all AI providers.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- dummy settings-modal kept for any stale JS references -->
|
||||
<div id="settings-modal" class="hidden" style="display:none !important;"></div>
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -850,6 +1406,10 @@
|
|||
<script src="/js/milestones.js"></script>
|
||||
<script src="/js/nextcloud.js"></script>
|
||||
<script src="/js/wellVisit.js"></script>
|
||||
<script src="/js/shadess.js"></script>
|
||||
<script src="/js/sickVisit.js"></script>
|
||||
<script src="/js/encounters.js"></script>
|
||||
<script src="/js/memories.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -229,3 +229,408 @@
|
|||
}
|
||||
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// ADMIN CMS — Announcements, Feature Flags, Email, AI Prompts
|
||||
// ============================================================
|
||||
(function() {
|
||||
|
||||
var cmsLoaded = false;
|
||||
|
||||
// Load CMS when admin tab is opened
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.tab-btn');
|
||||
if (btn && btn.getAttribute('data-tab') === 'admin') {
|
||||
if (!cmsLoaded) { loadCms(); cmsLoaded = true; }
|
||||
}
|
||||
|
||||
// Save buttons
|
||||
if (e.target.closest('#btn-save-announcement')) saveAnnouncement();
|
||||
if (e.target.closest('#btn-save-flags')) saveFlags();
|
||||
if (e.target.closest('#btn-save-email')) saveEmail();
|
||||
if (e.target.closest('#btn-test-email')) sendTestEmail();
|
||||
if (e.target.closest('#btn-save-prompt')) savePrompt();
|
||||
if (e.target.closest('#btn-reset-prompt')) resetPrompt();
|
||||
if (e.target.closest('#btn-save-smtp')) saveSmtp();
|
||||
if (e.target.closest('#btn-clear-smtp')) clearSmtp();
|
||||
if (e.target.closest('#btn-add-custom-model')) addCustomModel();
|
||||
if (e.target.closest('#btn-save-auto-delete')) saveAutoDelete();
|
||||
if (e.target.closest('#btn-reset-all-defaults')) resetAllDefaults();
|
||||
});
|
||||
|
||||
// When email template selector changes, repopulate fields
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.id === 'cms-email-template') loadEmailFields(e.target.value);
|
||||
if (e.target.id === 'cms-prompt-select') loadPromptText(e.target.value);
|
||||
});
|
||||
|
||||
// ---- LOAD ALL CONFIG ----
|
||||
|
||||
function loadCms() {
|
||||
fetch('/api/admin/config', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var cfg = {};
|
||||
(data.config || []).forEach(function(row) { cfg[row.key] = row.value; });
|
||||
|
||||
// Announcement
|
||||
var annEnabled = document.getElementById('cms-ann-enabled');
|
||||
var annType = document.getElementById('cms-ann-type');
|
||||
var annText = document.getElementById('cms-ann-text');
|
||||
if (annEnabled) annEnabled.value = cfg['announcement.enabled'] || 'false';
|
||||
if (annType) annType.value = cfg['announcement.type'] || 'info';
|
||||
if (annText) annText.value = cfg['announcement.text'] || '';
|
||||
|
||||
// Feature flags
|
||||
var flagReadAloud = document.getElementById('cms-flag-read-aloud');
|
||||
var flagNextcloud = document.getElementById('cms-flag-nextcloud');
|
||||
if (flagReadAloud) flagReadAloud.value = cfg['feature.read_aloud'] !== undefined ? cfg['feature.read_aloud'] : 'true';
|
||||
if (flagNextcloud) flagNextcloud.value = cfg['feature.nextcloud'] !== undefined ? cfg['feature.nextcloud'] : 'true';
|
||||
|
||||
// Email — load verify template by default
|
||||
window._adminEmailConfig = cfg;
|
||||
loadEmailFields('verify');
|
||||
})
|
||||
.catch(function(err) { console.error('[AdminCMS] Config load failed:', err); });
|
||||
|
||||
loadPromptList();
|
||||
loadSmtp();
|
||||
loadModels();
|
||||
|
||||
// Auto-delete setting
|
||||
var autoDeleteDays = cfg['site.auto_delete_days'] || '7';
|
||||
var el = document.getElementById('cms-auto-delete-days');
|
||||
if (el) el.value = autoDeleteDays;
|
||||
el = document.getElementById('admin-auto-delete-days');
|
||||
if (el) el.textContent = autoDeleteDays;
|
||||
}
|
||||
|
||||
// ---- ANNOUNCEMENT ----
|
||||
|
||||
function saveAnnouncement() {
|
||||
var enabled = document.getElementById('cms-ann-enabled').value;
|
||||
var type = document.getElementById('cms-ann-type').value;
|
||||
var text = document.getElementById('cms-ann-text').value;
|
||||
|
||||
Promise.all([
|
||||
putConfig('announcement.enabled', enabled),
|
||||
putConfig('announcement.type', type),
|
||||
putConfig('announcement.text', text)
|
||||
]).then(function() {
|
||||
showToast('Announcement saved', 'success');
|
||||
if (typeof loadAnnouncement === 'function') loadAnnouncement();
|
||||
}).catch(function() { showToast('Save failed', 'error'); });
|
||||
}
|
||||
|
||||
// ---- FEATURE FLAGS ----
|
||||
|
||||
function saveFlags() {
|
||||
var readAloud = document.getElementById('cms-flag-read-aloud').value;
|
||||
var nextcloud = document.getElementById('cms-flag-nextcloud').value;
|
||||
|
||||
Promise.all([
|
||||
putConfig('feature.read_aloud', readAloud),
|
||||
putConfig('feature.nextcloud', nextcloud)
|
||||
]).then(function() {
|
||||
showToast('Feature flags saved', 'success');
|
||||
}).catch(function() { showToast('Save failed', 'error'); });
|
||||
}
|
||||
|
||||
// ---- EMAIL TEMPLATES ----
|
||||
|
||||
function loadEmailFields(template) {
|
||||
var cfg = window._adminEmailConfig || {};
|
||||
var subject = document.getElementById('cms-email-subject');
|
||||
var body = document.getElementById('cms-email-body');
|
||||
if (subject) subject.value = cfg['email.' + template + '.subject'] || '';
|
||||
if (body) body.value = cfg['email.' + template + '.body'] || '';
|
||||
}
|
||||
|
||||
function saveEmail() {
|
||||
var template = document.getElementById('cms-email-template').value;
|
||||
var subject = document.getElementById('cms-email-subject').value;
|
||||
var body = document.getElementById('cms-email-body').value;
|
||||
|
||||
Promise.all([
|
||||
putConfig('email.' + template + '.subject', subject),
|
||||
putConfig('email.' + template + '.body', body)
|
||||
]).then(function() {
|
||||
// Update local cache
|
||||
if (!window._adminEmailConfig) window._adminEmailConfig = {};
|
||||
window._adminEmailConfig['email.' + template + '.subject'] = subject;
|
||||
window._adminEmailConfig['email.' + template + '.body'] = body;
|
||||
showToast('Email template saved', 'success');
|
||||
}).catch(function() { showToast('Save failed', 'error'); });
|
||||
}
|
||||
|
||||
function sendTestEmail() {
|
||||
var template = document.getElementById('cms-email-template').value;
|
||||
var to = prompt('Send test email to (enter email address):');
|
||||
if (!to || !to.trim()) return;
|
||||
fetch('/api/admin/config/test-email', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ to: to.trim(), template: template })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) showToast('Test email sent to ' + to, 'success');
|
||||
else showToast(data.error || 'Send failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
}
|
||||
|
||||
// ---- AI PROMPTS ----
|
||||
|
||||
function loadPromptList() {
|
||||
fetch('/api/admin/config/prompts', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var sel = document.getElementById('cms-prompt-select');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
(data.prompts || []).forEach(function(p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.key;
|
||||
opt.textContent = p.key;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
window._adminPrompts = {};
|
||||
(data.prompts || []).forEach(function(p) { window._adminPrompts[p.key] = p.value; });
|
||||
if (data.prompts && data.prompts.length > 0) {
|
||||
loadPromptText(data.prompts[0].key);
|
||||
}
|
||||
})
|
||||
.catch(function(err) { console.error('[AdminCMS] Prompts load failed:', err); });
|
||||
}
|
||||
|
||||
function loadPromptText(key) {
|
||||
var textarea = document.getElementById('cms-prompt-text');
|
||||
if (!textarea) return;
|
||||
textarea.value = (window._adminPrompts && window._adminPrompts[key]) || '';
|
||||
}
|
||||
|
||||
function savePrompt() {
|
||||
var sel = document.getElementById('cms-prompt-select');
|
||||
var text = document.getElementById('cms-prompt-text');
|
||||
if (!sel || !text) return;
|
||||
var key = sel.value;
|
||||
var value = text.value;
|
||||
if (!key) return;
|
||||
|
||||
putConfig('prompt.' + key, value)
|
||||
.then(function() {
|
||||
if (!window._adminPrompts) window._adminPrompts = {};
|
||||
window._adminPrompts[key] = value;
|
||||
showToast('Prompt saved', 'success');
|
||||
})
|
||||
.catch(function() { showToast('Save failed', 'error'); });
|
||||
}
|
||||
|
||||
function resetPrompt() {
|
||||
var sel = document.getElementById('cms-prompt-select');
|
||||
if (!sel || !sel.value) return;
|
||||
var key = sel.value;
|
||||
if (!confirm('Reset "' + key + '" to the hardcoded default? This cannot be undone.')) return;
|
||||
|
||||
fetch('/api/admin/config/prompts/' + key + '/reset', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
if (!window._adminPrompts) window._adminPrompts = {};
|
||||
window._adminPrompts[key] = data.value;
|
||||
var textarea = document.getElementById('cms-prompt-text');
|
||||
if (textarea) textarea.value = data.value;
|
||||
showToast('Prompt reset to default', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Reset failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
}
|
||||
|
||||
// ---- SMTP ----
|
||||
|
||||
function loadSmtp() {
|
||||
fetch('/api/admin/config/smtp/status', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var badge = document.getElementById('smtp-source-badge');
|
||||
if (badge) {
|
||||
if (data.source === 'env') badge.textContent = 'Configured via env';
|
||||
else if (data.source === 'database') badge.textContent = 'Configured in DB';
|
||||
else { badge.textContent = 'Not configured'; badge.style.background = 'var(--red-light)'; badge.style.color = 'var(--red)'; }
|
||||
}
|
||||
var h = document.getElementById('cms-smtp-host'); if (h) h.value = data.host || '';
|
||||
var p = document.getElementById('cms-smtp-port'); if (p) p.value = data.port || '587';
|
||||
var u = document.getElementById('cms-smtp-user'); if (u) u.value = data.user || '';
|
||||
var f = document.getElementById('cms-smtp-from'); if (f) f.value = data.from || '';
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function saveSmtp() {
|
||||
var host = document.getElementById('cms-smtp-host').value.trim();
|
||||
var port = document.getElementById('cms-smtp-port').value.trim();
|
||||
var user = document.getElementById('cms-smtp-user').value.trim();
|
||||
var pass = document.getElementById('cms-smtp-pass').value.trim();
|
||||
var from = document.getElementById('cms-smtp-from').value.trim();
|
||||
var secure = document.getElementById('cms-smtp-secure').value;
|
||||
if (!host) { showToast('SMTP host required', 'error'); return; }
|
||||
|
||||
fetch('/api/admin/config/smtp', {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ host: host, port: port, user: user, pass: pass, from: from, secure: secure === 'true' })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast('SMTP settings saved', 'success'); loadSmtp(); }
|
||||
else showToast(data.error || 'Save failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
}
|
||||
|
||||
function clearSmtp() {
|
||||
if (!confirm('Clear DB SMTP settings? Env vars will be used if set.')) return;
|
||||
fetch('/api/admin/config/smtp', { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast('SMTP DB settings cleared', 'info'); loadSmtp(); }
|
||||
else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
}
|
||||
|
||||
// ---- MODELS ----
|
||||
|
||||
function loadModels() {
|
||||
fetch('/api/admin/config/models', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
renderModelsList(data.models || [], data.custom || []);
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function renderModelsList(models, custom) {
|
||||
var container = document.getElementById('cms-models-list');
|
||||
if (!container) return;
|
||||
|
||||
var rows = models.map(function(m) {
|
||||
return '<div class="model-row">' +
|
||||
'<div class="model-row-name">' + esc(m.name) +
|
||||
' <span class="model-row-tag">' + esc(m.tag || '') + '</span>' +
|
||||
'</div>' +
|
||||
'<span class="model-row-cost">' + esc(m.cost || '') + '</span>' +
|
||||
'<label style="display:flex;align-items:center;gap:4px;font-size:12px;">' +
|
||||
'<input type="checkbox" class="model-toggle" data-id="' + esc(m.id) + '" ' + (m.enabled !== false ? 'checked' : '') + '> Enabled' +
|
||||
'</label>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
if (custom.length > 0) {
|
||||
rows.push('<div style="font-size:11px;font-weight:700;color:var(--g500);text-transform:uppercase;letter-spacing:0.5px;margin-top:8px;padding-top:8px;border-top:1px solid var(--g200);">Custom Models</div>');
|
||||
custom.forEach(function(m) {
|
||||
rows.push('<div class="model-row">' +
|
||||
'<div class="model-row-name">' + esc(m.name) + ' <span class="model-row-tag" style="background:var(--blue-light);color:var(--blue);">CUSTOM</span></div>' +
|
||||
'<span class="model-row-cost" style="font-family:monospace;font-size:11px;">' + esc(m.id) + '</span>' +
|
||||
'<button class="btn-sm btn-ghost model-delete-custom" data-id="' + esc(m.id) + '" style="color:var(--red);font-size:11px;"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>');
|
||||
});
|
||||
}
|
||||
|
||||
container.innerHTML = rows.join('');
|
||||
|
||||
container.querySelectorAll('.model-toggle').forEach(function(cb) {
|
||||
cb.addEventListener('change', function() {
|
||||
fetch('/api/admin/config/models/toggle', {
|
||||
method: 'PUT', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ modelId: cb.dataset.id, enabled: cb.checked })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { if (!d.success) showToast(d.error || 'Failed', 'error'); })
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.model-delete-custom').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Remove custom model "' + btn.dataset.id + '"?')) return;
|
||||
fetch('/api/admin/config/models/custom/' + encodeURIComponent(btn.dataset.id), { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { if (d.success) { showToast('Removed', 'info'); loadModels(); } })
|
||||
.catch(function() {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addCustomModel() {
|
||||
var id = prompt('OpenRouter Model ID (e.g. google/gemini-2.5-pro):');
|
||||
if (!id || !id.trim()) return;
|
||||
var name = prompt('Display name:', id.split('/').pop());
|
||||
if (!name) return;
|
||||
var cost = prompt('Cost estimate (e.g. ~$0.01):', '~$0.01');
|
||||
|
||||
fetch('/api/admin/config/models/custom', {
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ id: id.trim(), name: name.trim(), cost: cost || '?' })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast('Custom model added', 'success'); loadModels(); }
|
||||
else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
}
|
||||
|
||||
// ---- AUTO-DELETE / RESET ----
|
||||
|
||||
function saveAutoDelete() {
|
||||
var days = document.getElementById('cms-auto-delete-days').value;
|
||||
putConfig('site.auto_delete_days', days)
|
||||
.then(function() {
|
||||
var el = document.getElementById('admin-auto-delete-days');
|
||||
if (el) el.textContent = days;
|
||||
showToast('Auto-delete set to ' + days + ' days', 'success');
|
||||
})
|
||||
.catch(function() { showToast('Save failed', 'error'); });
|
||||
}
|
||||
|
||||
function resetAllDefaults() {
|
||||
if (!confirm('Reset all settings to factory defaults?\n\nAnnouncements, feature flags, email templates will be reset. SMTP and custom models are preserved.')) return;
|
||||
fetch('/api/admin/config/reset-defaults', { method: 'POST', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast(data.message || 'Reset to defaults', 'success');
|
||||
cmsLoaded = false;
|
||||
loadCms();
|
||||
} else showToast(data.error || 'Reset failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
}
|
||||
|
||||
// ---- HELPERS ----
|
||||
|
||||
function putConfig(key, value) {
|
||||
return fetch('/api/admin/config/' + encodeURIComponent(key), {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ value: value })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Failed');
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -52,6 +52,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
activateTab('encounter'); // default
|
||||
}
|
||||
|
||||
// Load settings data when settings tab is activated
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail && e.detail.tab === 'settings') {
|
||||
if (typeof load2FAStatus === 'function') load2FAStatus();
|
||||
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
|
||||
if (typeof loadMemories === 'function') loadMemories();
|
||||
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
|
||||
}
|
||||
});
|
||||
|
||||
// --- MODEL SELECTOR ---
|
||||
var modelSelect = document.getElementById('global-model-select');
|
||||
var costBadge = document.getElementById('model-cost-badge');
|
||||
|
|
@ -116,6 +126,30 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
// GLOBAL FUNCTIONS (must be outside DOMContentLoaded)
|
||||
// ============================================================
|
||||
|
||||
// ── Announcement banner ────────────────────────────────────
|
||||
function loadAnnouncement() {
|
||||
fetch('/api/admin/config/announcement', { headers: getAuthHeaders() })
|
||||
.then(function(r) { if (!r.ok) throw new Error('not ok'); return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var banner = document.getElementById('announcement-banner');
|
||||
var text = document.getElementById('announcement-text');
|
||||
var icon = document.getElementById('announcement-icon');
|
||||
if (!banner || !text) return;
|
||||
if (data.enabled && data.text && data.text.trim()) {
|
||||
text.textContent = data.text;
|
||||
// type → icon + class
|
||||
var icons = { info: 'fa-info-circle', warning: 'fa-triangle-exclamation', error: 'fa-circle-xmark', success: 'fa-circle-check' };
|
||||
var type = data.type || 'info';
|
||||
if (icon) { icon.className = 'fas ' + (icons[type] || icons.info); }
|
||||
banner.className = 'announcement-banner ann-' + type;
|
||||
} else {
|
||||
banner.className = 'announcement-banner hidden';
|
||||
}
|
||||
})
|
||||
.catch(function() {}); // silently ignore if endpoint not yet available
|
||||
}
|
||||
|
||||
function getSelectedModel() {
|
||||
// Prefer the active tab's own model selector if present
|
||||
var activeTab = document.querySelector('.tab-content.active');
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
|
||||
console.log('[Auth] ✅ Entered app as:', user.email, 'Role:', user.role);
|
||||
|
||||
// Load announcement banner if function is available
|
||||
if (typeof loadAnnouncement === 'function') loadAnnouncement();
|
||||
}
|
||||
|
||||
function exitApp() {
|
||||
|
|
@ -157,23 +160,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
showToast('Logged out', 'info');
|
||||
}
|
||||
|
||||
// Settings button
|
||||
// Settings button — navigate to settings tab
|
||||
if (target.id === 'btn-settings' || target.closest('#btn-settings')) {
|
||||
e.preventDefault();
|
||||
var modal = document.getElementById('settings-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
modal.className = modal.className.replace('hidden', '').trim();
|
||||
load2FAStatus();
|
||||
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Close settings
|
||||
if (target.id === 'close-settings' || target.closest('#close-settings')) {
|
||||
e.preventDefault();
|
||||
var modal = document.getElementById('settings-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
var settingsBtn = document.querySelector('.tab-btn[data-tab="settings"]');
|
||||
if (settingsBtn) settingsBtn.click();
|
||||
load2FAStatus();
|
||||
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
|
||||
if (typeof loadMemories === 'function') loadMemories();
|
||||
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
|
||||
}
|
||||
|
||||
// Setup 2FA
|
||||
|
|
|
|||
342
public/js/encounters.js
Normal file
342
public/js/encounters.js
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
// ============================================================
|
||||
// ENCOUNTERS.JS — Save/resume/pause encounter progress
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
// ── Pause/Resume for recording buttons ─────────────────────────────────
|
||||
// Each recording module (encounter, dictation) gets a pause button wired here.
|
||||
|
||||
function wirePause(recordBtnId, pauseBtnId, getRecorder) {
|
||||
var pauseBtn = document.getElementById(pauseBtnId);
|
||||
if (!pauseBtn) return;
|
||||
var isPaused = false;
|
||||
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
var rec = getRecorder();
|
||||
if (!rec || !rec.mediaRecorder) return;
|
||||
if (!isPaused) {
|
||||
if (rec.mediaRecorder.state === 'recording') {
|
||||
rec.mediaRecorder.pause();
|
||||
isPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
pauseBtn.classList.remove('btn-ghost');
|
||||
pauseBtn.classList.add('btn-primary');
|
||||
}
|
||||
} else {
|
||||
if (rec.mediaRecorder.state === 'paused') {
|
||||
rec.mediaRecorder.resume();
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset when recording stops
|
||||
document.addEventListener('recording-stopped', function(e) {
|
||||
if (e.detail && e.detail.module === recordBtnId.replace('-record-btn', '')) {
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
pauseBtn.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
document.addEventListener('recording-started', function(e) {
|
||||
if (e.detail && e.detail.module === recordBtnId.replace('-record-btn', '')) {
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
pauseBtn.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Expose pause wiring globally (called by each recording module)
|
||||
window.wirePauseButton = wirePause;
|
||||
|
||||
// ── Saved Encounters Manager ───────────────────────────────────────────
|
||||
|
||||
var _savedEncounters = [];
|
||||
var _loadCallback = null; // set by each module to handle loading a saved encounter
|
||||
|
||||
// Register a load handler for a specific tab type
|
||||
window.registerEncounterLoadHandler = function(type, fn) {
|
||||
if (!window._encLoadHandlers) window._encLoadHandlers = {};
|
||||
window._encLoadHandlers[type] = fn;
|
||||
};
|
||||
|
||||
// Save current encounter state
|
||||
window.saveEncounter = function(opts) {
|
||||
// opts: { id, label, enc_type, transcript, generated_note, partial_data }
|
||||
if (!opts.label || !opts.label.trim()) { showToast('Enter a patient label first', 'error'); return; }
|
||||
fetch('/api/encounters/saved', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(opts)
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast('Encounter saved (' + (opts.label || '') + ')', 'success');
|
||||
// Store the returned ID for subsequent saves
|
||||
if (data.id && opts.onSaved) opts.onSaved(data.id);
|
||||
loadSavedEncountersList();
|
||||
} else {
|
||||
showToast(data.error || 'Save failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Save failed', 'error'); });
|
||||
};
|
||||
|
||||
// Load list and refresh all displays (settings page + open popovers)
|
||||
function loadSavedEncountersList(targetContainer) {
|
||||
fetch('/api/encounters/saved', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
_savedEncounters = data.encounters || [];
|
||||
renderSavedList();
|
||||
// Refresh any open popovers
|
||||
['enc', 'dict', 'wv', 'sick'].forEach(function(pfx) {
|
||||
var pop = document.getElementById(pfx + '-load-popover');
|
||||
if (pop && !pop.classList.contains('hidden')) {
|
||||
var search = pop.querySelector('.enc-load-search');
|
||||
renderPopoverList(pfx, search ? search.value : '');
|
||||
}
|
||||
});
|
||||
if (targetContainer) {
|
||||
renderPopoverList(targetContainer, '');
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function renderPopoverList(pfx, query) {
|
||||
var listEl = document.getElementById(pfx + '-pop-list');
|
||||
if (!listEl) return;
|
||||
var filtered = _savedEncounters.filter(function(enc) {
|
||||
if (!query) return true;
|
||||
return (enc.label || '').toLowerCase().indexOf(query.toLowerCase()) !== -1 ||
|
||||
(enc.enc_type || '').toLowerCase().indexOf(query.toLowerCase()) !== -1;
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = '<div style="padding:10px 12px;color:var(--g400);font-size:13px;">' + (query ? 'No matches.' : 'No saved encounters.') + '</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = filtered.map(function(enc) {
|
||||
var date = enc.updated_at ? new Date(enc.updated_at).toLocaleDateString() : '';
|
||||
return '<div class="enc-pop-item" data-id="' + enc.id + '" data-type="' + esc(enc.enc_type) + '">' +
|
||||
'<div style="flex:1;min-width:0;">' +
|
||||
'<div style="font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + esc(enc.label) + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--g500);">' + esc(enc.enc_type) + ' · ' + date + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-sm btn-ghost enc-pop-delete" data-id="' + enc.id + '" style="color:var(--red);flex-shrink:0;" title="Delete"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
listEl.querySelectorAll('.enc-pop-item').forEach(function(item) {
|
||||
item.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.enc-pop-delete')) return;
|
||||
resumeEncounter(item.dataset.id, item.dataset.type);
|
||||
// close popover
|
||||
var pop = item.closest('.enc-load-popover');
|
||||
if (pop) pop.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('.enc-pop-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
deleteEncounter(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSavedList() {
|
||||
var container = document.getElementById('saved-enc-list');
|
||||
if (!container) return;
|
||||
if (_savedEncounters.length === 0) {
|
||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No saved encounters.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = _savedEncounters.map(function(enc) {
|
||||
var date = enc.updated_at ? new Date(enc.updated_at).toLocaleDateString() : '';
|
||||
var expires = enc.expires_at ? new Date(enc.expires_at).toLocaleDateString() : '';
|
||||
var preview = (enc.transcript_preview || '').substring(0, 80);
|
||||
return '<div class="saved-enc-item" data-id="' + enc.id + '">' +
|
||||
'<div style="flex:1;">' +
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
|
||||
'<span class="saved-enc-label">' + esc(enc.label) + '</span>' +
|
||||
'<span class="saved-enc-type">' + esc(enc.enc_type) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="saved-enc-meta">' + date + ' · expires ' + expires + (preview ? ' · ' + esc(preview) + '...' : '') + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-sm btn-primary enc-resume-btn" data-id="' + enc.id + '" data-type="' + esc(enc.enc_type) + '">Resume</button>' +
|
||||
'<button class="btn-sm btn-ghost enc-delete-btn" data-id="' + enc.id + '" style="color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.enc-resume-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { resumeEncounter(btn.dataset.id, btn.dataset.type); });
|
||||
});
|
||||
container.querySelectorAll('.enc-delete-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { deleteEncounter(btn.dataset.id); });
|
||||
});
|
||||
}
|
||||
|
||||
function openLoadPopover(pfx) {
|
||||
var popover = document.getElementById(pfx + '-load-popover');
|
||||
if (!popover) return;
|
||||
var isHidden = popover.classList.contains('hidden');
|
||||
// Close all popovers first
|
||||
['enc', 'dict', 'wv', 'sick'].forEach(function(p) {
|
||||
var pop = document.getElementById(p + '-load-popover');
|
||||
if (pop) pop.classList.add('hidden');
|
||||
});
|
||||
if (!isHidden) return; // was open, now closed
|
||||
popover.classList.remove('hidden');
|
||||
var searchEl = popover.querySelector('.enc-load-search');
|
||||
if (searchEl) { searchEl.value = ''; searchEl.focus(); }
|
||||
if (_savedEncounters.length === 0) {
|
||||
loadSavedEncountersList();
|
||||
} else {
|
||||
renderPopoverList(pfx, '');
|
||||
}
|
||||
// Wire search
|
||||
if (searchEl && !searchEl._wired) {
|
||||
searchEl._wired = true;
|
||||
searchEl.addEventListener('input', function() { renderPopoverList(pfx, this.value); });
|
||||
}
|
||||
// Wire close button — stop propagation so doc handler doesn't re-process
|
||||
var closeBtn = popover.querySelector('.enc-pop-close');
|
||||
if (closeBtn && !closeBtn._wired) {
|
||||
closeBtn._wired = true;
|
||||
closeBtn.addEventListener('click', function(e) { e.stopPropagation(); popover.classList.add('hidden'); });
|
||||
}
|
||||
// Stop click propagation inside popover so outside-click handler doesn't fire for inner clicks
|
||||
if (!popover._stopPropWired) {
|
||||
popover._stopPropWired = true;
|
||||
popover.addEventListener('click', function(e) { e.stopPropagation(); });
|
||||
}
|
||||
}
|
||||
|
||||
function resumeEncounter(id, type) {
|
||||
fetch('/api/encounters/saved/' + id, { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { showToast(data.error || 'Failed', 'error'); return; }
|
||||
var enc = data.encounter;
|
||||
// Close all popovers
|
||||
['enc', 'dict', 'wv', 'sick'].forEach(function(p) {
|
||||
var pop = document.getElementById(p + '-load-popover');
|
||||
if (pop) pop.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Navigate to correct tab
|
||||
var tabMap = {
|
||||
encounter: 'encounter', dictation: 'dictation', hospital: 'hospital',
|
||||
chart: 'chart', soap: 'soap', milestones: 'milestones',
|
||||
wellvisit: 'wellvisit', sickvisit: 'sickvisit'
|
||||
};
|
||||
var tabName = tabMap[enc.enc_type] || 'encounter';
|
||||
var tabBtn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
|
||||
if (tabBtn) tabBtn.click();
|
||||
|
||||
// Fill in data
|
||||
setTimeout(function() {
|
||||
// Fill label
|
||||
var labelEl = document.getElementById(tabName + '-label') || document.getElementById(enc.enc_type + '-label');
|
||||
if (labelEl) labelEl.value = enc.label;
|
||||
|
||||
// Set saved ID so next save updates same record
|
||||
window['_savedEncId_' + tabName] = enc.id;
|
||||
|
||||
// Call tab-specific handler
|
||||
if (window._encLoadHandlers && window._encLoadHandlers[enc.enc_type]) {
|
||||
window._encLoadHandlers[enc.enc_type](enc);
|
||||
} else {
|
||||
// Default: fill transcript field if it exists
|
||||
var transcriptEl = document.getElementById(tabName + '-transcript') || document.getElementById(enc.enc_type + '-transcript');
|
||||
if (transcriptEl && enc.transcript) transcriptEl.textContent = enc.transcript;
|
||||
// Fill generated note
|
||||
if (enc.generated_note) {
|
||||
var noteEl = document.getElementById(enc.enc_type + '-hpi-text') || document.getElementById(tabName + '-hpi-text');
|
||||
if (noteEl) {
|
||||
noteEl.textContent = enc.generated_note;
|
||||
var outputCard = noteEl.closest('.output-card');
|
||||
if (outputCard) outputCard.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
showToast('Encounter "' + enc.label + '" loaded', 'success');
|
||||
}, 200);
|
||||
})
|
||||
.catch(function() { showToast('Load failed', 'error'); });
|
||||
}
|
||||
|
||||
function deleteEncounter(id) {
|
||||
if (!confirm('Delete this saved encounter? This cannot be undone.')) return;
|
||||
fetch('/api/encounters/saved/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast('Deleted', 'info'); loadSavedEncountersList(); }
|
||||
else showToast(data.error || 'Delete failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Delete failed', 'error'); });
|
||||
}
|
||||
|
||||
// Expose loadSavedEncountersList globally so auth.js / settings page can call it
|
||||
window.loadSavedEncountersList = loadSavedEncountersList;
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
// Settings button — refresh saved encounters list
|
||||
if (e.target.closest('#btn-settings')) {
|
||||
loadSavedEncountersList();
|
||||
}
|
||||
// Save buttons per tab
|
||||
if (e.target.closest('#btn-enc-save')) saveFromTab('encounter', 'enc');
|
||||
if (e.target.closest('#btn-dict-save')) saveFromTab('dictation', 'dict');
|
||||
// Load buttons — open inline popovers
|
||||
if (e.target.closest('#btn-enc-load')) openLoadPopover('enc');
|
||||
if (e.target.closest('#btn-dict-load')) openLoadPopover('dict');
|
||||
if (e.target.closest('#btn-wv-load')) openLoadPopover('wv');
|
||||
if (e.target.closest('#btn-sick-load')) openLoadPopover('sick');
|
||||
// Close popovers when clicking outside (popover stops its own propagation so this only fires for outside clicks)
|
||||
if (!e.target.closest('#btn-enc-load') && !e.target.closest('#btn-dict-load') &&
|
||||
!e.target.closest('#btn-wv-load') && !e.target.closest('#btn-sick-load')) {
|
||||
['enc', 'dict', 'wv', 'sick'].forEach(function(p) {
|
||||
var pop = document.getElementById(p + '-load-popover');
|
||||
if (pop) pop.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function saveFromTab(type, prefix) {
|
||||
var labelEl = document.getElementById(prefix + '-label');
|
||||
var transcriptEl = document.getElementById(prefix + '-transcript');
|
||||
var noteEl = document.getElementById(prefix + '-hpi-text');
|
||||
var label = labelEl ? labelEl.value.trim() : '';
|
||||
if (!label) { showToast('Enter a patient label before saving', 'error'); return; }
|
||||
|
||||
var savedId = window['_savedEncId_' + type] || null;
|
||||
|
||||
window.saveEncounter({
|
||||
id: savedId,
|
||||
label: label,
|
||||
enc_type: type,
|
||||
transcript: transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '',
|
||||
generated_note: noteEl ? (noteEl.innerText || noteEl.textContent || '') : '',
|
||||
onSaved: function(newId) { window['_savedEncId_' + type] = newId; }
|
||||
});
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
|
||||
console.log('✅ Encounters module loaded');
|
||||
|
||||
})();
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
(function() {
|
||||
var recordBtn = document.getElementById('enc-record-btn');
|
||||
var pauseBtn = document.getElementById('enc-pause-btn');
|
||||
var indicator = document.getElementById('enc-recording-indicator');
|
||||
var timerEl = document.getElementById('enc-timer');
|
||||
var transcript = document.getElementById('enc-transcript');
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
var recorder = new AudioRecorder();
|
||||
var timer = createTimer(timerEl);
|
||||
var isRecording = false;
|
||||
var isPaused = false;
|
||||
var recognition = createSpeechRecognition();
|
||||
var finalText = '';
|
||||
|
||||
|
|
@ -24,7 +26,7 @@
|
|||
}
|
||||
transcript.innerHTML = finalText + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
|
||||
};
|
||||
recognition.onend = function() { if (isRecording) try { recognition.start(); } catch(e) {} };
|
||||
recognition.onend = function() { if (isRecording && !isPaused) try { recognition.start(); } catch(e) {} };
|
||||
recognition.onerror = function() {};
|
||||
}
|
||||
|
||||
|
|
@ -33,20 +35,26 @@
|
|||
finalText = '';
|
||||
recorder.start().then(function() {
|
||||
isRecording = true;
|
||||
isPaused = false;
|
||||
recordBtn.classList.add('recording');
|
||||
recordBtn.querySelector('span').textContent = 'Stop';
|
||||
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
||||
indicator.classList.remove('hidden');
|
||||
timer.start();
|
||||
if (recognition) try { recognition.start(); } catch(e) {}
|
||||
showToast('Recording started', 'info');
|
||||
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'enc' } }));
|
||||
}).catch(function() { showToast('Microphone denied', 'error'); });
|
||||
} else {
|
||||
isRecording = false;
|
||||
isPaused = false;
|
||||
var dur = timer.stop();
|
||||
recordBtn.classList.remove('recording');
|
||||
recordBtn.querySelector('span').textContent = 'Start Recording';
|
||||
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; }
|
||||
indicator.classList.add('hidden');
|
||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'enc' } }));
|
||||
|
||||
showLoading('Transcribing...');
|
||||
recorder.stop().then(function(blob) {
|
||||
|
|
@ -60,7 +68,42 @@
|
|||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', function() { transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden'); });
|
||||
// Pause / Resume
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
if (!isRecording) return;
|
||||
if (!isPaused) {
|
||||
if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'recording') {
|
||||
recorder.mediaRecorder.pause();
|
||||
timer.stop();
|
||||
isPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
pauseBtn.classList.add('btn-primary');
|
||||
pauseBtn.classList.remove('btn-ghost');
|
||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||
showToast('Recording paused', 'info');
|
||||
}
|
||||
} else {
|
||||
if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'paused') {
|
||||
recorder.mediaRecorder.resume();
|
||||
timer.start();
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
if (recognition) try { recognition.start(); } catch(e) {}
|
||||
showToast('Recording resumed', 'info');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearBtn.addEventListener('click', function() {
|
||||
transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
|
||||
window._savedEncId_encounter = null;
|
||||
var labelEl = document.getElementById('enc-label');
|
||||
if (labelEl) labelEl.value = '';
|
||||
});
|
||||
|
||||
generateBtn.addEventListener('click', function() {
|
||||
var text = transcript.innerText.trim();
|
||||
|
|
@ -96,5 +139,17 @@
|
|||
document.getElementById('enc-refine-btn').addEventListener('click', function() { refineDocument('enc-hpi-text', 'enc-refine-input'); });
|
||||
document.getElementById('enc-shorten-btn').addEventListener('click', function() { shortenDocument('enc-hpi-text'); });
|
||||
|
||||
// Register load handler for resuming saved encounters
|
||||
if (typeof registerEncounterLoadHandler === 'function') {
|
||||
registerEncounterLoadHandler('encounter', function(enc) {
|
||||
if (enc.transcript) transcript.textContent = enc.transcript;
|
||||
if (enc.generated_note) {
|
||||
hpiText.textContent = enc.generated_note;
|
||||
outputCard.classList.remove('hidden');
|
||||
}
|
||||
try { var pd = JSON.parse(enc.partial_data || '{}'); if (pd.age) document.getElementById('enc-age').value = pd.age; if (pd.gender) document.getElementById('enc-gender').value = pd.gender; } catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Encounter module loaded');
|
||||
})();
|
||||
|
|
|
|||
174
public/js/memories.js
Normal file
174
public/js/memories.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// ============================================================
|
||||
// MEMORIES.JS — User template/memory storage + settings UI
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
var _memories = [];
|
||||
var _editingId = null;
|
||||
|
||||
var CATEGORY_LABELS = {
|
||||
physical_exam: 'Physical Exam',
|
||||
ros: 'Review of Systems',
|
||||
encounter_format: 'Encounter Format',
|
||||
family_history: 'Family History',
|
||||
assessment_plan: 'Assessment & Plan',
|
||||
custom: 'Custom'
|
||||
};
|
||||
|
||||
// ── Load memories ────────────────────────────────────────────────────────
|
||||
|
||||
function loadMemories() {
|
||||
fetch('/api/memories', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
_memories = data.memories || [];
|
||||
renderMemoryList();
|
||||
})
|
||||
.catch(function(err) { console.error('[Memories] Load failed:', err); });
|
||||
}
|
||||
|
||||
function renderMemoryList() {
|
||||
var container = document.getElementById('mem-list');
|
||||
if (!container) return;
|
||||
if (_memories.length === 0) {
|
||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No templates saved yet. Add one above.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = _memories.map(function(m) {
|
||||
var catLabel = CATEGORY_LABELS[m.category] || m.category;
|
||||
var preview = (m.content || '').substring(0, 100).replace(/\n/g, ' ');
|
||||
return '<div class="mem-item" data-id="' + m.id + '">' +
|
||||
'<div class="mem-item-info">' +
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
|
||||
'<span class="mem-item-name">' + esc(m.name) + '</span>' +
|
||||
'<span class="mem-item-cat">' + esc(catLabel) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="mem-item-preview">' + esc(preview) + (m.content && m.content.length > 100 ? '...' : '') + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-sm btn-ghost mem-edit-btn" data-id="' + m.id + '" title="Edit"><i class="fas fa-pen"></i></button>' +
|
||||
'<button class="btn-sm btn-ghost mem-delete-btn" data-id="' + m.id + '" style="color:var(--red);" title="Delete"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.mem-edit-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { editMemory(parseInt(btn.dataset.id)); });
|
||||
});
|
||||
container.querySelectorAll('.mem-delete-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { deleteMemory(parseInt(btn.dataset.id)); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Save memory ──────────────────────────────────────────────────────────
|
||||
|
||||
function saveMemory() {
|
||||
var name = document.getElementById('mem-name').value.trim();
|
||||
var category = document.getElementById('mem-category').value;
|
||||
var content = document.getElementById('mem-content').value.trim();
|
||||
|
||||
if (!name) { showToast('Enter a template name', 'error'); return; }
|
||||
if (!content) { showToast('Enter template content', 'error'); return; }
|
||||
|
||||
var url = _editingId ? '/api/memories/' + _editingId : '/api/memories';
|
||||
var method = _editingId ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ name: name, category: category, content: content })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast(_editingId ? 'Template updated' : 'Template saved', 'success');
|
||||
cancelEdit();
|
||||
loadMemories();
|
||||
} else {
|
||||
showToast(data.error || 'Save failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Save failed', 'error'); });
|
||||
}
|
||||
|
||||
function editMemory(id) {
|
||||
var mem = _memories.find(function(m) { return m.id === id; });
|
||||
if (!mem) return;
|
||||
_editingId = id;
|
||||
document.getElementById('mem-name').value = mem.name;
|
||||
document.getElementById('mem-category').value = mem.category;
|
||||
document.getElementById('mem-content').value = mem.content;
|
||||
|
||||
var btn = document.getElementById('btn-mem-save');
|
||||
if (btn) btn.innerHTML = '<i class="fas fa-check"></i> Update Template';
|
||||
|
||||
// Add cancel button if not present
|
||||
if (!document.getElementById('btn-mem-cancel')) {
|
||||
var cancel = document.createElement('button');
|
||||
cancel.id = 'btn-mem-cancel';
|
||||
cancel.className = 'btn-sm btn-ghost';
|
||||
cancel.innerHTML = '<i class="fas fa-times"></i> Cancel';
|
||||
cancel.style.marginLeft = '6px';
|
||||
cancel.addEventListener('click', cancelEdit);
|
||||
if (btn && btn.parentNode) btn.parentNode.appendChild(cancel);
|
||||
}
|
||||
document.getElementById('mem-name').focus();
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
_editingId = null;
|
||||
document.getElementById('mem-name').value = '';
|
||||
document.getElementById('mem-content').value = '';
|
||||
var btn = document.getElementById('btn-mem-save');
|
||||
if (btn) btn.innerHTML = '<i class="fas fa-plus"></i> Add Template';
|
||||
var cancel = document.getElementById('btn-mem-cancel');
|
||||
if (cancel) cancel.remove();
|
||||
}
|
||||
|
||||
function deleteMemory(id) {
|
||||
var mem = _memories.find(function(m) { return m.id === id; });
|
||||
if (!confirm('Delete template "' + (mem ? mem.name : '') + '"?')) return;
|
||||
fetch('/api/memories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast('Template deleted', 'info'); loadMemories(); }
|
||||
else showToast(data.error || 'Delete failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Delete failed', 'error'); });
|
||||
}
|
||||
|
||||
// ── Event wiring ─────────────────────────────────────────────────────────
|
||||
|
||||
// Expose so auth.js can call on settings tab navigation
|
||||
window.loadMemories = loadMemories;
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
// Settings button or settings tab — load memories
|
||||
if (e.target.closest('#btn-settings') || (e.target.closest('.tab-btn') && e.target.closest('.tab-btn[data-tab="settings"]'))) {
|
||||
setTimeout(loadMemories, 100);
|
||||
}
|
||||
// Save memory button
|
||||
if (e.target.closest('#btn-mem-save')) {
|
||||
saveMemory();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Expose context for AI generation ────────────────────────────────────
|
||||
|
||||
// Call this before any AI generation to get user memory context.
|
||||
// Always fetches from the API so it works even if _memories cache is empty.
|
||||
window.getUserMemoryContext = function() {
|
||||
return fetch('/api/memories/context', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) { return data.success ? (data.context || '') : ''; })
|
||||
.catch(function() { return ''; });
|
||||
};
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
console.log('✅ Memories module loaded');
|
||||
|
||||
})();
|
||||
970
public/js/shadess.js
Normal file
970
public/js/shadess.js
Normal file
|
|
@ -0,0 +1,970 @@
|
|||
// ============================================================
|
||||
// SHADESS.JS — SSHADESS psychosocial screening form + well visit note
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
// ── SSHADESS domain definitions ──────────────────────────────────────────
|
||||
// Each domain has key questions (most important shown first) + comment field
|
||||
var SHADESS_DOMAINS = [
|
||||
{
|
||||
key: 'strengths',
|
||||
label: 'Strengths',
|
||||
icon: 'fa-star',
|
||||
color: '#f59e0b',
|
||||
intro: 'Starting with what you do well helps us get to know you better.',
|
||||
questions: [
|
||||
{ id: 'str1', text: 'Has something they are proud of or enjoy', type: 'yn' },
|
||||
{ id: 'str2', text: 'Describes self positively when asked', type: 'yn' },
|
||||
{ id: 'str3', text: 'Has at least one trusted adult they can talk to', type: 'yn' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'school',
|
||||
label: 'School',
|
||||
icon: 'fa-graduation-cap',
|
||||
color: '#3b82f6',
|
||||
intro: 'Ask about school performance, attendance, and future plans.',
|
||||
questions: [
|
||||
{ id: 'sch1', text: 'Grades are satisfactory / doing their best', type: 'yn' },
|
||||
{ id: 'sch2', text: 'Likes school or finds something enjoyable about it', type: 'yn' },
|
||||
{ id: 'sch3', text: 'Regular attendance (no truancy concerns)', type: 'yn' },
|
||||
{ id: 'sch4', text: 'Has plans or goals for the future', type: 'yn' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'home',
|
||||
label: 'Home',
|
||||
icon: 'fa-house',
|
||||
color: '#10b981',
|
||||
intro: 'Ask about living situation and family relationships.',
|
||||
questions: [
|
||||
{ id: 'hom1', text: 'Stable living situation', type: 'yn' },
|
||||
{ id: 'hom2', text: 'Gets along with people at home', type: 'yn' },
|
||||
{ id: 'hom3', text: 'Would talk to family member if stressed', type: 'yn' },
|
||||
{ id: 'hom4', text: 'Has experienced household violence or instability (concern if YES)', type: 'yn', concern_if: true },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'activities',
|
||||
label: 'Activities',
|
||||
icon: 'fa-users',
|
||||
color: '#8b5cf6',
|
||||
intro: 'Ask about friends, hobbies, and peer relationships.',
|
||||
questions: [
|
||||
{ id: 'act1', text: 'Has friends and spends time with them', type: 'yn' },
|
||||
{ id: 'act2', text: 'Involved in sports, clubs, or hobbies', type: 'yn' },
|
||||
{ id: 'act3', text: 'Social media/screen use within healthy limits', type: 'yn' },
|
||||
{ id: 'act4', text: 'Has experienced bullying (concern if YES)', type: 'yn', concern_if: true },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'drugs',
|
||||
label: 'Drugs / Substances',
|
||||
icon: 'fa-pills',
|
||||
color: '#ef4444',
|
||||
intro: 'This is confidential. Ask in private.',
|
||||
questions: [
|
||||
{ id: 'drg1', text: 'Has tried cigarettes / vaping / tobacco', type: 'yn', concern_if: true },
|
||||
{ id: 'drg2', text: 'Has tried alcohol', type: 'yn', concern_if: true },
|
||||
{ id: 'drg3', text: 'Has tried marijuana or other drugs', type: 'yn', concern_if: true },
|
||||
{ id: 'drg4', text: 'Friends use substances', type: 'yn' },
|
||||
{ id: 'drg5', text: 'CRAFFT screen result (if done)', type: 'text', placeholder: 'e.g., Score 0 — low risk' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'emotions',
|
||||
label: 'Emotions / Eating',
|
||||
icon: 'fa-heart',
|
||||
color: '#ec4899',
|
||||
intro: 'Screen for depression, anxiety, and disordered eating.',
|
||||
questions: [
|
||||
{ id: 'emo1', text: 'Feeling down, sad, or hopeless recently', type: 'yn', concern_if: true },
|
||||
{ id: 'emo2', text: 'Feeling unusually stressed or anxious', type: 'yn', concern_if: true },
|
||||
{ id: 'emo3', text: 'Trouble sleeping', type: 'yn' },
|
||||
{ id: 'emo4', text: 'PHQ-A / depression screen result (if done)', type: 'text', placeholder: 'e.g., PHQ-A score 3 — minimal' },
|
||||
{ id: 'emo5', text: 'Happy with eating habits and body image', type: 'yn' },
|
||||
{ id: 'emo6', text: 'Restricting food / purging / using diet pills (concern if YES)', type: 'yn', concern_if: true },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'sexuality',
|
||||
label: 'Sexuality',
|
||||
icon: 'fa-shield-heart',
|
||||
color: '#f97316',
|
||||
intro: 'Ask in private. Normalize the questions.',
|
||||
questions: [
|
||||
{ id: 'sex1', text: 'Comfortable discussing attraction/identity', type: 'yn' },
|
||||
{ id: 'sex2', text: 'Sexually active', type: 'yn' },
|
||||
{ id: 'sex3', text: 'Uses protection consistently if sexually active', type: 'yn' },
|
||||
{ id: 'sex4', text: 'History of unwanted sexual contact (concern if YES)', type: 'yn', concern_if: true },
|
||||
{ id: 'sex5', text: 'STI screening indicated/done', type: 'yn' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'safety',
|
||||
label: 'Safety',
|
||||
icon: 'fa-shield-halved',
|
||||
color: '#6366f1',
|
||||
intro: 'Ask about violence, weapons, and suicidal ideation.',
|
||||
questions: [
|
||||
{ id: 'saf1', text: 'Feels safe at school and home', type: 'yn' },
|
||||
{ id: 'saf2', text: 'Carries a weapon (concern if YES)', type: 'yn', concern_if: true },
|
||||
{ id: 'saf3', text: 'Has been in physical fights recently', type: 'yn', concern_if: true },
|
||||
{ id: 'saf4', text: 'Wears seatbelt; safe driving practices', type: 'yn' },
|
||||
{ id: 'saf5', text: 'Thoughts of hurting self or suicide (concern if YES — STAT eval)', type: 'yn', concern_if: true },
|
||||
{ id: 'saf6', text: 'Columbia/ASQ suicide screen result (if done)', type: 'text', placeholder: 'e.g., ASQ: negative' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// ── Render SHADESS form ──────────────────────────────────────────────────
|
||||
var _shadessAnswers = {}; // key -> { questions: {id: answer}, comment: '', concern: false, skipped: false }
|
||||
|
||||
function initShadess() {
|
||||
var container = document.getElementById('shadess-domains');
|
||||
if (!container) return;
|
||||
|
||||
// Initialize answers object
|
||||
SHADESS_DOMAINS.forEach(function(d) {
|
||||
_shadessAnswers[d.key] = { questions: {}, comment: '', concern: false, skipped: false };
|
||||
});
|
||||
|
||||
container.innerHTML = SHADESS_DOMAINS.map(function(domain) {
|
||||
return renderDomain(domain);
|
||||
}).join('');
|
||||
|
||||
// Wire up events
|
||||
container.addEventListener('change', function(e) {
|
||||
var el = e.target;
|
||||
// yn question
|
||||
if (el.classList.contains('shadess-yn')) {
|
||||
var domainKey = el.dataset.domain;
|
||||
var qid = el.dataset.qid;
|
||||
if (_shadessAnswers[domainKey]) {
|
||||
_shadessAnswers[domainKey].questions[qid] = el.value;
|
||||
// Auto-flag concern
|
||||
var domain = SHADESS_DOMAINS.find(function(d) { return d.key === domainKey; });
|
||||
if (domain) {
|
||||
var q = domain.questions.find(function(q) { return q.id === qid; });
|
||||
if (q && q.concern_if === (el.value === 'yes')) {
|
||||
_shadessAnswers[domainKey].concern = true;
|
||||
var flagEl = document.getElementById('shadess-concern-' + domainKey);
|
||||
if (flagEl) flagEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// text question
|
||||
if (el.classList.contains('shadess-text')) {
|
||||
if (_shadessAnswers[el.dataset.domain]) {
|
||||
_shadessAnswers[el.dataset.domain].questions[el.dataset.qid] = el.value;
|
||||
}
|
||||
}
|
||||
// comment
|
||||
if (el.classList.contains('shadess-comment')) {
|
||||
if (_shadessAnswers[el.dataset.domain]) {
|
||||
_shadessAnswers[el.dataset.domain].comment = el.value;
|
||||
}
|
||||
}
|
||||
// skip toggle
|
||||
if (el.classList.contains('shadess-skip')) {
|
||||
if (_shadessAnswers[el.dataset.domain]) {
|
||||
_shadessAnswers[el.dataset.domain].skipped = el.checked;
|
||||
var body = document.getElementById('shadess-body-' + el.dataset.domain);
|
||||
if (body) body.style.opacity = el.checked ? '0.3' : '1';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Concern flag manual toggle
|
||||
container.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.shadess-flag-btn')) {
|
||||
var btn = e.target.closest('.shadess-flag-btn');
|
||||
var domainKey = btn.dataset.domain;
|
||||
if (_shadessAnswers[domainKey]) {
|
||||
_shadessAnswers[domainKey].concern = !_shadessAnswers[domainKey].concern;
|
||||
var flagEl = document.getElementById('shadess-concern-' + domainKey);
|
||||
if (flagEl) flagEl.classList.toggle('hidden', !_shadessAnswers[domainKey].concern);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderDomain(domain) {
|
||||
var qHtml = domain.questions.map(function(q) {
|
||||
if (q.type === 'yn') {
|
||||
return '<div class="shadess-q-row">' +
|
||||
'<span class="shadess-q-text">' + esc(q.text) + (q.concern_if !== undefined ? ' <span style="font-size:10px;color:var(--g400);">(flag if ' + (q.concern_if ? 'Yes' : 'No') + ')</span>' : '') + '</span>' +
|
||||
'<div class="shadess-yn-btns">' +
|
||||
'<select class="shadess-yn" data-domain="' + domain.key + '" data-qid="' + q.id + '" style="font-size:12px;padding:3px 6px;border:1px solid var(--g300);border-radius:6px;">' +
|
||||
'<option value="">—</option><option value="yes">Yes</option><option value="no">No</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
} else {
|
||||
return '<div class="shadess-q-row">' +
|
||||
'<span class="shadess-q-text">' + esc(q.text) + '</span>' +
|
||||
'<input class="shadess-text" data-domain="' + domain.key + '" data-qid="' + q.id + '" type="text" placeholder="' + esc(q.placeholder || '') + '" style="font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:160px;">' +
|
||||
'</div>';
|
||||
}
|
||||
}).join('');
|
||||
|
||||
return '<div class="shadess-domain" style="border-left:3px solid ' + domain.color + ';">' +
|
||||
'<div class="shadess-domain-header">' +
|
||||
'<i class="fas ' + domain.icon + '" style="color:' + domain.color + ';width:16px;"></i>' +
|
||||
'<strong style="font-size:14px;">' + esc(domain.label) + '</strong>' +
|
||||
'<span style="font-size:11px;color:var(--g500);flex:1;margin-left:6px;">' + esc(domain.intro) + '</span>' +
|
||||
'<span id="shadess-concern-' + domain.key + '" class="shadess-concern-badge hidden"><i class="fas fa-triangle-exclamation"></i> Concern</span>' +
|
||||
'<button class="shadess-flag-btn btn-sm btn-ghost" data-domain="' + domain.key + '" style="font-size:11px;" title="Toggle concern flag"><i class="fas fa-flag"></i></button>' +
|
||||
'<label style="font-size:11px;color:var(--g500);display:flex;align-items:center;gap:4px;"><input type="checkbox" class="shadess-skip" data-domain="' + domain.key + '"> Skip</label>' +
|
||||
'</div>' +
|
||||
'<div id="shadess-body-' + domain.key + '" class="shadess-domain-body">' +
|
||||
qHtml +
|
||||
'<div class="shadess-q-row" style="align-items:flex-start;">' +
|
||||
'<span class="shadess-q-text" style="padding-top:4px;">Additional notes:</span>' +
|
||||
'<textarea class="shadess-comment" data-domain="' + domain.key + '" rows="2" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;" placeholder="Free text comments for this domain..."></textarea>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── SHADESS recording (listen in) ────────────────────────────────────────
|
||||
var _shadessRecorder = null;
|
||||
var _shadessTimer = null;
|
||||
var _shadessRecording = false;
|
||||
var _shadessIsPaused = false;
|
||||
var _shhadessRecognition = null;
|
||||
var _shadessTranscript = '';
|
||||
|
||||
function initShadessRecording() {
|
||||
var recordBtn = document.getElementById('shadess-record-btn');
|
||||
var pauseBtn = document.getElementById('shadess-pause-btn');
|
||||
var indicator = document.getElementById('shadess-rec-indicator');
|
||||
var timerEl = document.getElementById('shadess-timer');
|
||||
if (!recordBtn) return;
|
||||
|
||||
_shadessTimer = createTimer(timerEl);
|
||||
_shhadessRecognition = createSpeechRecognition();
|
||||
if (_shhadessRecognition) {
|
||||
_shhadessRecognition.onresult = function(e) {
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) _shadessTranscript += e.results[i][0].transcript + ' ';
|
||||
}
|
||||
};
|
||||
_shhadessRecognition.onend = function() {
|
||||
if (_shadessRecording && !_shadessIsPaused) try { _shhadessRecognition.start(); } catch(e) {}
|
||||
};
|
||||
}
|
||||
|
||||
recordBtn.addEventListener('click', function() {
|
||||
if (!_shadessRecording) {
|
||||
_shadessTranscript = '';
|
||||
_shadessRecorder = new AudioRecorder();
|
||||
_shadessRecorder.start().then(function() {
|
||||
_shadessRecording = true; _shadessIsPaused = false;
|
||||
recordBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
|
||||
recordBtn.classList.add('recording');
|
||||
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
||||
indicator.classList.remove('hidden');
|
||||
_shadessTimer.start();
|
||||
if (_shhadessRecognition) try { _shhadessRecognition.start(); } catch(e) {}
|
||||
showToast('SSHADESS recording started', 'info');
|
||||
}).catch(function() { showToast('Microphone denied', 'error'); });
|
||||
} else {
|
||||
_shadessRecording = false; _shadessIsPaused = false;
|
||||
var dur = _shadessTimer.stop();
|
||||
recordBtn.innerHTML = '<i class="fas fa-microphone"></i> Listen In';
|
||||
recordBtn.classList.remove('recording');
|
||||
if (pauseBtn) { pauseBtn.classList.add('hidden'); }
|
||||
indicator.classList.add('hidden');
|
||||
if (_shhadessRecognition) try { _shhadessRecognition.stop(); } catch(e) {}
|
||||
|
||||
showLoading('Transcribing SSHADESS...');
|
||||
_shadessRecorder.stop().then(function(blob) {
|
||||
if (!blob || blob.size === 0) { hideLoading(); return; }
|
||||
return transcribeAudio(blob).then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
_shadessTranscript = data.text;
|
||||
showToast('SSHADESS audio transcribed (' + dur + 's)', 'success');
|
||||
}
|
||||
});
|
||||
}).catch(function() { hideLoading(); });
|
||||
}
|
||||
});
|
||||
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
if (!_shadessRecording || !_shadessRecorder || !_shadessRecorder.mediaRecorder) return;
|
||||
if (!_shadessIsPaused) {
|
||||
_shadessRecorder.mediaRecorder.pause();
|
||||
_shadessTimer.stop();
|
||||
_shadessIsPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
if (_shhadessRecognition) try { _shhadessRecognition.stop(); } catch(e) {}
|
||||
} else {
|
||||
_shadessRecorder.mediaRecorder.resume();
|
||||
_shadessTimer.start();
|
||||
_shadessIsPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
if (_shhadessRecognition) try { _shhadessRecognition.start(); } catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generate SSHADESS assessment ─────────────────────────────────────────
|
||||
function generateShadess() {
|
||||
var age = document.getElementById('shadess-age').value;
|
||||
var gender = document.getElementById('shadess-gender').value;
|
||||
|
||||
// Build domains payload from answers
|
||||
var domains = {};
|
||||
SHADESS_DOMAINS.forEach(function(d) {
|
||||
var ans = _shadessAnswers[d.key];
|
||||
var questions = d.questions.map(function(q) {
|
||||
return { id: q.id, text: q.text, answer: ans.questions[q.id] || null };
|
||||
}).filter(function(q) { return q.answer !== null && q.answer !== ''; });
|
||||
|
||||
domains[d.key] = {
|
||||
skipped: ans.skipped,
|
||||
questions: questions,
|
||||
comment: ans.comment,
|
||||
concern: ans.concern
|
||||
};
|
||||
});
|
||||
|
||||
// Check something is filled in
|
||||
var hasData = Object.values(domains).some(function(d) {
|
||||
return !d.skipped && (d.questions.length > 0 || d.comment);
|
||||
});
|
||||
if (!hasData && !_shadessTranscript) {
|
||||
showToast('Fill in at least one domain before generating', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading('Generating SSHADESS assessment...');
|
||||
|
||||
var modelEl = document.querySelector('#wv-panel-shadess .tab-model-select');
|
||||
|
||||
fetch('/api/well-visit/shadess', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
patientAge: age,
|
||||
patientGender: gender,
|
||||
domains: domains,
|
||||
dictationText: _shadessTranscript || null,
|
||||
model: modelEl ? modelEl.value : getSelectedModel()
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
var out = document.getElementById('shadess-result-text');
|
||||
var outCard = document.getElementById('shadess-output');
|
||||
var tag = document.getElementById('shadess-model-tag');
|
||||
if (out) out.textContent = data.assessment;
|
||||
if (tag) tag.textContent = (data.model || '').split('/').pop();
|
||||
if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); }
|
||||
showToast('SSHADESS assessment generated', 'success');
|
||||
// Auto-fill well visit note SSHADESS field
|
||||
var wvShadess = document.getElementById('wv-shadess-text');
|
||||
if (wvShadess) wvShadess.value = data.assessment;
|
||||
} else {
|
||||
showToast(data.error || 'Generation failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
// ── Well Visit Note — ROS data ────────────────────────────────────────────
|
||||
var _rosData = {}; // { constitutionalStatus: 'wnl'|'abnormal'|'', constitutionalNote: '...', ... }
|
||||
var _peData = {};
|
||||
|
||||
// 15 ROS systems
|
||||
var ROS_SYSTEMS = [
|
||||
{ key: 'constitutional', label: 'Constitutional', detail: 'fever, fatigue, weight loss/gain, appetite' },
|
||||
{ key: 'eyes', label: 'Eyes', detail: 'vision changes, discharge, redness' },
|
||||
{ key: 'ent', label: 'ENT', detail: 'ear pain, congestion, sore throat, mouth sores' },
|
||||
{ key: 'cardiovascular', label: 'Cardiovascular', detail: 'chest pain, palpitations, murmur' },
|
||||
{ key: 'respiratory', label: 'Respiratory', detail: 'cough, wheeze, dyspnea' },
|
||||
{ key: 'gastrointestinal', label: 'Gastrointestinal', detail: 'nausea, vomiting, diarrhea, constipation, abdominal pain' },
|
||||
{ key: 'genitourinary', label: 'Genitourinary', detail: 'dysuria, frequency, discharge, menstrual' },
|
||||
{ key: 'musculoskeletal', label: 'Musculoskeletal', detail: 'joint pain, swelling, gait issues, back pain' },
|
||||
{ key: 'skin', label: 'Skin', detail: 'rash, lesions, itch, hair/nail changes' },
|
||||
{ key: 'neurological', label: 'Neurological', detail: 'headache, dizziness, seizures, numbness' },
|
||||
{ key: 'psychiatric', label: 'Psychiatric', detail: 'mood, anxiety, sleep, behavior changes' },
|
||||
{ key: 'endocrine', label: 'Endocrine', detail: 'polyuria/polydipsia, heat/cold intolerance, growth concerns' },
|
||||
{ key: 'hematologic', label: 'Hematologic/Lymphatic', detail: 'bruising, bleeding, lymph nodes' },
|
||||
{ key: 'allergic', label: 'Allergic/Immunologic', detail: 'allergic reactions, frequent infections' },
|
||||
{ key: 'developmental', label: 'Developmental/Behavioral', detail: 'milestones, school, social' },
|
||||
];
|
||||
|
||||
// 17 PE systems
|
||||
var PE_SYSTEMS = [
|
||||
{ key: 'general', label: 'General Appearance', detail: 'WDWN, alert, NAD, etc.' },
|
||||
{ key: 'peEyes', label: 'Eyes', detail: 'PERRL, conjunctiva, EOM' },
|
||||
{ key: 'ears', label: 'Ears', detail: 'TMs, canals, hearing' },
|
||||
{ key: 'nose', label: 'Nose', detail: 'mucosa, septum, turbinates' },
|
||||
{ key: 'mouthThroat', label: 'Mouth/Throat', detail: 'lips, gums, teeth, tonsils, pharynx' },
|
||||
{ key: 'neck', label: 'Neck', detail: 'lymph nodes, thyroid, masses' },
|
||||
{ key: 'chestLungs', label: 'Chest/Lungs', detail: 'air entry, wheezing, retractions' },
|
||||
{ key: 'peCv', label: 'Cardiovascular', detail: 'S1/S2, murmur, pulses, cap refill' },
|
||||
{ key: 'abdomen', label: 'Abdomen', detail: 'soft, tenderness, organomegaly, BS' },
|
||||
{ key: 'guTanner', label: 'Genitourinary/Tanner', detail: 'Tanner stage, genitalia, hernias' },
|
||||
{ key: 'msk', label: 'Musculoskeletal', detail: 'strength, ROM, gait, spine' },
|
||||
{ key: 'extremities', label: 'Extremities', detail: 'tone, reflexes, clubbing, edema' },
|
||||
{ key: 'peSkin', label: 'Skin', detail: 'rashes, lesions, cafe au lait, bruises' },
|
||||
{ key: 'peNeuro', label: 'Neurological', detail: 'CN intact, DTRs, coordination' },
|
||||
{ key: 'lymphatic', label: 'Lymphatic', detail: 'cervical, axillary, inguinal nodes' },
|
||||
{ key: 'breast', label: 'Breast', detail: 'if indicated by age/Tanner' },
|
||||
{ key: 'pePsych', label: 'Psychiatric', detail: 'affect, mood, behavior during exam' },
|
||||
];
|
||||
|
||||
// ── Shared ROS row renderer (used by well visit and sick visit) ──────────
|
||||
// dataObj = reference to _rosData or _peData
|
||||
// systemsList = ROS_SYSTEMS or PE_SYSTEMS
|
||||
// prefix = 'ros' | 'pe'
|
||||
// btnLabels = { wnl:'WNL', abnormal:'Abnormal', notrev:'Not reviewed' } or { wnl:'Normal', abnormal:'Abnormal', notrev:'Not examined' }
|
||||
window.renderRosRows = function(systemsList, dataObj, prefix, btnLabels) {
|
||||
var bl = btnLabels || { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' };
|
||||
return systemsList.map(function(sys) {
|
||||
var status = dataObj[sys.key + '_status'] || '';
|
||||
var note = dataObj[sys.key + '_note'] || '';
|
||||
var wnlAct = status === 'wnl' ? ' wnl' : '';
|
||||
var abnAct = status === 'abnormal' ? ' abnormal' : '';
|
||||
var notrevAct = status === 'notrev' ? ' notrev' : '';
|
||||
var noteVis = status === 'abnormal' ? ' visible' : '';
|
||||
return '<div class="ros-system-row" data-sys-key="' + esc(sys.key) + '" data-prefix="' + prefix + '">' +
|
||||
'<span class="ros-system-name" title="' + esc(sys.detail) + '">' + esc(sys.label) + ' <span style="font-size:10px;color:var(--g400);">(' + esc(sys.detail) + ')</span></span>' +
|
||||
'<button class="ros-status-btn' + wnlAct + '" data-status="wnl" data-sys="' + esc(sys.key) + '" data-prefix="' + prefix + '">' + esc(bl.wnl) + '</button>' +
|
||||
'<button class="ros-status-btn' + abnAct + '" data-status="abnormal" data-sys="' + esc(sys.key) + '" data-prefix="' + prefix + '">' + esc(bl.abnormal) + '</button>' +
|
||||
'<button class="ros-status-btn' + notrevAct + '" data-status="notrev" data-sys="' + esc(sys.key) + '" data-prefix="' + prefix + '">' + esc(bl.notrev) + '</button>' +
|
||||
'<input class="ros-note-input' + noteVis + '" data-sys="' + esc(sys.key) + '" data-prefix="' + prefix + '" type="text" placeholder="Describe..." value="' + esc(note) + '">' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// Wire ROS/PE click events on a container element
|
||||
window.wireRosContainer = function(containerEl, dataObj) {
|
||||
containerEl.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.ros-status-btn');
|
||||
if (!btn) return;
|
||||
var sysKey = btn.dataset.sys;
|
||||
var status = btn.dataset.status;
|
||||
var prefix = btn.dataset.prefix;
|
||||
if (!sysKey) return;
|
||||
|
||||
// Toggle
|
||||
var cur = dataObj[sysKey + '_status'] || '';
|
||||
dataObj[sysKey + '_status'] = (cur === status) ? '' : status;
|
||||
|
||||
// Update buttons in row
|
||||
var row = btn.closest('.ros-system-row');
|
||||
if (row) {
|
||||
row.querySelectorAll('.ros-status-btn').forEach(function(b) {
|
||||
b.classList.remove('wnl', 'abnormal', 'notrev');
|
||||
if (dataObj[sysKey + '_status'] && b.dataset.status === dataObj[sysKey + '_status']) {
|
||||
b.classList.add(dataObj[sysKey + '_status']);
|
||||
}
|
||||
});
|
||||
var noteInput = row.querySelector('.ros-note-input');
|
||||
if (noteInput) {
|
||||
if (dataObj[sysKey + '_status'] === 'abnormal') {
|
||||
noteInput.classList.add('visible');
|
||||
noteInput.focus();
|
||||
} else {
|
||||
noteInput.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
containerEl.addEventListener('input', function(e) {
|
||||
var inp = e.target.closest('.ros-note-input');
|
||||
if (!inp) return;
|
||||
var sysKey = inp.dataset.sys;
|
||||
if (sysKey) dataObj[sysKey + '_note'] = inp.value;
|
||||
});
|
||||
};
|
||||
|
||||
// Format ROS data as text for AI
|
||||
window.formatRosForAI = function(systemsList, dataObj, heading) {
|
||||
var lines = [heading + ':'];
|
||||
var hasAny = false;
|
||||
systemsList.forEach(function(sys) {
|
||||
var status = dataObj[sys.key + '_status'] || '';
|
||||
if (!status) return;
|
||||
hasAny = true;
|
||||
var note = dataObj[sys.key + '_note'] || '';
|
||||
var statusLabel = status === 'wnl' ? 'WNL' : status === 'abnormal' ? 'Abnormal' : 'Not reviewed';
|
||||
lines.push(' ' + sys.label + ': ' + statusLabel + (note ? ' — ' + note : ''));
|
||||
});
|
||||
return hasAny ? lines.join('\n') : '';
|
||||
};
|
||||
|
||||
// ── ICD-10 Diagnoses ──────────────────────────────────────────────────────
|
||||
var COMMON_DX = [
|
||||
{ code: 'Z00.129', name: 'Routine child health exam, no abnormal findings' },
|
||||
{ code: 'Z00.121', name: 'Routine child health exam, with abnormal findings' },
|
||||
{ code: 'J06.9', name: 'Upper Respiratory Infection, NOS' },
|
||||
{ code: 'J02.9', name: 'Acute pharyngitis, NOS' },
|
||||
{ code: 'J03.90', name: 'Acute tonsillitis, NOS' },
|
||||
{ code: 'H66.90', name: 'Otitis media, NOS' },
|
||||
{ code: 'J20.9', name: 'Acute bronchitis' },
|
||||
{ code: 'J45.20', name: 'Mild intermittent asthma, uncomplicated' },
|
||||
{ code: 'J45.30', name: 'Mild persistent asthma' },
|
||||
{ code: 'A09', name: 'Gastroenteritis/colitis' },
|
||||
{ code: 'L20.9', name: 'Atopic dermatitis, NOS' },
|
||||
{ code: 'L30.9', name: 'Dermatitis NOS' },
|
||||
{ code: 'R50.9', name: 'Fever NOS' },
|
||||
{ code: 'R10.9', name: 'Abdominal pain NOS' },
|
||||
{ code: 'R05.9', name: 'Cough' },
|
||||
{ code: 'B34.9', name: 'Viral infection NOS' },
|
||||
{ code: 'Z23', name: 'Immunization encounter' },
|
||||
{ code: 'Z41.9', name: 'Procedure for non-remedial reason' },
|
||||
{ code: 'K21.0', name: 'GERD with esophagitis' },
|
||||
{ code: 'K21.9', name: 'GERD without esophagitis' },
|
||||
{ code: 'F90.0', name: 'ADHD, predominantly inattentive' },
|
||||
{ code: 'F90.2', name: 'ADHD, combined type' },
|
||||
{ code: 'F41.1', name: 'Generalized anxiety disorder' },
|
||||
{ code: 'F32.9', name: 'Major depressive episode NOS' },
|
||||
{ code: 'E11.9', name: 'Type 2 diabetes' },
|
||||
{ code: 'E03.9', name: 'Hypothyroidism NOS' },
|
||||
{ code: 'M54.5', name: 'Low back pain' },
|
||||
{ code: 'G43.909', name: 'Migraine NOS' },
|
||||
];
|
||||
|
||||
// Expose so sick visit can reuse
|
||||
window.COMMON_DX = COMMON_DX;
|
||||
|
||||
// Render the DX component into a container element
|
||||
// selectedDxArr = reference to an array of { code, name }
|
||||
// idPrefix = unique prefix for element IDs (e.g. 'wv' or 'sv')
|
||||
window.renderDxComponent = function(containerEl, selectedDxArr, idPrefix) {
|
||||
var searchId = idPrefix + '-dx-search';
|
||||
var tagsId = idPrefix + '-dx-tags';
|
||||
var chipsId = idPrefix + '-dx-chips';
|
||||
var freetextId = idPrefix + '-dx-freetext';
|
||||
|
||||
containerEl.innerHTML =
|
||||
'<div style="margin-bottom:8px;">' +
|
||||
'<div style="display:flex;gap:6px;align-items:center;margin-bottom:6px;">' +
|
||||
'<input id="' + searchId + '" type="text" placeholder="Search or type ICD-10 code..." style="flex:1;font-size:12px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;font-family:inherit;">' +
|
||||
'<button class="btn-sm btn-primary" id="' + idPrefix + '-dx-add-btn" style="font-size:12px;white-space:nowrap;"><i class="fas fa-plus"></i> Add</button>' +
|
||||
'</div>' +
|
||||
'<div id="' + tagsId + '" style="display:flex;flex-wrap:wrap;gap:4px;min-height:28px;margin-bottom:6px;"></div>' +
|
||||
'<div style="font-size:11px;color:var(--g500);margin-bottom:4px;">Common diagnoses — click to add:</div>' +
|
||||
'<div id="' + chipsId + '" style="display:flex;flex-wrap:wrap;gap:4px;"></div>' +
|
||||
'</div>' +
|
||||
'<div style="margin-top:8px;">' +
|
||||
'<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Additional diagnoses (free text):</label>' +
|
||||
'<input id="' + freetextId + '" type="text" placeholder="e.g. Asthma J45.20, GERD K21.9" style="width:100%;font-size:12px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;font-family:inherit;">' +
|
||||
'</div>';
|
||||
|
||||
// Render chips
|
||||
var chipsEl = containerEl.querySelector('#' + chipsId);
|
||||
if (chipsEl) {
|
||||
chipsEl.innerHTML = COMMON_DX.map(function(dx) {
|
||||
return '<span class="dx-chip" data-code="' + esc(dx.code) + '" data-name="' + esc(dx.name) + '">' + esc(dx.code) + ' ' + esc(dx.name) + '</span>';
|
||||
}).join('');
|
||||
chipsEl.addEventListener('click', function(e) {
|
||||
var chip = e.target.closest('.dx-chip');
|
||||
if (!chip) return;
|
||||
addDx(selectedDxArr, { code: chip.dataset.code, name: chip.dataset.name }, containerEl, tagsId);
|
||||
});
|
||||
}
|
||||
|
||||
// Add button / Enter key
|
||||
var searchEl = containerEl.querySelector('#' + searchId);
|
||||
var addBtn = containerEl.querySelector('#' + idPrefix + '-dx-add-btn');
|
||||
function addFromSearch() {
|
||||
var val = searchEl ? searchEl.value.trim() : '';
|
||||
if (!val) return;
|
||||
// Try to match a common dx
|
||||
var match = COMMON_DX.find(function(dx) {
|
||||
return dx.code.toLowerCase() === val.toLowerCase() ||
|
||||
dx.name.toLowerCase().indexOf(val.toLowerCase()) !== -1 ||
|
||||
val.toLowerCase().indexOf(dx.code.toLowerCase()) !== -1;
|
||||
});
|
||||
if (match) {
|
||||
addDx(selectedDxArr, match, containerEl, tagsId);
|
||||
} else {
|
||||
// Parse as "Name CODE" or just free text
|
||||
var parts = val.match(/^(.*?)\s+([A-Z][0-9][0-9A-Z\.]*)$/i);
|
||||
if (parts) {
|
||||
addDx(selectedDxArr, { code: parts[2].toUpperCase(), name: parts[1].trim() }, containerEl, tagsId);
|
||||
} else {
|
||||
addDx(selectedDxArr, { code: '', name: val }, containerEl, tagsId);
|
||||
}
|
||||
}
|
||||
if (searchEl) searchEl.value = '';
|
||||
}
|
||||
if (addBtn) addBtn.addEventListener('click', addFromSearch);
|
||||
if (searchEl) {
|
||||
searchEl.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addFromSearch(); }
|
||||
});
|
||||
}
|
||||
|
||||
renderDxTags(selectedDxArr, containerEl, tagsId);
|
||||
};
|
||||
|
||||
function addDx(arr, dx, containerEl, tagsId) {
|
||||
// Avoid duplicates
|
||||
var alreadyExists = arr.some(function(d) {
|
||||
return d.code === dx.code && d.name === dx.name;
|
||||
});
|
||||
if (alreadyExists) return;
|
||||
arr.push(dx);
|
||||
renderDxTags(arr, containerEl, tagsId);
|
||||
}
|
||||
|
||||
function renderDxTags(arr, containerEl, tagsId) {
|
||||
var tagsEl = containerEl.querySelector('#' + tagsId);
|
||||
if (!tagsEl) return;
|
||||
tagsEl.innerHTML = arr.map(function(dx, i) {
|
||||
return '<span class="dx-tag">' +
|
||||
(dx.code ? '<strong>' + esc(dx.code) + '</strong> ' : '') + esc(dx.name) +
|
||||
'<span class="dx-remove" data-idx="' + i + '" title="Remove">✕</span>' +
|
||||
'</span>';
|
||||
}).join('');
|
||||
tagsEl.querySelectorAll('.dx-remove').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var idx = parseInt(btn.dataset.idx);
|
||||
arr.splice(idx, 1);
|
||||
renderDxTags(arr, containerEl, tagsId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.formatDxForAI = function(selectedDxArr, freetextId) {
|
||||
var lines = [];
|
||||
selectedDxArr.forEach(function(dx) {
|
||||
lines.push((dx.code ? dx.code + ' ' : '') + dx.name);
|
||||
});
|
||||
var freetextEl = freetextId ? document.getElementById(freetextId) : null;
|
||||
if (freetextEl && freetextEl.value.trim()) {
|
||||
lines.push(freetextEl.value.trim());
|
||||
}
|
||||
return lines.length ? 'Diagnoses:\n' + lines.map(function(l) { return ' - ' + l; }).join('\n') : '';
|
||||
};
|
||||
|
||||
// ── Well Visit Note — module state ─────────────────────────────────────────
|
||||
var _wvSelectedDx = [];
|
||||
|
||||
// ── Well Visit Note Generation ───────────────────────────────────────────
|
||||
var _wvRecorder = null;
|
||||
var _wvTimer = null;
|
||||
var _wvRecording = false;
|
||||
var _wvPaused = false;
|
||||
var _wvRecognition = null;
|
||||
var _wvTranscript = '';
|
||||
|
||||
function initWvRecording() {
|
||||
var recordBtn = document.getElementById('wv-record-btn');
|
||||
var pauseBtn = document.getElementById('wv-pause-btn');
|
||||
var indicator = document.getElementById('wv-rec-indicator');
|
||||
var timerEl = document.getElementById('wv-timer');
|
||||
var transcriptEl = document.getElementById('wv-transcript');
|
||||
if (!recordBtn) return;
|
||||
|
||||
_wvTimer = createTimer(timerEl);
|
||||
_wvRecognition = createSpeechRecognition();
|
||||
if (_wvRecognition) {
|
||||
_wvRecognition.onresult = function(e) {
|
||||
var interim = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) _wvTranscript += e.results[i][0].transcript + ' ';
|
||||
else interim = e.results[i][0].transcript;
|
||||
}
|
||||
if (transcriptEl) transcriptEl.innerHTML = _wvTranscript + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
|
||||
};
|
||||
_wvRecognition.onend = function() { if (_wvRecording && !_wvPaused) try { _wvRecognition.start(); } catch(e) {} };
|
||||
}
|
||||
|
||||
recordBtn.addEventListener('click', function() {
|
||||
if (!_wvRecording) {
|
||||
_wvTranscript = '';
|
||||
_wvRecorder = new AudioRecorder();
|
||||
_wvRecorder.start().then(function() {
|
||||
_wvRecording = true; _wvPaused = false;
|
||||
recordBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
|
||||
recordBtn.classList.add('recording');
|
||||
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
||||
indicator.classList.remove('hidden');
|
||||
_wvTimer.start();
|
||||
if (_wvRecognition) try { _wvRecognition.start(); } catch(e) {}
|
||||
}).catch(function() { showToast('Microphone denied', 'error'); });
|
||||
} else {
|
||||
_wvRecording = false; _wvPaused = false;
|
||||
var dur = _wvTimer.stop();
|
||||
recordBtn.innerHTML = '<i class="fas fa-microphone"></i> Listen In';
|
||||
recordBtn.classList.remove('recording');
|
||||
if (pauseBtn) { pauseBtn.classList.add('hidden'); }
|
||||
indicator.classList.add('hidden');
|
||||
if (_wvRecognition) try { _wvRecognition.stop(); } catch(e) {}
|
||||
|
||||
showLoading('Transcribing...');
|
||||
_wvRecorder.stop().then(function(blob) {
|
||||
if (!blob || blob.size === 0) { hideLoading(); if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); return; }
|
||||
return transcribeAudio(blob).then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) { if (transcriptEl) transcriptEl.textContent = data.text; _wvTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
|
||||
else { if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); }
|
||||
});
|
||||
}).catch(function() { hideLoading(); });
|
||||
}
|
||||
});
|
||||
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
if (!_wvRecording || !_wvRecorder || !_wvRecorder.mediaRecorder) return;
|
||||
if (!_wvPaused) {
|
||||
_wvRecorder.mediaRecorder.pause(); _wvTimer.stop(); _wvPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
if (_wvRecognition) try { _wvRecognition.stop(); } catch(e) {}
|
||||
} else {
|
||||
_wvRecorder.mediaRecorder.resume(); _wvTimer.start(); _wvPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
if (_wvRecognition) try { _wvRecognition.start(); } catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ROS/PE init for well visit ────────────────────────────────────────────
|
||||
function initWvRosPe() {
|
||||
var rosContainer = document.getElementById('wv-ros-container');
|
||||
var peContainer = document.getElementById('wv-pe-container');
|
||||
var dxContainer = document.getElementById('wv-dx-container');
|
||||
var allWnlBtn = document.getElementById('wv-ros-all-wnl');
|
||||
var allNormalBtn = document.getElementById('wv-pe-all-normal');
|
||||
|
||||
if (rosContainer) {
|
||||
rosContainer.innerHTML = window.renderRosRows(ROS_SYSTEMS, _rosData, 'ros', { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' });
|
||||
window.wireRosContainer(rosContainer, _rosData);
|
||||
}
|
||||
|
||||
if (peContainer) {
|
||||
peContainer.innerHTML = window.renderRosRows(PE_SYSTEMS, _peData, 'pe', { wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' });
|
||||
window.wireRosContainer(peContainer, _peData);
|
||||
}
|
||||
|
||||
if (allWnlBtn) {
|
||||
allWnlBtn.addEventListener('click', function() {
|
||||
ROS_SYSTEMS.forEach(function(sys) { _rosData[sys.key + '_status'] = 'wnl'; });
|
||||
if (rosContainer) {
|
||||
rosContainer.innerHTML = window.renderRosRows(ROS_SYSTEMS, _rosData, 'ros', { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' });
|
||||
window.wireRosContainer(rosContainer, _rosData);
|
||||
}
|
||||
showToast('All ROS set to WNL', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
if (allNormalBtn) {
|
||||
allNormalBtn.addEventListener('click', function() {
|
||||
PE_SYSTEMS.forEach(function(sys) { _peData[sys.key + '_status'] = 'wnl'; });
|
||||
if (peContainer) {
|
||||
peContainer.innerHTML = window.renderRosRows(PE_SYSTEMS, _peData, 'pe', { wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' });
|
||||
window.wireRosContainer(peContainer, _peData);
|
||||
}
|
||||
showToast('All PE set to Normal', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
if (dxContainer) {
|
||||
window.renderDxComponent(dxContainer, _wvSelectedDx, 'wv');
|
||||
}
|
||||
}
|
||||
|
||||
function generateWvNote() {
|
||||
var age = document.getElementById('wv-note-age').value;
|
||||
var gender = document.getElementById('wv-note-gender').value;
|
||||
if (!age) { showToast('Enter patient age/visit age', 'error'); return; }
|
||||
|
||||
var transcriptEl = document.getElementById('wv-transcript');
|
||||
var transcript = transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '';
|
||||
|
||||
showLoading('Generating well visit note...');
|
||||
|
||||
var modelEl = document.querySelector('#wv-panel-note .tab-model-select');
|
||||
|
||||
// Format ROS, PE, DX
|
||||
var rosText = window.formatRosForAI(ROS_SYSTEMS, _rosData, 'Review of Systems');
|
||||
var peText = window.formatRosForAI(PE_SYSTEMS, _peData, 'Physical Examination');
|
||||
var dxText = window.formatDxForAI(_wvSelectedDx, 'wv-dx-freetext');
|
||||
|
||||
// Fetch user memories to include physician templates
|
||||
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||
|
||||
memoriesPromise.then(function(memCtx) {
|
||||
return fetch('/api/well-visit/note', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
patientAge: age,
|
||||
visitAge: age,
|
||||
patientGender: gender,
|
||||
vitals: document.getElementById('wv-vitals').value,
|
||||
transcript: transcript || null,
|
||||
screenings: document.getElementById('wv-screenings').value,
|
||||
vaccines: document.getElementById('wv-vaccines').value,
|
||||
shadessAssessment: document.getElementById('wv-shadess-text').value || null,
|
||||
ros: rosText || null,
|
||||
physicalExam: peText || null,
|
||||
diagnoses: dxText || null,
|
||||
physicianMemories: memCtx || null,
|
||||
noteStyle: document.getElementById('wv-note-style').value,
|
||||
model: modelEl ? modelEl.value : getSelectedModel()
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
var noteEl = document.getElementById('wv-note-text');
|
||||
var outCard = document.getElementById('wv-note-output');
|
||||
var tag = document.getElementById('wv-note-model-tag');
|
||||
if (noteEl) noteEl.textContent = data.note;
|
||||
if (tag) tag.textContent = (data.model || '').split('/').pop();
|
||||
if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); }
|
||||
showToast('Well visit note generated!', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Generation failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
// ── Wire subtab events ───────────────────────────────────────────────────
|
||||
function initSubtabWiring() {
|
||||
var subtabBar = document.querySelector('.wv-subtab-bar');
|
||||
if (!subtabBar) return;
|
||||
|
||||
subtabBar.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.wv-subtab-btn');
|
||||
if (!btn) return;
|
||||
var subtab = btn.dataset.subtab;
|
||||
if (subtab === 'shadess' || subtab === 'note') {
|
||||
// These are handled here; wellVisit.js handles the others
|
||||
showWvSubpanel(subtab);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showWvSubpanel(name) {
|
||||
document.querySelectorAll('.wv-subpanel').forEach(function(p) { p.classList.add('hidden'); });
|
||||
document.querySelectorAll('.wv-subtab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
var panel = document.getElementById('wv-panel-' + name);
|
||||
var btn = document.querySelector('.wv-subtab-btn[data-subtab="' + name + '"]');
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
if (btn) btn.classList.add('active');
|
||||
}
|
||||
|
||||
// ── Save bar for well visit ──────────────────────────────────────────────
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('#btn-shadess-generate')) generateShadess();
|
||||
if (e.target.closest('#btn-wv-generate')) generateWvNote();
|
||||
if (e.target.closest('#wv-refine-btn')) refineDocument('wv-note-text', 'wv-refine-input');
|
||||
if (e.target.closest('#wv-shorten-btn')) shortenDocument('wv-note-text');
|
||||
if (e.target.closest('#btn-wv-save')) {
|
||||
var labelEl = document.getElementById('wv-label');
|
||||
var transcriptEl = document.getElementById('wv-transcript');
|
||||
var noteEl = document.getElementById('wv-note-text');
|
||||
if (typeof saveEncounter === 'function') {
|
||||
window.saveEncounter({
|
||||
id: window._savedEncId_wellvisit || null,
|
||||
label: labelEl ? labelEl.value.trim() : '',
|
||||
enc_type: 'wellvisit',
|
||||
transcript: transcriptEl ? (transcriptEl.innerText || '') : '',
|
||||
generated_note: noteEl ? (noteEl.innerText || '') : '',
|
||||
onSaved: function(id) { window._savedEncId_wellvisit = id; }
|
||||
});
|
||||
}
|
||||
}
|
||||
if (e.target.closest('#btn-shadess-new-patient')) resetShadessForm();
|
||||
if (e.target.closest('#btn-wv-new-patient')) resetWvNote();
|
||||
});
|
||||
|
||||
// Register well visit load handler
|
||||
if (typeof registerEncounterLoadHandler === 'function') {
|
||||
registerEncounterLoadHandler('wellvisit', function(enc) {
|
||||
var transcriptEl = document.getElementById('wv-transcript');
|
||||
if (transcriptEl && enc.transcript) transcriptEl.textContent = enc.transcript;
|
||||
if (enc.generated_note) {
|
||||
var noteEl = document.getElementById('wv-note-text');
|
||||
var outCard = document.getElementById('wv-note-output');
|
||||
if (noteEl) noteEl.textContent = enc.generated_note;
|
||||
if (outCard) outCard.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Reset functions ──────────────────────────────────────────────────────
|
||||
function resetShadessForm() {
|
||||
// Reset answers
|
||||
SHADESS_DOMAINS.forEach(function(d) {
|
||||
_shadessAnswers[d.key] = { questions: {}, comment: '', concern: false, skipped: false };
|
||||
});
|
||||
// Clear the DOM form
|
||||
initShadess();
|
||||
// Clear age/gender inputs
|
||||
var ageEl = document.getElementById('shadess-age');
|
||||
var genderEl = document.getElementById('shadess-gender');
|
||||
if (ageEl) ageEl.value = '';
|
||||
if (genderEl) genderEl.value = '';
|
||||
// Clear transcript and hide output
|
||||
_shadessTranscript = '';
|
||||
var outCard = document.getElementById('shadess-output');
|
||||
var resultEl = document.getElementById('shadess-result-text');
|
||||
if (outCard) outCard.classList.add('hidden');
|
||||
if (resultEl) resultEl.textContent = '';
|
||||
showToast('SSHADESS form cleared for new patient', 'info');
|
||||
}
|
||||
|
||||
function resetWvNote() {
|
||||
// Clear all well visit fields
|
||||
['wv-note-age', 'wv-note-gender', 'wv-vitals', 'wv-screenings', 'wv-vaccines', 'wv-shadess-text', 'wv-label'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
var transcriptEl = document.getElementById('wv-transcript');
|
||||
if (transcriptEl) transcriptEl.textContent = '';
|
||||
_wvTranscript = '';
|
||||
|
||||
// Reset ROS/PE/DX
|
||||
_rosData = {};
|
||||
_peData = {};
|
||||
_wvSelectedDx.length = 0;
|
||||
initWvRosPe();
|
||||
|
||||
var outCard = document.getElementById('wv-note-output');
|
||||
var noteEl = document.getElementById('wv-note-text');
|
||||
if (outCard) outCard.classList.add('hidden');
|
||||
if (noteEl) noteEl.textContent = '';
|
||||
window._savedEncId_wellvisit = null;
|
||||
showToast('Well visit note cleared for new patient', 'info');
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── Init on DOM ready ────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initShadess();
|
||||
initShadessRecording();
|
||||
initWvRecording();
|
||||
initSubtabWiring();
|
||||
initWvRosPe();
|
||||
});
|
||||
|
||||
console.log('SHADESS module loaded');
|
||||
|
||||
})();
|
||||
423
public/js/sickVisit.js
Normal file
423
public/js/sickVisit.js
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
// ============================================================
|
||||
// SICK VISIT TAB — Recording, ROS/PE inference, Note generation
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── 15 ROS systems (same keys as shadess.js) ─────────────────────────────
|
||||
var ROS_SYSTEMS = [
|
||||
{ key: 'constitutional', label: 'Constitutional', detail: 'fever, fatigue, weight loss/gain, appetite' },
|
||||
{ key: 'eyes', label: 'Eyes', detail: 'vision changes, discharge, redness' },
|
||||
{ key: 'ent', label: 'ENT', detail: 'ear pain, congestion, sore throat, mouth sores' },
|
||||
{ key: 'cardiovascular', label: 'Cardiovascular', detail: 'chest pain, palpitations, murmur' },
|
||||
{ key: 'respiratory', label: 'Respiratory', detail: 'cough, wheeze, dyspnea' },
|
||||
{ key: 'gastrointestinal', label: 'Gastrointestinal', detail: 'nausea, vomiting, diarrhea, constipation, abdominal pain' },
|
||||
{ key: 'genitourinary', label: 'Genitourinary', detail: 'dysuria, frequency, discharge, menstrual' },
|
||||
{ key: 'musculoskeletal', label: 'Musculoskeletal', detail: 'joint pain, swelling, gait issues, back pain' },
|
||||
{ key: 'skin', label: 'Skin', detail: 'rash, lesions, itch, hair/nail changes' },
|
||||
{ key: 'neurological', label: 'Neurological', detail: 'headache, dizziness, seizures, numbness' },
|
||||
{ key: 'psychiatric', label: 'Psychiatric', detail: 'mood, anxiety, sleep, behavior changes' },
|
||||
{ key: 'endocrine', label: 'Endocrine', detail: 'polyuria/polydipsia, heat/cold intolerance, growth concerns' },
|
||||
{ key: 'hematologic', label: 'Hematologic/Lymphatic', detail: 'bruising, bleeding, lymph nodes' },
|
||||
{ key: 'allergic', label: 'Allergic/Immunologic', detail: 'allergic reactions, frequent infections' },
|
||||
{ key: 'developmental', label: 'Developmental/Behavioral', detail: 'milestones, school, social' },
|
||||
];
|
||||
|
||||
// ── 17 PE systems ─────────────────────────────────────────────────────────
|
||||
var PE_SYSTEMS = [
|
||||
{ key: 'general', label: 'General Appearance', detail: 'WDWN, alert, NAD, etc.' },
|
||||
{ key: 'peEyes', label: 'Eyes', detail: 'PERRL, conjunctiva, EOM' },
|
||||
{ key: 'ears', label: 'Ears', detail: 'TMs, canals, hearing' },
|
||||
{ key: 'nose', label: 'Nose', detail: 'mucosa, septum, turbinates' },
|
||||
{ key: 'mouthThroat', label: 'Mouth/Throat', detail: 'lips, gums, teeth, tonsils, pharynx' },
|
||||
{ key: 'neck', label: 'Neck', detail: 'lymph nodes, thyroid, masses' },
|
||||
{ key: 'chestLungs', label: 'Chest/Lungs', detail: 'air entry, wheezing, retractions' },
|
||||
{ key: 'peCv', label: 'Cardiovascular', detail: 'S1/S2, murmur, pulses, cap refill' },
|
||||
{ key: 'abdomen', label: 'Abdomen', detail: 'soft, tenderness, organomegaly, BS' },
|
||||
{ key: 'guTanner', label: 'Genitourinary/Tanner', detail: 'Tanner stage, genitalia, hernias' },
|
||||
{ key: 'msk', label: 'Musculoskeletal', detail: 'strength, ROM, gait, spine' },
|
||||
{ key: 'extremities', label: 'Extremities', detail: 'tone, reflexes, clubbing, edema' },
|
||||
{ key: 'peSkin', label: 'Skin', detail: 'rashes, lesions, cafe au lait, bruises' },
|
||||
{ key: 'peNeuro', label: 'Neurological', detail: 'CN intact, DTRs, coordination' },
|
||||
{ key: 'lymphatic', label: 'Lymphatic', detail: 'cervical, axillary, inguinal nodes' },
|
||||
{ key: 'breast', label: 'Breast', detail: 'if indicated by age/Tanner' },
|
||||
{ key: 'pePsych', label: 'Psychiatric', detail: 'affect, mood, behavior during exam' },
|
||||
];
|
||||
|
||||
// ── Inference rules (CC keyword → ROS and PE system keys) ─────────────────
|
||||
var INFERENCE_RULES = [
|
||||
{ keywords: ['ear', 'hearing', 'otalgia', 'otitis'],
|
||||
ros: ['constitutional', 'ent'],
|
||||
pe: ['general', 'ears', 'nose', 'mouthThroat', 'neck'] },
|
||||
{ keywords: ['throat', 'strep', 'pharyn', 'tonsil'],
|
||||
ros: ['constitutional', 'ent'],
|
||||
pe: ['general', 'mouthThroat', 'neck', 'ears', 'lymphatic'] },
|
||||
{ keywords: ['cough', 'breath', 'asthma', 'wheez', 'respiratory', 'bronch'],
|
||||
ros: ['constitutional', 'respiratory', 'cardiovascular', 'ent'],
|
||||
pe: ['general', 'chestLungs', 'peCv', 'ears', 'nose'] },
|
||||
{ keywords: ['stomach', 'vomit', 'nausea', 'diarr', 'abdomin', 'constip', 'belly'],
|
||||
ros: ['constitutional', 'gastrointestinal', 'genitourinary'],
|
||||
pe: ['general', 'abdomen', 'chestLungs', 'peCv', 'peSkin'] },
|
||||
{ keywords: ['fever', 'temp', 'hot'],
|
||||
ros: ['constitutional', 'ent', 'respiratory', 'gastrointestinal'],
|
||||
pe: ['general', 'ears', 'mouthThroat', 'chestLungs', 'abdomen', 'peSkin', 'lymphatic'] },
|
||||
{ keywords: ['rash', 'skin', 'itch', 'eczema', 'hives', 'dermat'],
|
||||
ros: ['constitutional', 'skin', 'allergic'],
|
||||
pe: ['general', 'peSkin', 'chestLungs', 'peCv', 'lymphatic'] },
|
||||
{ keywords: ['headache', 'head', 'migrain'],
|
||||
ros: ['constitutional', 'neurological', 'eyes', 'ent'],
|
||||
pe: ['general', 'peNeuro', 'peEyes', 'ears', 'neck'] },
|
||||
{ keywords: ['urin', 'uti', 'dysuria', 'bladder', 'kidney'],
|
||||
ros: ['constitutional', 'genitourinary', 'gastrointestinal'],
|
||||
pe: ['general', 'abdomen', 'guTanner', 'chestLungs', 'peCv'] },
|
||||
{ keywords: ['joint', 'knee', 'limb', 'leg', 'arm', 'ankle', 'musculo', 'back', 'pain'],
|
||||
ros: ['constitutional', 'musculoskeletal', 'neurological'],
|
||||
pe: ['general', 'msk', 'extremities', 'peNeuro', 'peSkin'] },
|
||||
{ keywords: ['anxi', 'mood', 'behav', 'depress', 'mental', 'school'],
|
||||
ros: ['constitutional', 'psychiatric', 'neurological', 'developmental'],
|
||||
pe: ['general', 'pePsych', 'peNeuro', 'peSkin'] },
|
||||
];
|
||||
|
||||
var DEFAULT_ROS = ['constitutional', 'ent', 'respiratory', 'gastrointestinal'];
|
||||
var DEFAULT_PE = ['general', 'ears', 'chestLungs', 'peCv', 'abdomen', 'peSkin', 'peNeuro'];
|
||||
|
||||
function inferSystems(cc) {
|
||||
var lc = (cc || '').toLowerCase();
|
||||
var rosSet = {};
|
||||
var peSet = {};
|
||||
var matched = false;
|
||||
|
||||
INFERENCE_RULES.forEach(function(rule) {
|
||||
if (rule.keywords.some(function(kw) { return lc.indexOf(kw) !== -1; })) {
|
||||
matched = true;
|
||||
rule.ros.forEach(function(k) { rosSet[k] = true; });
|
||||
rule.pe.forEach(function(k) { peSet[k] = true; });
|
||||
}
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
DEFAULT_ROS.forEach(function(k) { rosSet[k] = true; });
|
||||
DEFAULT_PE.forEach(function(k) { peSet[k] = true; });
|
||||
}
|
||||
|
||||
// Return top 4 ROS, top 7 PE
|
||||
var rosKeys = Object.keys(rosSet).slice(0, 4);
|
||||
var peKeys = Object.keys(peSet).slice(0, 7);
|
||||
|
||||
// Ensure we always have at least the default counts
|
||||
DEFAULT_ROS.forEach(function(k) { if (rosKeys.length < 4 && rosKeys.indexOf(k) === -1) rosKeys.push(k); });
|
||||
DEFAULT_PE.forEach(function(k) { if (peKeys.length < 7 && peKeys.indexOf(k) === -1) peKeys.push(k); });
|
||||
|
||||
return {
|
||||
ros: rosKeys.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean),
|
||||
pe: peKeys.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Module state ─────────────────────────────────────────────────────────
|
||||
var _sickRosData = {};
|
||||
var _sickPeData = {};
|
||||
var _sickSelectedDx = [];
|
||||
var _sickRosSystems = DEFAULT_ROS.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
|
||||
var _sickPeSystems = DEFAULT_PE.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
|
||||
|
||||
// ── Recording ────────────────────────────────────────────────────────────
|
||||
var _sickRecorder = null;
|
||||
var _sickTimer = null;
|
||||
var _sickRecording = false;
|
||||
var _sickPaused = false;
|
||||
var _sickRecognition = null;
|
||||
var _sickTranscript = '';
|
||||
|
||||
function initRecording() {
|
||||
var recordBtn = document.getElementById('sick-record-btn');
|
||||
var pauseBtn = document.getElementById('sick-pause-btn');
|
||||
var indicator = document.getElementById('sick-rec-indicator');
|
||||
var timerEl = document.getElementById('sick-timer');
|
||||
var transcriptEl = document.getElementById('sick-transcript');
|
||||
if (!recordBtn) return;
|
||||
|
||||
_sickTimer = createTimer(timerEl);
|
||||
_sickRecognition = createSpeechRecognition();
|
||||
if (_sickRecognition) {
|
||||
_sickRecognition.onresult = function(e) {
|
||||
var interim = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) _sickTranscript += e.results[i][0].transcript + ' ';
|
||||
else interim = e.results[i][0].transcript;
|
||||
}
|
||||
if (transcriptEl) transcriptEl.innerHTML = _sickTranscript + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
|
||||
};
|
||||
_sickRecognition.onend = function() { if (_sickRecording && !_sickPaused) try { _sickRecognition.start(); } catch(e) {} };
|
||||
}
|
||||
|
||||
recordBtn.addEventListener('click', function() {
|
||||
if (!_sickRecording) {
|
||||
_sickTranscript = '';
|
||||
_sickRecorder = new AudioRecorder();
|
||||
_sickRecorder.start().then(function() {
|
||||
_sickRecording = true; _sickPaused = false;
|
||||
recordBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
|
||||
recordBtn.classList.add('recording');
|
||||
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
||||
indicator.classList.remove('hidden');
|
||||
_sickTimer.start();
|
||||
if (_sickRecognition) try { _sickRecognition.start(); } catch(e) {}
|
||||
showToast('Sick visit recording started', 'info');
|
||||
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'sick' } }));
|
||||
}).catch(function() { showToast('Microphone denied', 'error'); });
|
||||
} else {
|
||||
_sickRecording = false; _sickPaused = false;
|
||||
var dur = _sickTimer.stop();
|
||||
recordBtn.innerHTML = '<i class="fas fa-microphone"></i> Listen In';
|
||||
recordBtn.classList.remove('recording');
|
||||
if (pauseBtn) pauseBtn.classList.add('hidden');
|
||||
indicator.classList.add('hidden');
|
||||
if (_sickRecognition) try { _sickRecognition.stop(); } catch(e) {}
|
||||
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'sick' } }));
|
||||
|
||||
showLoading('Transcribing...');
|
||||
_sickRecorder.stop().then(function(blob) {
|
||||
if (!blob || blob.size === 0) { hideLoading(); if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); return; }
|
||||
return transcribeAudio(blob).then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) { if (transcriptEl) transcriptEl.textContent = data.text; _sickTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
|
||||
else { if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); }
|
||||
});
|
||||
}).catch(function() { hideLoading(); });
|
||||
}
|
||||
});
|
||||
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
if (!_sickRecording || !_sickRecorder || !_sickRecorder.mediaRecorder) return;
|
||||
if (!_sickPaused) {
|
||||
_sickRecorder.mediaRecorder.pause(); _sickTimer.stop(); _sickPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
if (_sickRecognition) try { _sickRecognition.stop(); } catch(e) {}
|
||||
} else {
|
||||
_sickRecorder.mediaRecorder.resume(); _sickTimer.start(); _sickPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
if (_sickRecognition) try { _sickRecognition.start(); } catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ROS/PE rendering ──────────────────────────────────────────────────────
|
||||
function populateAddDropdowns() {
|
||||
var rosAddEl = document.getElementById('sick-ros-add');
|
||||
var peAddEl = document.getElementById('sick-pe-add');
|
||||
|
||||
if (rosAddEl) {
|
||||
rosAddEl.innerHTML = '<option value="">+ Add system</option>' +
|
||||
ROS_SYSTEMS.map(function(s) {
|
||||
return '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>';
|
||||
}).join('');
|
||||
rosAddEl.addEventListener('change', function() {
|
||||
var key = this.value;
|
||||
if (!key) return;
|
||||
this.value = '';
|
||||
var sys = ROS_SYSTEMS.find(function(s) { return s.key === key; });
|
||||
if (!sys) return;
|
||||
if (_sickRosSystems.some(function(s) { return s.key === key; })) return;
|
||||
_sickRosSystems.push(sys);
|
||||
renderRosPe();
|
||||
});
|
||||
}
|
||||
|
||||
if (peAddEl) {
|
||||
peAddEl.innerHTML = '<option value="">+ Add system</option>' +
|
||||
PE_SYSTEMS.map(function(s) {
|
||||
return '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>';
|
||||
}).join('');
|
||||
peAddEl.addEventListener('change', function() {
|
||||
var key = this.value;
|
||||
if (!key) return;
|
||||
this.value = '';
|
||||
var sys = PE_SYSTEMS.find(function(s) { return s.key === key; });
|
||||
if (!sys) return;
|
||||
if (_sickPeSystems.some(function(s) { return s.key === key; })) return;
|
||||
_sickPeSystems.push(sys);
|
||||
renderRosPe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderRosPe() {
|
||||
var rosContainer = document.getElementById('sick-ros-container');
|
||||
var peContainer = document.getElementById('sick-pe-container');
|
||||
|
||||
if (rosContainer && typeof window.renderRosRows === 'function') {
|
||||
rosContainer.innerHTML = window.renderRosRows(_sickRosSystems, _sickRosData, 'sros', { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' });
|
||||
window.wireRosContainer(rosContainer, _sickRosData);
|
||||
}
|
||||
if (peContainer && typeof window.renderRosRows === 'function') {
|
||||
peContainer.innerHTML = window.renderRosRows(_sickPeSystems, _sickPeData, 'spe', { wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' });
|
||||
window.wireRosContainer(peContainer, _sickPeData);
|
||||
}
|
||||
}
|
||||
|
||||
function inferAndRender() {
|
||||
var cc = (document.getElementById('sick-cc') || {}).value || '';
|
||||
var inferred = inferSystems(cc);
|
||||
_sickRosSystems = inferred.ros;
|
||||
_sickPeSystems = inferred.pe;
|
||||
_sickRosData = {};
|
||||
_sickPeData = {};
|
||||
renderRosPe();
|
||||
if (cc) showToast('ROS/PE updated for: ' + cc, 'info');
|
||||
}
|
||||
|
||||
// ── Diagnoses ─────────────────────────────────────────────────────────────
|
||||
function initDx() {
|
||||
var dxContainer = document.getElementById('sick-dx-container');
|
||||
if (dxContainer && typeof window.renderDxComponent === 'function') {
|
||||
window.renderDxComponent(dxContainer, _sickSelectedDx, 'sick');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generate note ─────────────────────────────────────────────────────────
|
||||
function generateSickNote() {
|
||||
var age = (document.getElementById('sick-age') || {}).value || '';
|
||||
var gender = (document.getElementById('sick-gender') || {}).value || '';
|
||||
var cc = (document.getElementById('sick-cc') || {}).value || '';
|
||||
if (!cc) { showToast('Enter a chief complaint first', 'error'); return; }
|
||||
|
||||
var transcriptEl = document.getElementById('sick-transcript');
|
||||
var transcript = transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '';
|
||||
|
||||
var rosText = typeof window.formatRosForAI === 'function' ? window.formatRosForAI(_sickRosSystems, _sickRosData, 'Review of Systems') : '';
|
||||
var peText = typeof window.formatRosForAI === 'function' ? window.formatRosForAI(_sickPeSystems, _sickPeData, 'Physical Examination') : '';
|
||||
var dxText = typeof window.formatDxForAI === 'function' ? window.formatDxForAI(_sickSelectedDx, 'sick-dx-freetext') : '';
|
||||
|
||||
showLoading('Generating sick visit note...');
|
||||
|
||||
var modelEl = document.getElementById('sick-model-select');
|
||||
|
||||
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||
|
||||
memoriesPromise.then(function(memCtx) {
|
||||
return fetch('/api/sick-visit/note', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
patientAge: age,
|
||||
patientGender: gender,
|
||||
chiefComplaint: cc,
|
||||
transcript: transcript || null,
|
||||
ros: rosText || null,
|
||||
physicalExam: peText || null,
|
||||
diagnoses: dxText || null,
|
||||
physicianMemories: memCtx || null,
|
||||
model: modelEl ? modelEl.value : getSelectedModel()
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
var noteEl = document.getElementById('sick-note-text');
|
||||
var outCard = document.getElementById('sick-note-output');
|
||||
var tag = document.getElementById('sick-note-model-tag');
|
||||
if (noteEl) noteEl.textContent = data.note;
|
||||
if (tag) tag.textContent = (data.model || '').split('/').pop();
|
||||
if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); }
|
||||
showToast('Sick visit note generated!', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Generation failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
// ── Reset ─────────────────────────────────────────────────────────────────
|
||||
function resetSickVisit() {
|
||||
var fields = ['sick-age', 'sick-gender', 'sick-cc', 'sick-label'];
|
||||
fields.forEach(function(id) { var el = document.getElementById(id); if (el) el.value = ''; });
|
||||
var transcriptEl = document.getElementById('sick-transcript');
|
||||
if (transcriptEl) transcriptEl.textContent = '';
|
||||
_sickTranscript = '';
|
||||
_sickRosData = {};
|
||||
_sickPeData = {};
|
||||
_sickSelectedDx.length = 0;
|
||||
_sickRosSystems = DEFAULT_ROS.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
|
||||
_sickPeSystems = DEFAULT_PE.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
|
||||
renderRosPe();
|
||||
initDx();
|
||||
var outCard = document.getElementById('sick-note-output');
|
||||
var noteEl = document.getElementById('sick-note-text');
|
||||
if (outCard) outCard.classList.add('hidden');
|
||||
if (noteEl) noteEl.textContent = '';
|
||||
window._savedEncId_sickvisit = null;
|
||||
showToast('Sick visit cleared for new patient', 'info');
|
||||
}
|
||||
|
||||
// ── Click handler ─────────────────────────────────────────────────────────
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('#btn-sick-generate')) generateSickNote();
|
||||
if (e.target.closest('#btn-sick-infer')) inferAndRender();
|
||||
if (e.target.closest('#btn-sick-new-patient')) resetSickVisit();
|
||||
if (e.target.closest('#sick-refine-btn')) refineDocument('sick-note-text', 'sick-refine-input');
|
||||
if (e.target.closest('#sick-shorten-btn')) shortenDocument('sick-note-text');
|
||||
|
||||
if (e.target.closest('#sick-ros-all-wnl')) {
|
||||
_sickRosSystems.forEach(function(sys) { _sickRosData[sys.key + '_status'] = 'wnl'; });
|
||||
renderRosPe();
|
||||
showToast('All ROS set to WNL', 'success');
|
||||
}
|
||||
if (e.target.closest('#sick-pe-all-normal')) {
|
||||
_sickPeSystems.forEach(function(sys) { _sickPeData[sys.key + '_status'] = 'wnl'; });
|
||||
renderRosPe();
|
||||
showToast('All PE set to Normal', 'success');
|
||||
}
|
||||
|
||||
if (e.target.closest('#btn-sick-save')) {
|
||||
var labelEl = document.getElementById('sick-label');
|
||||
var transcriptEl = document.getElementById('sick-transcript');
|
||||
var noteEl = document.getElementById('sick-note-text');
|
||||
if (typeof saveEncounter === 'function') {
|
||||
saveEncounter({
|
||||
id: window._savedEncId_sickvisit || null,
|
||||
label: labelEl ? labelEl.value.trim() : '',
|
||||
enc_type: 'sickvisit',
|
||||
transcript: transcriptEl ? (transcriptEl.innerText || '') : '',
|
||||
generated_note: noteEl ? (noteEl.innerText || '') : '',
|
||||
onSaved: function(id) { window._savedEncId_sickvisit = id; }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Load handler ──────────────────────────────────────────────────────────
|
||||
if (typeof registerEncounterLoadHandler === 'function') {
|
||||
registerEncounterLoadHandler('sickvisit', function(enc) {
|
||||
var transcriptEl = document.getElementById('sick-transcript');
|
||||
if (transcriptEl && enc.transcript) transcriptEl.textContent = enc.transcript;
|
||||
if (enc.generated_note) {
|
||||
var noteEl = document.getElementById('sick-note-text');
|
||||
var outCard = document.getElementById('sick-note-output');
|
||||
if (noteEl) noteEl.textContent = enc.generated_note;
|
||||
if (outCard) outCard.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initRecording();
|
||||
populateAddDropdowns();
|
||||
renderRosPe();
|
||||
initDx();
|
||||
});
|
||||
|
||||
console.log('SickVisit module loaded');
|
||||
|
||||
})();
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
(function() {
|
||||
var recordBtn = document.getElementById('dict-record-btn');
|
||||
var pauseBtn = document.getElementById('dict-pause-btn');
|
||||
var indicator = document.getElementById('dict-recording-indicator');
|
||||
var timerEl = document.getElementById('dict-timer');
|
||||
var transcript = document.getElementById('dict-transcript');
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
var recorder = new AudioRecorder();
|
||||
var timer = createTimer(timerEl);
|
||||
var isRecording = false;
|
||||
var isPaused = false;
|
||||
var recognition = createSpeechRecognition();
|
||||
var finalText = '';
|
||||
|
||||
|
|
@ -24,7 +26,7 @@
|
|||
}
|
||||
transcript.innerHTML = finalText + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
|
||||
};
|
||||
recognition.onend = function() { if (isRecording) try { recognition.start(); } catch(e) {} };
|
||||
recognition.onend = function() { if (isRecording && !isPaused) try { recognition.start(); } catch(e) {} };
|
||||
}
|
||||
|
||||
recordBtn.addEventListener('click', function() {
|
||||
|
|
@ -32,19 +34,25 @@
|
|||
finalText = '';
|
||||
recorder.start().then(function() {
|
||||
isRecording = true;
|
||||
isPaused = false;
|
||||
recordBtn.classList.add('recording');
|
||||
recordBtn.querySelector('span').textContent = 'Stop';
|
||||
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
||||
indicator.classList.remove('hidden');
|
||||
timer.start();
|
||||
if (recognition) try { recognition.start(); } catch(e) {}
|
||||
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'dict' } }));
|
||||
}).catch(function() { showToast('Microphone denied', 'error'); });
|
||||
} else {
|
||||
isRecording = false;
|
||||
isPaused = false;
|
||||
var dur = timer.stop();
|
||||
recordBtn.classList.remove('recording');
|
||||
recordBtn.querySelector('span').textContent = 'Start Dictation';
|
||||
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; }
|
||||
indicator.classList.add('hidden');
|
||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'dict' } }));
|
||||
|
||||
showLoading('Transcribing...');
|
||||
recorder.stop().then(function(blob) {
|
||||
|
|
@ -58,7 +66,42 @@
|
|||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', function() { transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden'); });
|
||||
// Pause / Resume
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', function() {
|
||||
if (!isRecording) return;
|
||||
if (!isPaused) {
|
||||
if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'recording') {
|
||||
recorder.mediaRecorder.pause();
|
||||
timer.stop();
|
||||
isPaused = true;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||
pauseBtn.classList.add('btn-primary');
|
||||
pauseBtn.classList.remove('btn-ghost');
|
||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||
showToast('Paused', 'info');
|
||||
}
|
||||
} else {
|
||||
if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'paused') {
|
||||
recorder.mediaRecorder.resume();
|
||||
timer.start();
|
||||
isPaused = false;
|
||||
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
pauseBtn.classList.remove('btn-primary');
|
||||
pauseBtn.classList.add('btn-ghost');
|
||||
if (recognition) try { recognition.start(); } catch(e) {}
|
||||
showToast('Resumed', 'info');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearBtn.addEventListener('click', function() {
|
||||
transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
|
||||
window._savedEncId_dictation = null;
|
||||
var labelEl = document.getElementById('dict-label');
|
||||
if (labelEl) labelEl.value = '';
|
||||
});
|
||||
|
||||
generateBtn.addEventListener('click', function() {
|
||||
var text = transcript.innerText.trim();
|
||||
|
|
@ -107,5 +150,16 @@
|
|||
document.getElementById('dict-refine-btn').addEventListener('click', function() { refineDocument('dict-hpi-text', 'dict-refine-input'); });
|
||||
document.getElementById('dict-shorten-btn').addEventListener('click', function() { shortenDocument('dict-hpi-text'); });
|
||||
|
||||
// Register load handler
|
||||
if (typeof registerEncounterLoadHandler === 'function') {
|
||||
registerEncounterLoadHandler('dictation', function(enc) {
|
||||
if (enc.transcript) transcript.textContent = enc.transcript;
|
||||
if (enc.generated_note) {
|
||||
hpiText.textContent = enc.generated_note;
|
||||
outputCard.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Dictation module loaded');
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@
|
|||
Mpox: 'Mpox (JYNNEOS)',
|
||||
};
|
||||
|
||||
// ─── Module-level state for visit statuses ────────────────────────────────
|
||||
// keyed by visitId + '.' + itemKey
|
||||
var _visitStatuses = {};
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||
function init() {
|
||||
populateVisitSelect();
|
||||
|
|
@ -182,10 +186,16 @@
|
|||
html += '<div class="wv-vax-list">';
|
||||
data.vaccines.forEach(function (v) {
|
||||
var fullName = VACCINE_FULL_NAMES[v.vaccine] || v.vaccine;
|
||||
html += '<div class="wv-vax-item">';
|
||||
var itemKey = 'vax_' + v.vaccine + '_d' + (v.dose || '');
|
||||
var statusKey = visitId + '.' + itemKey;
|
||||
var curStatus = _visitStatuses[statusKey] || '';
|
||||
html += '<div class="wv-vax-item" data-status-key="' + esc(statusKey) + '" data-item-type="vaccine" data-item-label="' + esc(fullName + (v.dose ? ' Dose ' + v.dose : '')) + '">';
|
||||
html += '<div class="wv-vax-name"><i class="fas fa-circle-dot wv-vax-dot"></i>' + esc(fullName) + '</div>';
|
||||
if (v.dose) html += '<span class="wv-vax-dose">Dose ' + esc(String(v.dose)) + '</span>';
|
||||
if (v.notes) html += '<div class="wv-vax-note"><i class="fas fa-circle-info"></i> ' + esc(v.notes) + '</div>';
|
||||
html += '<div class="wv-vax-status-row" style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px;">';
|
||||
html += renderVaxStatusBtns(statusKey, curStatus);
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
|
|
@ -196,7 +206,7 @@
|
|||
if (sensoryItems.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-eye"></i> Sensory Screens</h4>';
|
||||
html += renderScreenItems(sensoryItems);
|
||||
html += renderScreenItems(sensoryItems, visitId);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -205,7 +215,7 @@
|
|||
if (devItems.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-brain"></i> Developmental / Behavioral Screens</h4>';
|
||||
html += renderScreenItems(devItems);
|
||||
html += renderScreenItems(devItems, visitId);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -214,7 +224,7 @@
|
|||
if (procItems.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-flask"></i> Labs & Procedures</h4>';
|
||||
html += renderScreenItems(procItems);
|
||||
html += renderScreenItems(procItems, visitId);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +233,7 @@
|
|||
if (oralItems.length) {
|
||||
html += '<div class="wv-section">';
|
||||
html += '<h4 class="wv-section-title"><i class="fas fa-tooth"></i> Oral Health</h4>';
|
||||
html += renderScreenItems(oralItems);
|
||||
html += renderScreenItems(oralItems, visitId);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +242,157 @@
|
|||
html += '<div class="wv-section wv-notes-box"><i class="fas fa-circle-info"></i> ' + esc(data.notes) + '</div>';
|
||||
}
|
||||
|
||||
// ── Copy to Visit Note button ──
|
||||
html += '<div style="margin-top:12px;padding:8px 0;">';
|
||||
html += '<button class="btn-sm btn-primary" id="btn-wv-copy-to-note" style="font-size:12px;"><i class="fas fa-clipboard-check"></i> Copy Statuses to Visit Note</button>';
|
||||
html += '<span style="font-size:11px;color:var(--g400);margin-left:8px;">Copies vaccine + screening statuses into the Screenings & Vaccines fields</span>';
|
||||
html += '</div>';
|
||||
|
||||
panel.innerHTML = html || '<p class="wv-empty">No specific recommendations found for this visit.</p>';
|
||||
|
||||
// Wire up status buttons
|
||||
panel.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.visit-status-btn');
|
||||
if (btn) handleVisitStatusClick(btn, visitId);
|
||||
|
||||
if (e.target.closest('#btn-wv-copy-to-note')) {
|
||||
copyVisitStatusesToNote(visitId);
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up notes inputs
|
||||
panel.addEventListener('input', function (e) {
|
||||
var noteInput = e.target.closest('.visit-note-input');
|
||||
if (noteInput) {
|
||||
var sk = noteInput.dataset.statusKey;
|
||||
if (!_visitStatuses[sk]) _visitStatuses[sk] = {};
|
||||
if (typeof _visitStatuses[sk] === 'string') _visitStatuses[sk] = { status: _visitStatuses[sk] };
|
||||
_visitStatuses[sk].note = noteInput.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderVaxStatusBtns(statusKey, curStatus) {
|
||||
var statuses = [
|
||||
{ value: 'Given', label: 'Given', cls: 'given' },
|
||||
{ value: 'Refused', label: 'Refused', cls: 'refused' },
|
||||
{ value: 'Deferred', label: 'Deferred', cls: 'deferred' },
|
||||
{ value: 'Already Done', label: 'Already Done', cls: 'already-done' },
|
||||
];
|
||||
return statuses.map(function (s) {
|
||||
var active = (typeof curStatus === 'object' ? curStatus.status : curStatus) === s.value;
|
||||
return '<button class="visit-status-btn' + (active ? ' ' + s.cls : '') + '" data-status-key="' + esc(statusKey) + '" data-status-val="' + esc(s.value) + '" data-btn-cls="' + s.cls + '">' + s.label + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderScreenStatusBtns(statusKey, curStatus) {
|
||||
var statuses = [
|
||||
{ value: 'Done', label: 'Done', cls: 'done' },
|
||||
{ value: 'Refused', label: 'Refused', cls: 'refused' },
|
||||
{ value: 'Not Due / N/A', label: 'Not Due / N/A', cls: 'not-due' },
|
||||
];
|
||||
return statuses.map(function (s) {
|
||||
var active = (typeof curStatus === 'object' ? curStatus.status : curStatus) === s.value;
|
||||
return '<button class="visit-status-btn' + (active ? ' ' + s.cls : '') + '" data-status-key="' + esc(statusKey) + '" data-status-val="' + esc(s.value) + '" data-btn-cls="' + s.cls + '">' + s.label + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function handleVisitStatusClick(btn, visitId) {
|
||||
var statusKey = btn.dataset.statusKey;
|
||||
var statusVal = btn.dataset.statusVal;
|
||||
var btnCls = btn.dataset.btnCls;
|
||||
if (!statusKey) return;
|
||||
|
||||
// Toggle: clicking same status deselects
|
||||
var cur = _visitStatuses[statusKey];
|
||||
var curVal = typeof cur === 'object' ? (cur ? cur.status : '') : (cur || '');
|
||||
var note = typeof cur === 'object' && cur ? (cur.note || '') : '';
|
||||
|
||||
if (curVal === statusVal) {
|
||||
_visitStatuses[statusKey] = { status: '', note: note };
|
||||
} else {
|
||||
_visitStatuses[statusKey] = { status: statusVal, note: note };
|
||||
}
|
||||
|
||||
// Update button highlight in this row
|
||||
var container = btn.closest('[data-status-key]') || btn.parentElement;
|
||||
// Find all visit-status-btn siblings with same statusKey
|
||||
var parent = btn.parentElement;
|
||||
parent.querySelectorAll('.visit-status-btn').forEach(function (b) {
|
||||
b.className = 'visit-status-btn';
|
||||
});
|
||||
var newVal = _visitStatuses[statusKey].status;
|
||||
if (newVal) {
|
||||
parent.querySelectorAll('.visit-status-btn').forEach(function (b) {
|
||||
if (b.dataset.statusVal === newVal) {
|
||||
b.classList.add(b.dataset.btnCls);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function copyVisitStatusesToNote(visitId) {
|
||||
var lines = [];
|
||||
|
||||
// Vaccines
|
||||
var vaxLines = [];
|
||||
Object.keys(_visitStatuses).forEach(function (k) {
|
||||
if (k.indexOf(visitId + '.vax_') === 0) {
|
||||
var itemEl = document.querySelector('[data-status-key="' + k + '"]');
|
||||
var label = itemEl ? itemEl.dataset.itemLabel : k.replace(visitId + '.vax_', '');
|
||||
var s = _visitStatuses[k];
|
||||
var status = typeof s === 'object' ? s.status : s;
|
||||
var note = typeof s === 'object' ? (s.note || '') : '';
|
||||
if (status) {
|
||||
vaxLines.push(label + ': ' + status + (note ? ' (' + note + ')' : ''));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (vaxLines.length) {
|
||||
lines.push('Vaccines:');
|
||||
vaxLines.forEach(function (l) { lines.push(' - ' + l); });
|
||||
}
|
||||
|
||||
// Screenings
|
||||
var screenLines = [];
|
||||
Object.keys(_visitStatuses).forEach(function (k) {
|
||||
if (k.indexOf(visitId + '.') === 0 && k.indexOf(visitId + '.vax_') !== 0) {
|
||||
var itemEl = document.querySelector('[data-status-key="' + k + '"]');
|
||||
var label = itemEl ? itemEl.dataset.itemLabel : k.replace(visitId + '.', '');
|
||||
var s = _visitStatuses[k];
|
||||
var status = typeof s === 'object' ? s.status : s;
|
||||
var note = typeof s === 'object' ? (s.note || '') : '';
|
||||
if (status) {
|
||||
screenLines.push(label + ': ' + status + (note ? ' (' + note + ')' : ''));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (screenLines.length) {
|
||||
lines.push('Screenings:');
|
||||
screenLines.forEach(function (l) { lines.push(' - ' + l); });
|
||||
}
|
||||
|
||||
if (!lines.length) {
|
||||
if (typeof showToast === 'function') showToast('No statuses selected yet. Click Done/Given/etc on each item first.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
var text = lines.join('\n');
|
||||
|
||||
// Put into wv-vaccines and wv-screenings textareas
|
||||
var vaxEl = document.getElementById('wv-vaccines');
|
||||
var screenEl = document.getElementById('wv-screenings');
|
||||
|
||||
if (vaxLines.length && vaxEl) {
|
||||
var existing = vaxEl.value.trim();
|
||||
vaxEl.value = existing ? existing + '\n' + vaxLines.join('\n') : vaxLines.join('\n');
|
||||
}
|
||||
if (screenLines.length && screenEl) {
|
||||
var existingS = screenEl.value.trim();
|
||||
screenEl.value = existingS ? existingS + '\n' + screenLines.join('\n') : screenLines.join('\n');
|
||||
}
|
||||
|
||||
if (typeof showToast === 'function') showToast('Visit statuses copied to Visit Note tab', 'success');
|
||||
}
|
||||
|
||||
function buildStatusItems(obj, labels, exclude) {
|
||||
|
|
@ -244,14 +404,23 @@
|
|||
});
|
||||
}
|
||||
|
||||
function renderScreenItems(items) {
|
||||
function renderScreenItems(items, visitId) {
|
||||
var html = '<div class="wv-screen-list">';
|
||||
items.forEach(function (item) {
|
||||
var statusClass = item.status === 'dot' ? 'wv-status-dot' : item.status === 'range' ? 'wv-status-range' : 'wv-status-risk';
|
||||
var statusText = item.status === 'dot' ? 'Recommended' : item.status === 'range' ? 'In Range' : 'If at Risk';
|
||||
html += '<div class="wv-screen-item">';
|
||||
var statusKey = (visitId || 'visit') + '.' + item.key;
|
||||
var curStatus = _visitStatuses[statusKey] || {};
|
||||
var curVal = typeof curStatus === 'object' ? (curStatus.status || '') : (curStatus || '');
|
||||
var curNote = typeof curStatus === 'object' ? (curStatus.note || '') : '';
|
||||
|
||||
html += '<div class="wv-screen-item" data-status-key="' + esc(statusKey) + '" data-item-label="' + esc(item.label) + '">';
|
||||
html += '<span class="wv-status-badge ' + statusClass + '">' + statusText + '</span>';
|
||||
html += '<span class="wv-screen-name">' + esc(item.label) + '</span>';
|
||||
html += '<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto;">';
|
||||
html += renderScreenStatusBtns(statusKey, curVal);
|
||||
html += '<input class="visit-note-input" data-status-key="' + esc(statusKey) + '" type="text" placeholder="Notes..." style="font-size:11px;padding:2px 6px;border:1px solid var(--g300);border-radius:6px;width:120px;" value="' + esc(curNote) + '">';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
|
|
|||
44
server.js
44
server.js
|
|
@ -101,19 +101,20 @@ app.use(express.static(path.join(__dirname, 'public'), {
|
|||
// Routes
|
||||
app.use('/api/auth', require('./src/routes/auth'));
|
||||
app.use('/api/admin', require('./src/routes/admin'));
|
||||
app.use('/api', require('./src/routes/transcribe'));
|
||||
app.use('/api', require('./src/routes/hpi'));
|
||||
app.use('/api', require('./src/routes/hospitalCourse'));
|
||||
app.use('/api', require('./src/routes/chartReview'));
|
||||
app.use('/api', require('./src/routes/milestones'));
|
||||
app.use('/api', require('./src/routes/soap'));
|
||||
app.use('/api', require('./src/routes/tts'));
|
||||
app.use('/api', require('./src/routes/nextcloud'));
|
||||
app.use('/api', require('./src/routes/refine'));
|
||||
app.use('/api', require('./src/routes/logs'));
|
||||
app.use('/api/admin', require('./src/routes/adminConfig'));
|
||||
|
||||
// Public endpoints — must come BEFORE any router that applies authMiddleware to /api/*
|
||||
const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models');
|
||||
app.get('/api/models', (req, res) => res.json({ models: getAvailableModels(), provider: modelsProvider }));
|
||||
app.get('/api/models', async (req, res) => {
|
||||
try {
|
||||
var { getAvailableModelsWithOverrides } = require('./src/utils/models');
|
||||
var db = require('./src/db/database');
|
||||
var models = await getAvailableModelsWithOverrides(db);
|
||||
res.json({ models: models, provider: modelsProvider });
|
||||
} catch(e) {
|
||||
res.json({ models: getAvailableModels(), provider: modelsProvider });
|
||||
}
|
||||
});
|
||||
|
||||
const { activeProvider } = require('./src/utils/ai');
|
||||
app.get('/api/health', (req, res) => {
|
||||
|
|
@ -127,6 +128,22 @@ app.get('/api/health', (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
// Authenticated feature routes
|
||||
app.use('/api', require('./src/routes/transcribe'));
|
||||
app.use('/api', require('./src/routes/hpi'));
|
||||
app.use('/api', require('./src/routes/hospitalCourse'));
|
||||
app.use('/api', require('./src/routes/chartReview'));
|
||||
app.use('/api', require('./src/routes/milestones'));
|
||||
app.use('/api', require('./src/routes/soap'));
|
||||
app.use('/api', require('./src/routes/tts'));
|
||||
app.use('/api', require('./src/routes/nextcloud'));
|
||||
app.use('/api', require('./src/routes/refine'));
|
||||
app.use('/api', require('./src/routes/logs'));
|
||||
app.use('/api', require('./src/routes/encounters'));
|
||||
app.use('/api', require('./src/routes/memories'));
|
||||
app.use('/api', require('./src/routes/wellVisit'));
|
||||
app.use('/api', require('./src/routes/sickVisit'));
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
|
|
@ -134,6 +151,11 @@ app.get('*', (req, res) => {
|
|||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Load prompt DB overrides after DB is ready (3s grace period)
|
||||
const PROMPTS = require('./src/utils/prompts');
|
||||
const db = require('./src/db/database');
|
||||
setTimeout(() => { PROMPTS.loadFromDb(db); }, 3000);
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
server.listen(PORT, () => {
|
||||
console.log('');
|
||||
|
|
|
|||
|
|
@ -97,22 +97,80 @@ async function initDatabase() {
|
|||
CREATE INDEX IF NOT EXISTS idx_api_ts ON api_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_access_ts ON access_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_encounters (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL DEFAULT 'Untitled',
|
||||
enc_type TEXT NOT NULL DEFAULT 'encounter',
|
||||
transcript TEXT DEFAULT '',
|
||||
generated_note TEXT DEFAULT '',
|
||||
partial_data TEXT DEFAULT '{}',
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_memories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
category TEXT NOT NULL DEFAULT 'custom',
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id);
|
||||
`);
|
||||
|
||||
// Add columns if upgrading
|
||||
var migrations = [
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'user'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT false"
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT false",
|
||||
"CREATE TABLE IF NOT EXISTS saved_encounters (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, label TEXT NOT NULL DEFAULT 'Untitled', enc_type TEXT NOT NULL DEFAULT 'encounter', transcript TEXT DEFAULT '', generated_note TEXT DEFAULT '', partial_data TEXT DEFAULT '{}', status TEXT DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days')",
|
||||
"CREATE TABLE IF NOT EXISTS user_memories (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, category TEXT NOT NULL DEFAULT 'custom', name TEXT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
|
||||
"CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id)"
|
||||
];
|
||||
for (var i = 0; i < migrations.length; i++) {
|
||||
try { await client.query(migrations[i]); } catch(e) {}
|
||||
}
|
||||
|
||||
// Initialize default settings
|
||||
await client.query(`
|
||||
INSERT INTO app_settings (key, value) VALUES ('registration_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`);
|
||||
// Add updated_by column to app_settings if upgrading
|
||||
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
|
||||
|
||||
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
|
||||
var defaults = [
|
||||
// Core
|
||||
['registration_enabled', 'true'],
|
||||
// Announcements
|
||||
['announcement.enabled', 'false'],
|
||||
['announcement.text', ''],
|
||||
['announcement.type', 'info'],
|
||||
// Feature flags
|
||||
['feature.read_aloud', 'true'],
|
||||
['feature.nextcloud', 'true'],
|
||||
// Email — verify
|
||||
['email.verify.subject', 'Verify your Pediatric AI Scribe account'],
|
||||
['email.verify.body', 'Thank you for signing up! Click the button below to verify your email address. This link expires in 24 hours.'],
|
||||
// Email — reset
|
||||
['email.reset.subject', 'Reset your password — Pediatric AI Scribe'],
|
||||
['email.reset.body', 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed — your password has not been changed.'],
|
||||
['feature.memories', 'true'],
|
||||
['site.name', 'Pediatric AI Scribe'],
|
||||
['site.auto_delete_days', '7'],
|
||||
];
|
||||
for (var d = 0; d < defaults.length; d++) {
|
||||
await client.query(
|
||||
"INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING",
|
||||
defaults[d]
|
||||
);
|
||||
}
|
||||
|
||||
console.log('✅ Database tables: ready');
|
||||
} catch (err) {
|
||||
|
|
@ -124,6 +182,16 @@ async function initDatabase() {
|
|||
|
||||
initDatabase();
|
||||
|
||||
// Clean up expired saved encounters daily
|
||||
async function cleanupExpiredEncounters() {
|
||||
try {
|
||||
var result = await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()');
|
||||
if (result.rowCount > 0) console.log('[DB] Cleaned up ' + result.rowCount + ' expired encounters');
|
||||
} catch(e) { console.error('[DB] Cleanup error:', e.message); }
|
||||
}
|
||||
setInterval(cleanupExpiredEncounters, 6 * 60 * 60 * 1000); // every 6 hours
|
||||
setTimeout(cleanupExpiredEncounters, 10000); // also run 10s after startup
|
||||
|
||||
// Query helpers
|
||||
var db = {
|
||||
get: async function(sql, params) {
|
||||
|
|
|
|||
300
src/routes/adminConfig.js
Normal file
300
src/routes/adminConfig.js
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
// ============================================================
|
||||
// ADMIN CONFIG ROUTES — CMS for announcements, prompts, emails, flags
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
var PROMPTS = require('../utils/prompts');
|
||||
var logger = require('../utils/logger');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// ── GET announcement (any authenticated user) ──────────────────────────────
|
||||
router.get('/config/announcement', async function(req, res) {
|
||||
try {
|
||||
var enabled = await db.getSetting('announcement.enabled');
|
||||
var text = await db.getSetting('announcement.text');
|
||||
var type = await db.getSetting('announcement.type');
|
||||
res.json({
|
||||
success: true,
|
||||
enabled: enabled === 'true',
|
||||
text: text || '',
|
||||
type: type || 'info'
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.use(adminMiddleware);
|
||||
|
||||
// ── GET all config entries ─────────────────────────────────────────────────
|
||||
router.get('/config', async function(req, res) {
|
||||
try {
|
||||
var rows = await db.all(
|
||||
"SELECT key, value, updated_at FROM app_settings ORDER BY key",
|
||||
[]
|
||||
);
|
||||
// Also include current in-memory prompts (may differ if not yet in DB)
|
||||
var promptKeys = PROMPTS.getAllPrompts().map(function(p) { return p.key; });
|
||||
var inDb = new Set(rows.filter(function(r) { return r.key.startsWith('prompt.'); }).map(function(r) { return r.key; }));
|
||||
promptKeys.forEach(function(key) {
|
||||
var dbKey = 'prompt.' + key;
|
||||
if (!inDb.has(dbKey)) {
|
||||
rows.push({ key: dbKey, value: PROMPTS[key], updated_at: null });
|
||||
}
|
||||
});
|
||||
res.json({ success: true, config: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── PUT update a single config entry ──────────────────────────────────────
|
||||
router.put('/config/:key(*)', async function(req, res) {
|
||||
try {
|
||||
var key = req.params.key;
|
||||
var value = req.body.value;
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return res.status(400).json({ error: 'value is required' });
|
||||
}
|
||||
|
||||
// Security: only allow known key prefixes
|
||||
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.'];
|
||||
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
|
||||
if (!isAllowed) {
|
||||
return res.status(400).json({ error: 'Unknown config key' });
|
||||
}
|
||||
|
||||
await db.setSetting(key, String(value));
|
||||
|
||||
// Update in-memory prompt immediately if it's a prompt key
|
||||
if (key.startsWith('prompt.')) {
|
||||
var promptKey = key.replace('prompt.', '');
|
||||
PROMPTS.updatePrompt(promptKey, String(value));
|
||||
}
|
||||
|
||||
logger.audit(req.user.id, 'admin_config_update', 'Updated config: ' + key, req, { category: 'admin' });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST send test email ───────────────────────────────────────────────────
|
||||
router.post('/config/test-email', async function(req, res) {
|
||||
try {
|
||||
var { to, template } = req.body;
|
||||
if (!to) return res.status(400).json({ error: 'Recipient email required' });
|
||||
|
||||
var subjectKey = 'email.' + (template || 'verify') + '.subject';
|
||||
var bodyKey = 'email.' + (template || 'verify') + '.body';
|
||||
|
||||
var subject = await db.getSetting(subjectKey) || 'Test email from Pediatric AI Scribe';
|
||||
var bodyText = await db.getSetting(bodyKey) || 'This is a test email.';
|
||||
|
||||
var sendEmail = require('./auth').__sendEmail;
|
||||
if (!sendEmail) {
|
||||
return res.status(400).json({ error: 'Email not configured (SMTP_HOST missing)' });
|
||||
}
|
||||
|
||||
var emailWrapper = require('./auth').__emailWrapper;
|
||||
var btnStyle = require('./auth').__btnStyle;
|
||||
|
||||
var html = emailWrapper(
|
||||
'<p style="color:#4b5563;margin:0 0 20px;line-height:1.6;">' + bodyText.replace(/\n/g, '<br>') + '</p>' +
|
||||
(btnStyle ? '<a href="#" style="' + btnStyle() + '">Test Button</a>' : '')
|
||||
);
|
||||
|
||||
var ok = await sendEmail(to, '[TEST] ' + subject, html);
|
||||
res.json({ success: ok, message: ok ? 'Test email sent to ' + to : 'SMTP not configured' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET prompt list (for editor UI) ───────────────────────────────────────
|
||||
router.get('/config/prompts', async function(req, res) {
|
||||
try {
|
||||
var prompts = PROMPTS.getAllPrompts();
|
||||
// Load any DB overrides
|
||||
var dbRows = await db.all("SELECT key, value FROM app_settings WHERE key LIKE 'prompt.%'", []);
|
||||
var dbMap = {};
|
||||
dbRows.forEach(function(r) { dbMap[r.key] = r.value; });
|
||||
prompts.forEach(function(p) {
|
||||
var dbKey = 'prompt.' + p.key;
|
||||
if (dbMap[dbKey]) p.value = dbMap[dbKey];
|
||||
p.dbKey = dbKey;
|
||||
});
|
||||
res.json({ success: true, prompts: prompts });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST reset a prompt to hardcoded default ──────────────────────────────
|
||||
router.post('/config/prompts/:key/reset', async function(req, res) {
|
||||
try {
|
||||
var key = req.params.key;
|
||||
if (!Object.prototype.hasOwnProperty.call(PROMPTS, key) || typeof PROMPTS[key] !== 'string') {
|
||||
return res.status(404).json({ error: 'Prompt not found' });
|
||||
}
|
||||
// Delete DB override so hardcoded default is used
|
||||
await db.run("DELETE FROM app_settings WHERE key = ?", ['prompt.' + key]);
|
||||
// Reload from hardcoded source (re-require)
|
||||
delete require.cache[require.resolve('../utils/prompts')];
|
||||
var fresh = require('../utils/prompts');
|
||||
PROMPTS[key] = fresh[key] || PROMPTS[key];
|
||||
|
||||
logger.audit(req.user.id, 'admin_config_reset', 'Reset prompt to default: ' + key, req, { category: 'admin' });
|
||||
res.json({ success: true, value: PROMPTS[key] });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST reset all non-prompt settings to defaults ──────────────────────
|
||||
router.post('/config/reset-defaults', async function(req, res) {
|
||||
try {
|
||||
var defaults = [
|
||||
['registration_enabled', 'true'],
|
||||
['announcement.enabled', 'false'],
|
||||
['announcement.text', ''],
|
||||
['announcement.type', 'info'],
|
||||
['feature.read_aloud', 'true'],
|
||||
['feature.nextcloud', 'true'],
|
||||
['feature.memories', 'true'],
|
||||
['email.verify.subject', 'Verify your Pediatric AI Scribe account'],
|
||||
['email.verify.body', 'Thank you for signing up! Click the button below to verify your email address. This link expires in 24 hours.'],
|
||||
['email.reset.subject', 'Reset your password — Pediatric AI Scribe'],
|
||||
['email.reset.body', 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed — your password has not been changed.'],
|
||||
['site.name', 'Pediatric AI Scribe'],
|
||||
['site.auto_delete_days', '7'],
|
||||
];
|
||||
for (var d = 0; d < defaults.length; d++) {
|
||||
await db.setSetting(defaults[d][0], defaults[d][1]);
|
||||
}
|
||||
logger.audit(req.user.id, 'admin_config_reset_all', 'Reset all settings to defaults', req, { category: 'admin' });
|
||||
res.json({ success: true, message: 'Settings reset to defaults (SMTP and custom models preserved)' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET SMTP status ──────────────────────────────────────────────────────
|
||||
router.get('/config/smtp/status', async function(req, res) {
|
||||
try {
|
||||
var host = await db.getSetting('smtp.host') || process.env.SMTP_HOST || '';
|
||||
var user = await db.getSetting('smtp.user') || process.env.SMTP_USER || '';
|
||||
var from = await db.getSetting('smtp.from') || process.env.SMTP_FROM || process.env.SMTP_USER || '';
|
||||
var port = await db.getSetting('smtp.port') || process.env.SMTP_PORT || '587';
|
||||
// Never return password
|
||||
res.json({
|
||||
success: true,
|
||||
configured: !!host,
|
||||
host: host,
|
||||
port: port,
|
||||
user: user,
|
||||
from: from,
|
||||
source: process.env.SMTP_HOST ? 'env' : (host ? 'database' : 'none')
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── PUT update SMTP settings ─────────────────────────────────────────────
|
||||
router.put('/config/smtp', async function(req, res) {
|
||||
try {
|
||||
var { host, port, user, pass, from, secure } = req.body;
|
||||
if (!host) return res.status(400).json({ error: 'SMTP host required' });
|
||||
|
||||
await db.setSetting('smtp.host', host.trim());
|
||||
await db.setSetting('smtp.port', String(port || '587').trim());
|
||||
await db.setSetting('smtp.user', (user || '').trim());
|
||||
await db.setSetting('smtp.from', (from || user || '').trim());
|
||||
await db.setSetting('smtp.secure', String(secure === true || secure === 'true'));
|
||||
if (pass && pass.trim()) {
|
||||
await db.setSetting('smtp.pass', pass.trim()); // stored as-is; consider encrypting at rest
|
||||
}
|
||||
|
||||
logger.audit(req.user.id, 'admin_smtp_update', 'Updated SMTP settings', req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── DELETE SMTP settings (clear DB override, fall back to env) ───────────
|
||||
router.delete('/config/smtp', async function(req, res) {
|
||||
try {
|
||||
var keys = ['smtp.host', 'smtp.port', 'smtp.user', 'smtp.pass', 'smtp.from', 'smtp.secure'];
|
||||
for (var k of keys) {
|
||||
await db.run('DELETE FROM app_settings WHERE key = $1', [k]);
|
||||
}
|
||||
logger.audit(req.user.id, 'admin_smtp_clear', 'Cleared SMTP DB overrides', req, { category: 'admin' });
|
||||
res.json({ success: true, message: 'SMTP DB settings cleared (env vars still apply if set)' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET models config ────────────────────────────────────────────────────
|
||||
router.get('/config/models', async function(req, res) {
|
||||
try {
|
||||
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, activeProvider } = require('../utils/models');
|
||||
var providerModels = activeProvider === 'bedrock' ? BEDROCK_MODELS : (activeProvider === 'azure' ? AZURE_MODELS : OPENROUTER_MODELS);
|
||||
|
||||
var disabledRaw = await db.getSetting('models.disabled') || '[]';
|
||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||
var disabled, custom;
|
||||
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||
|
||||
var models = providerModels.map(function(m) {
|
||||
return Object.assign({}, m, { enabled: !disabled.includes(m.id) });
|
||||
});
|
||||
|
||||
res.json({ success: true, provider: activeProvider, models: models, custom: custom });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── PUT update model enabled/disabled state ──────────────────────────────
|
||||
router.put('/config/models/toggle', async function(req, res) {
|
||||
try {
|
||||
var { modelId, enabled } = req.body;
|
||||
if (!modelId) return res.status(400).json({ error: 'modelId required' });
|
||||
|
||||
var disabledRaw = await db.getSetting('models.disabled') || '[]';
|
||||
var disabled;
|
||||
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
||||
|
||||
if (enabled) {
|
||||
disabled = disabled.filter(function(id) { return id !== modelId; });
|
||||
} else {
|
||||
if (!disabled.includes(modelId)) disabled.push(modelId);
|
||||
}
|
||||
|
||||
await db.setSetting('models.disabled', JSON.stringify(disabled));
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST add custom model ────────────────────────────────────────────────
|
||||
router.post('/config/models/custom', async function(req, res) {
|
||||
try {
|
||||
var { id, name, cost, category } = req.body;
|
||||
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
|
||||
|
||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||
var custom;
|
||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||
|
||||
// Prevent duplicates
|
||||
custom = custom.filter(function(m) { return m.id !== id; });
|
||||
custom.push({ id: id.trim(), name: name.trim(), cost: cost || '?', category: category || 'smart', tag: 'CUSTOM' });
|
||||
|
||||
await db.setSetting('models.custom', JSON.stringify(custom));
|
||||
logger.audit(req.user.id, 'admin_model_add', 'Added custom model: ' + id, req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── DELETE custom model ──────────────────────────────────────────────────
|
||||
router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
|
||||
try {
|
||||
var modelId = req.params.modelId;
|
||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||
var custom;
|
||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||
custom = custom.filter(function(m) { return m.id !== modelId; });
|
||||
await db.setSetting('models.custom', JSON.stringify(custom));
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -35,21 +35,28 @@ function btnStyle() {
|
|||
return 'display:inline-block;background:linear-gradient(135deg,#2563eb,#7c3aed);color:white;padding:13px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;';
|
||||
}
|
||||
|
||||
// Email helper
|
||||
// Email helper — DB settings override env vars
|
||||
async function getSmtpTransport() {
|
||||
var nodemailer = require('nodemailer');
|
||||
// DB settings override env vars
|
||||
var host = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
|
||||
if (!host) return null;
|
||||
var port = parseInt(await db.getSetting('smtp.port').catch(function() { return null; }) || process.env.SMTP_PORT || '587', 10);
|
||||
var user = await db.getSetting('smtp.user').catch(function() { return null; }) || process.env.SMTP_USER || '';
|
||||
var pass = await db.getSetting('smtp.pass').catch(function() { return null; }) || process.env.SMTP_PASS || '';
|
||||
var from = await db.getSetting('smtp.from').catch(function() { return null; }) || process.env.SMTP_FROM || user;
|
||||
var secure = (await db.getSetting('smtp.secure').catch(function() { return null; }) || process.env.SMTP_SECURE || 'false') === 'true';
|
||||
return { transport: nodemailer.createTransport({ host: host, port: port, secure: secure, auth: user ? { user: user, pass: pass } : undefined }), from: from };
|
||||
}
|
||||
|
||||
async function sendEmail(to, subject, html) {
|
||||
if (!process.env.SMTP_HOST) {
|
||||
console.log('[Email] SMTP not configured. Would send to:', to);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
var nodemailer = require('nodemailer');
|
||||
var transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
});
|
||||
await transporter.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER, to: to, subject: subject, html: html });
|
||||
var smtp = await getSmtpTransport();
|
||||
if (!smtp) {
|
||||
console.log('[Email] SMTP not configured. Would send to:', to);
|
||||
return false;
|
||||
}
|
||||
await smtp.transport.sendMail({ from: smtp.from, to: to, subject: subject, html: html });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Email] Failed:', err.message);
|
||||
|
|
@ -91,9 +98,11 @@ router.post('/register', async (req, res) => {
|
|||
var userId = result.lastInsertRowid;
|
||||
|
||||
var verifyUrl = (process.env.APP_URL || 'http://localhost:3000') + '/api/auth/verify-email?token=' + verifyToken;
|
||||
await sendEmail(email, 'Welcome — please verify your email', emailWrapper(
|
||||
var verifySubject = await db.getSetting('email.verify.subject') || 'Verify your Pediatric AI Scribe account';
|
||||
var verifyBody = await db.getSetting('email.verify.body') || 'Great to have you on Pediatric AI Scribe. Please verify your email address by clicking the button below.';
|
||||
await sendEmail(email, verifySubject, emailWrapper(
|
||||
`<p style="margin:0 0 8px;font-size:22px;">👋 Welcome aboard, ${name}!</p>
|
||||
<p style="color:#4b5563;margin:12px 0 24px;line-height:1.6;">Great to have you on Pediatric AI Scribe. Before you start generating clinical notes, please confirm your email address by clicking the button below.</p>
|
||||
<p style="color:#4b5563;margin:12px 0 24px;line-height:1.6;">${verifyBody.replace(/\n/g, '<br>')}</p>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${verifyUrl}" style="${btnStyle()}">Verify My Email</a>
|
||||
</p>
|
||||
|
|
@ -105,8 +114,9 @@ router.post('/register', async (req, res) => {
|
|||
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
||||
[userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']);
|
||||
|
||||
// Auto-verify if no SMTP
|
||||
if (!process.env.SMTP_HOST) {
|
||||
// Auto-verify if no SMTP (check DB setting first, then env)
|
||||
var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
|
||||
if (!smtpHost) {
|
||||
await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]);
|
||||
var token = jwt.sign({ userId: userId }, JWT_SECRET, { expiresIn: '7d' });
|
||||
return res.json({
|
||||
|
|
@ -244,17 +254,17 @@ router.post('/forgot-password', async (req, res) => {
|
|||
if (!user) return res.json({ success: true, message: 'If account exists, reset email sent' });
|
||||
var token = crypto.randomBytes(32).toString('hex');
|
||||
await db.run('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?', [token, Date.now() + 3600000, user.id]);
|
||||
var resetUrl = (process.env.APP_URL || 'http://localhost:3000') + '/reset-password?token=' + token;
|
||||
await sendEmail(req.body.email, 'Reset your password — Pediatric AI Scribe', emailWrapper(
|
||||
var resetUrl = (process.env.APP_URL || 'http://localhost:3000') + '/reset-password?token=' + token;
|
||||
var resetSubject = await db.getSetting('email.reset.subject') || 'Reset your password — Pediatric AI Scribe';
|
||||
var resetBody = await db.getSetting('email.reset.body') || 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed.';
|
||||
await sendEmail(req.body.email, resetSubject, emailWrapper(
|
||||
`<p style="margin:0 0 8px;font-size:20px;">🔐 Password reset request</p>
|
||||
<p style="color:#4b5563;margin:12px 0 6px;line-height:1.6;">Hi there,</p>
|
||||
<p style="color:#4b5563;margin:0 0 24px;line-height:1.6;">Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password.</p>
|
||||
<p style="color:#4b5563;margin:12px 0 24px;line-height:1.6;">${resetBody.replace(/\n/g, '<br>')}</p>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${resetUrl}" style="${btnStyle()}">Reset My Password</a>
|
||||
</p>
|
||||
<p style="color:#6b7280;font-size:13px;margin:20px 0 4px;">Button not working? Copy and paste this link into your browser:</p>
|
||||
<p style="background:#f3f4f6;border-radius:6px;padding:10px 14px;font-size:12px;word-break:break-all;color:#374151;margin:0;">${resetUrl}</p>
|
||||
<p style="color:#9ca3af;font-size:12px;margin:20px 0 0;">This link expires in 1 hour. If you did not request a password reset, no action is needed — your password has not been changed.</p>`
|
||||
<p style="background:#f3f4f6;border-radius:6px;padding:10px 14px;font-size:12px;word-break:break-all;color:#374151;margin:0;">${resetUrl}</p>`
|
||||
));
|
||||
res.json({ success: true, message: 'If account exists, reset email sent' });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
|
|
@ -291,4 +301,8 @@ router.get('/registration-status', async (req, res) => {
|
|||
} catch (err) { res.json({ registrationEnabled: true }); }
|
||||
});
|
||||
|
||||
// Expose helpers for adminConfig test-email and email template loading
|
||||
module.exports = router;
|
||||
module.exports.__sendEmail = sendEmail;
|
||||
module.exports.__emailWrapper = emailWrapper;
|
||||
module.exports.__btnStyle = btnStyle;
|
||||
|
|
|
|||
90
src/routes/encounters.js
Normal file
90
src/routes/encounters.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// ============================================================
|
||||
// ENCOUNTERS ROUTES — Save/resume/pause encounter progress
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var logger = require('../utils/logger');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// ── GET all saved encounters for current user ────────────────────────────
|
||||
router.get('/encounters/saved', async function(req, res) {
|
||||
try {
|
||||
var rows = await db.all(
|
||||
"SELECT id, label, enc_type, status, created_at, updated_at, expires_at, LEFT(transcript, 200) as transcript_preview, LEFT(generated_note, 200) as note_preview FROM saved_encounters WHERE user_id = $1 AND expires_at > NOW() ORDER BY updated_at DESC",
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ success: true, encounters: rows });
|
||||
} catch (e) { logger.error('GET /encounters/saved', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET single saved encounter ───────────────────────────────────────────
|
||||
router.get('/encounters/saved/:id', async function(req, res) {
|
||||
try {
|
||||
var row = await db.get(
|
||||
'SELECT * FROM saved_encounters WHERE id = $1 AND user_id = $2 AND expires_at > NOW()',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: 'Encounter not found or expired' });
|
||||
res.json({ success: true, encounter: row });
|
||||
} catch (e) { logger.error('GET /encounters/saved/:id', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST save/update encounter progress ─────────────────────────────────
|
||||
router.post('/encounters/saved', async function(req, res) {
|
||||
try {
|
||||
var { id, label, enc_type, transcript, generated_note, partial_data, status } = req.body;
|
||||
var autoDeleteDays = parseInt(await db.getSetting('site.auto_delete_days') || '7', 10);
|
||||
|
||||
if (id) {
|
||||
// Update existing
|
||||
var existing = await db.get('SELECT id FROM saved_encounters WHERE id = $1 AND user_id = $2', [id, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||
await db.run(
|
||||
'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7',
|
||||
[
|
||||
label || 'Untitled',
|
||||
transcript || '',
|
||||
generated_note || '',
|
||||
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
||||
status || 'active',
|
||||
id, req.user.id
|
||||
]
|
||||
);
|
||||
res.json({ success: true, id: id });
|
||||
} else {
|
||||
// Create new
|
||||
var result = await db.run(
|
||||
'INSERT INTO saved_encounters (user_id, label, enc_type, transcript, generated_note, partial_data, status, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7, NOW() + ($8 || \' days\')::INTERVAL)',
|
||||
[
|
||||
req.user.id,
|
||||
label || 'Untitled',
|
||||
enc_type || 'encounter',
|
||||
transcript || '',
|
||||
generated_note || '',
|
||||
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
||||
status || 'active',
|
||||
autoDeleteDays
|
||||
]
|
||||
);
|
||||
res.json({ success: true, id: result.lastInsertRowid });
|
||||
}
|
||||
} catch (e) { logger.error('POST /encounters/saved', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── DELETE saved encounter ───────────────────────────────────────────────
|
||||
router.delete('/encounters/saved/:id', async function(req, res) {
|
||||
try {
|
||||
var result = await db.run(
|
||||
'DELETE FROM saved_encounters WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { logger.error('DELETE /encounters/saved/:id', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
90
src/routes/memories.js
Normal file
90
src/routes/memories.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// ============================================================
|
||||
// MEMORIES ROUTES — User template/memory storage
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var logger = require('../utils/logger');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
var VALID_CATEGORIES = ['physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom'];
|
||||
|
||||
// ── GET all memories for current user ───────────────────────────────────
|
||||
router.get('/memories', async function(req, res) {
|
||||
try {
|
||||
var rows = await db.all(
|
||||
'SELECT id, category, name, content, created_at, updated_at FROM user_memories WHERE user_id = $1 ORDER BY category, name',
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ success: true, memories: rows });
|
||||
} catch (e) { logger.error('GET /memories', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST create memory ───────────────────────────────────────────────────
|
||||
router.post('/memories', async function(req, res) {
|
||||
try {
|
||||
var { name, category, content } = req.body;
|
||||
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
if (!content || !content.trim()) return res.status(400).json({ error: 'Content required' });
|
||||
var cat = VALID_CATEGORIES.includes(category) ? category : 'custom';
|
||||
|
||||
// Limit per user
|
||||
var count = await db.get('SELECT COUNT(*) as cnt FROM user_memories WHERE user_id = $1', [req.user.id]);
|
||||
if (count && parseInt(count.cnt) >= 50) return res.status(400).json({ error: 'Maximum 50 memories per user' });
|
||||
|
||||
var result = await db.run(
|
||||
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
|
||||
[req.user.id, cat, name.trim().substring(0, 100), content.trim().substring(0, 5000)]
|
||||
);
|
||||
res.json({ success: true, id: result.lastInsertRowid });
|
||||
} catch (e) { logger.error('POST /memories', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── PUT update memory ────────────────────────────────────────────────────
|
||||
router.put('/memories/:id', async function(req, res) {
|
||||
try {
|
||||
var { name, category, content } = req.body;
|
||||
var cat = VALID_CATEGORIES.includes(category) ? category : 'custom';
|
||||
await db.run(
|
||||
'UPDATE user_memories SET name=$1, category=$2, content=$3, updated_at=NOW() WHERE id=$4 AND user_id=$5',
|
||||
[
|
||||
(name || '').trim().substring(0, 100),
|
||||
cat,
|
||||
(content || '').trim().substring(0, 5000),
|
||||
req.params.id,
|
||||
req.user.id
|
||||
]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) { logger.error('PUT /memories/:id', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── DELETE memory ────────────────────────────────────────────────────────
|
||||
router.delete('/memories/:id', async function(req, res) {
|
||||
try {
|
||||
await db.run('DELETE FROM user_memories WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { logger.error('DELETE /memories/:id', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET memories as prompt context (for AI generation) ──────────────────
|
||||
router.get('/memories/context', async function(req, res) {
|
||||
try {
|
||||
var rows = await db.all(
|
||||
'SELECT category, name, content FROM user_memories WHERE user_id = $1 ORDER BY category',
|
||||
[req.user.id]
|
||||
);
|
||||
if (rows.length === 0) return res.json({ success: true, context: '' });
|
||||
|
||||
var context = '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
|
||||
rows.forEach(function(r) {
|
||||
context += '--- ' + r.category.toUpperCase().replace('_', ' ') + ': ' + r.name + ' ---\n' + r.content + '\n\n';
|
||||
});
|
||||
res.json({ success: true, context: context.trim() });
|
||||
} catch (e) { logger.error('GET /memories/context', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
73
src/routes/sickVisit.js
Normal file
73
src/routes/sickVisit.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// ============================================================
|
||||
// SICK VISIT ROUTE — sick visit note generation
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var { callAI } = require('../utils/ai');
|
||||
var PROMPTS = require('../utils/prompts');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var logger = require('../utils/logger');
|
||||
|
||||
// ── POST generate sick visit note ────────────────────────────────────────
|
||||
router.post('/sick-visit/note', authMiddleware, async function(req, res) {
|
||||
var start = Date.now();
|
||||
try {
|
||||
var {
|
||||
patientAge, patientGender, chiefComplaint,
|
||||
transcript, dictation,
|
||||
ros, physicalExam, diagnoses,
|
||||
physicianMemories,
|
||||
model
|
||||
} = req.body;
|
||||
|
||||
if (!chiefComplaint) {
|
||||
return res.status(400).json({ error: 'Chief complaint is required' });
|
||||
}
|
||||
|
||||
// Assemble context
|
||||
var context = 'SICK VISIT\n';
|
||||
context += 'Patient: ' + (patientAge || 'Unknown age') + ', ' + (patientGender || 'Unknown gender') + '\n';
|
||||
context += 'Chief Complaint: ' + chiefComplaint + '\n\n';
|
||||
|
||||
if (transcript) {
|
||||
context += 'ENCOUNTER TRANSCRIPT/DICTATION:\n' + transcript + '\n\n';
|
||||
} else if (dictation) {
|
||||
context += 'PHYSICIAN DICTATION:\n' + dictation + '\n\n';
|
||||
}
|
||||
|
||||
if (ros) {
|
||||
context += ros + '\n\n';
|
||||
}
|
||||
|
||||
if (physicalExam) {
|
||||
context += physicalExam + '\n\n';
|
||||
}
|
||||
|
||||
if (diagnoses) {
|
||||
context += diagnoses + '\n\n';
|
||||
}
|
||||
|
||||
if (physicianMemories) {
|
||||
context += physicianMemories + '\n\n';
|
||||
}
|
||||
|
||||
var result = await callAI([
|
||||
{ role: 'system', content: PROMPTS.sickVisitNote },
|
||||
{ role: 'user', content: context }
|
||||
], { model });
|
||||
|
||||
var dur = Date.now() - start;
|
||||
logger.apiCall(req.user.id, '/api/sick-visit/note', {
|
||||
model: result.model, tokensInput: result.usage?.input_tokens,
|
||||
tokensOutput: result.usage?.output_tokens, duration: dur, statusCode: 200
|
||||
});
|
||||
|
||||
res.json({ success: true, note: result.content, model: result.model });
|
||||
} catch (e) {
|
||||
logger.error('[SickVisit] Note generation failed', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
164
src/routes/wellVisit.js
Normal file
164
src/routes/wellVisit.js
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// ============================================================
|
||||
// WELL VISIT ROUTES — SSHADESS assessment + note generation
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var { callAI } = require('../utils/ai');
|
||||
var PROMPTS = require('../utils/prompts');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var logger = require('../utils/logger');
|
||||
|
||||
// ── POST generate SSHADESS assessment summary ────────────────────────────
|
||||
router.post('/well-visit/shadess', authMiddleware, async function(req, res) {
|
||||
var start = Date.now();
|
||||
try {
|
||||
var { domains, patientAge, patientGender, dictationText, model } = req.body;
|
||||
|
||||
if (!domains && !dictationText) {
|
||||
return res.status(400).json({ error: 'Provide domain responses or dictation text' });
|
||||
}
|
||||
|
||||
// Build structured input text
|
||||
var inputText = 'PATIENT: ' + (patientAge || 'Adolescent') + ', ' + (patientGender || 'Unknown gender') + '\n\n';
|
||||
inputText += 'SSHADESS SCREENING DATA:\n\n';
|
||||
|
||||
if (dictationText) {
|
||||
inputText += 'PHYSICIAN DICTATION:\n' + dictationText + '\n\n';
|
||||
}
|
||||
|
||||
if (domains) {
|
||||
var domainOrder = ['strengths', 'school', 'home', 'activities', 'drugs', 'emotions', 'sexuality', 'safety'];
|
||||
var domainNames = {
|
||||
strengths: 'STRENGTHS',
|
||||
school: 'SCHOOL',
|
||||
home: 'HOME',
|
||||
activities: 'ACTIVITIES',
|
||||
drugs: 'DRUGS/SUBSTANCES',
|
||||
emotions: 'EMOTIONS/EATING',
|
||||
sexuality: 'SEXUALITY',
|
||||
safety: 'SAFETY'
|
||||
};
|
||||
|
||||
domainOrder.forEach(function(key) {
|
||||
var d = domains[key];
|
||||
if (!d || d.skipped) return;
|
||||
|
||||
inputText += domainNames[key] + ':\n';
|
||||
if (d.questions) {
|
||||
d.questions.forEach(function(q) {
|
||||
if (q.answer !== null && q.answer !== undefined && q.answer !== '') {
|
||||
inputText += ' - ' + q.text + ': ' + (q.answer === true || q.answer === 'yes' ? 'YES' : (q.answer === false || q.answer === 'no' ? 'NO' : q.answer)) + '\n';
|
||||
}
|
||||
});
|
||||
}
|
||||
if (d.comment && d.comment.trim()) {
|
||||
inputText += ' Comments: ' + d.comment + '\n';
|
||||
}
|
||||
if (d.concern) {
|
||||
inputText += ' *** CONCERN FLAGGED ***\n';
|
||||
}
|
||||
inputText += '\n';
|
||||
});
|
||||
}
|
||||
|
||||
var result = await callAI([
|
||||
{ role: 'system', content: PROMPTS.shadessAssessment },
|
||||
{ role: 'user', content: inputText }
|
||||
], { model });
|
||||
|
||||
var dur = Date.now() - start;
|
||||
logger.apiCall(req.user.id, '/api/well-visit/shadess', {
|
||||
model: result.model, tokensInput: result.usage?.input_tokens,
|
||||
tokensOutput: result.usage?.output_tokens, duration: dur, statusCode: 200
|
||||
});
|
||||
|
||||
res.json({ success: true, assessment: result.content, model: result.model });
|
||||
} catch (e) {
|
||||
logger.error('[WellVisit] SHADESS generation failed', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST generate full well visit encounter note ─────────────────────────
|
||||
router.post('/well-visit/note', authMiddleware, async function(req, res) {
|
||||
var start = Date.now();
|
||||
try {
|
||||
var {
|
||||
patientAge, patientGender, visitAge,
|
||||
vitals, measurements,
|
||||
transcript, dictation,
|
||||
screenings, vaccines,
|
||||
shadessAssessment,
|
||||
parentConcerns,
|
||||
ros, physicalExam, diagnoses,
|
||||
physicianMemories,
|
||||
noteStyle, // 'full' | 'short'
|
||||
model
|
||||
} = req.body;
|
||||
|
||||
if (!patientAge && !visitAge) {
|
||||
return res.status(400).json({ error: 'Patient age or visit age required' });
|
||||
}
|
||||
|
||||
// Assemble context
|
||||
var context = 'WELL CHILD VISIT\n';
|
||||
context += 'Patient: ' + (patientAge || visitAge) + ', ' + (patientGender || 'Unknown') + '\n\n';
|
||||
|
||||
if (vitals) {
|
||||
context += 'VITAL SIGNS:\n' + vitals + '\n\n';
|
||||
}
|
||||
if (measurements) {
|
||||
context += 'MEASUREMENTS/GROWTH:\n' + measurements + '\n\n';
|
||||
}
|
||||
if (parentConcerns) {
|
||||
context += 'PARENT/PATIENT CONCERNS:\n' + parentConcerns + '\n\n';
|
||||
}
|
||||
if (transcript) {
|
||||
context += 'ENCOUNTER TRANSCRIPT/DICTATION:\n' + transcript + '\n\n';
|
||||
} else if (dictation) {
|
||||
context += 'PHYSICIAN DICTATION:\n' + dictation + '\n\n';
|
||||
}
|
||||
if (screenings) {
|
||||
context += 'SCREENINGS COMPLETED:\n' + screenings + '\n\n';
|
||||
}
|
||||
if (vaccines) {
|
||||
context += 'IMMUNIZATIONS TODAY:\n' + vaccines + '\n\n';
|
||||
}
|
||||
if (shadessAssessment) {
|
||||
context += 'SSHADESS PSYCHOSOCIAL ASSESSMENT:\n' + shadessAssessment + '\n\n';
|
||||
}
|
||||
if (ros) {
|
||||
context += ros + '\n\n';
|
||||
}
|
||||
if (physicalExam) {
|
||||
context += physicalExam + '\n\n';
|
||||
}
|
||||
if (diagnoses) {
|
||||
context += diagnoses + '\n\n';
|
||||
}
|
||||
if (physicianMemories) {
|
||||
context += physicianMemories + '\n\n';
|
||||
}
|
||||
|
||||
var prompt = (noteStyle === 'short') ? PROMPTS.wellVisitShort : PROMPTS.wellVisitNote;
|
||||
|
||||
var result = await callAI([
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content: context }
|
||||
], { model });
|
||||
|
||||
var dur = Date.now() - start;
|
||||
logger.apiCall(req.user.id, '/api/well-visit/note', {
|
||||
model: result.model, tokensInput: result.usage?.input_tokens,
|
||||
tokensOutput: result.usage?.output_tokens, duration: dur, statusCode: 200
|
||||
});
|
||||
|
||||
res.json({ success: true, note: result.content, model: result.model });
|
||||
} catch (e) {
|
||||
logger.error('[WellVisit] Note generation failed', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
52
src/utils/config.js
Normal file
52
src/utils/config.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// ============================================================
|
||||
// CONFIG.JS — DB-backed app config with in-memory cache
|
||||
// Falls back to provided defaults if DB is unavailable
|
||||
// ============================================================
|
||||
|
||||
const db = require('../db/database');
|
||||
|
||||
const _cache = {};
|
||||
const CACHE_TTL = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
async function get(key, defaultVal) {
|
||||
const now = Date.now();
|
||||
if (_cache[key] && (now - _cache[key].ts) < CACHE_TTL) {
|
||||
return _cache[key].value;
|
||||
}
|
||||
try {
|
||||
const val = await db.getSetting(key);
|
||||
const result = (val !== null && val !== undefined) ? val : (defaultVal !== undefined ? defaultVal : null);
|
||||
_cache[key] = { value: result, ts: now };
|
||||
return result;
|
||||
} catch (e) {
|
||||
return defaultVal !== undefined ? defaultVal : null;
|
||||
}
|
||||
}
|
||||
|
||||
async function set(key, value) {
|
||||
await db.setSetting(key, String(value));
|
||||
_cache[key] = { value: String(value), ts: Date.now() };
|
||||
}
|
||||
|
||||
function invalidate(key) {
|
||||
if (key) {
|
||||
delete _cache[key];
|
||||
} else {
|
||||
Object.keys(_cache).forEach(k => delete _cache[k]);
|
||||
}
|
||||
}
|
||||
|
||||
async function getByPrefix(prefix) {
|
||||
try {
|
||||
const rows = await db.all(
|
||||
"SELECT key, value, updated_at FROM app_settings WHERE key LIKE $1 ORDER BY key",
|
||||
[prefix + '%']
|
||||
);
|
||||
rows.forEach(r => { _cache[r.key] = { value: r.value, ts: Date.now() }; });
|
||||
return rows;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { get, set, invalidate, getByPrefix };
|
||||
|
|
@ -98,6 +98,27 @@ console.log('🤖 Provider:', activeProvider);
|
|||
console.log('🤖 Default model:', DEFAULT_MODEL);
|
||||
console.log('🤖 Models available:', AVAILABLE_MODELS.length);
|
||||
|
||||
// DB-aware model list (used by /api/models endpoint)
|
||||
async function getAvailableModelsWithOverrides(db) {
|
||||
var baseModels = getAvailableModels();
|
||||
try {
|
||||
var disabledRaw = await db.getSetting('models.disabled') || '[]';
|
||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||
var disabled, custom;
|
||||
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||
|
||||
var result = baseModels.filter(function(m) { return !disabled.includes(m.id); });
|
||||
// Add custom models
|
||||
custom.forEach(function(m) {
|
||||
if (!result.find(function(r) { return r.id === m.id; })) result.push(m);
|
||||
});
|
||||
return result;
|
||||
} catch(e) {
|
||||
return baseModels;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AVAILABLE_MODELS,
|
||||
DEFAULT_MODEL,
|
||||
|
|
@ -106,6 +127,7 @@ module.exports = {
|
|||
BEDROCK_MODELS,
|
||||
AZURE_MODELS,
|
||||
getAvailableModels,
|
||||
getAvailableModelsWithOverrides,
|
||||
getDefaultModel,
|
||||
getFallbackModel,
|
||||
getBedrockModelId,
|
||||
|
|
|
|||
|
|
@ -202,7 +202,95 @@ ${CORE_RULES}
|
|||
Identify what information is MISSING or UNCLEAR.
|
||||
List specific questions the physician should answer to complete the document.
|
||||
Be specific: "What was the discharge weight?" not "Is there more info?"
|
||||
List as numbered questions.`
|
||||
List as numbered questions.`,
|
||||
|
||||
// ======================== SSHADESS ========================
|
||||
shadessAssessment: `You are an expert pediatric adolescent medicine specialist.
|
||||
Generate a professional SSHADESS psychosocial assessment summary from the structured screening data provided.
|
||||
${CORE_RULES}
|
||||
Format by domain — only include domains where data was collected:
|
||||
Strengths: / School: / Home: / Activities: / Drugs/Substances: / Emotions/Eating: / Sexuality: / Safety:
|
||||
For each domain: summarize findings, note any concerns identified.
|
||||
Overall Impression: 2-3 sentence summary of psychosocial risk level (low/moderate/high) and key areas for follow-up.
|
||||
Keep clinical and concise — this will be pasted into the encounter note.`,
|
||||
|
||||
// ======================== WELL VISIT NOTE ========================
|
||||
wellVisitNote: `You are an expert pediatric physician generating a complete well child visit encounter note.
|
||||
${CORE_RULES}
|
||||
Sections required:
|
||||
Chief Complaint:
|
||||
Interval History: (development, growth, behavior, school/activities, diet, sleep, safety since last visit — from transcript if provided)
|
||||
Review of Systems: (pertinent only; use physician ROS template if provided under PHYSICIAN TEMPLATES)
|
||||
Physical Examination: (use physician exam template if provided; include vital signs and measurements given)
|
||||
Screening Results: (list all screenings completed, results, validated tools used)
|
||||
Immunizations: (vaccines given today; note refusals or deferrals)
|
||||
Assessment:
|
||||
1. Well child visit — [age] [gender]
|
||||
2. [Any additional diagnoses]
|
||||
Plan: (anticipatory guidance, follow-up, referrals, next well visit)
|
||||
Rules:
|
||||
- Do NOT fabricate screening results — only include what was explicitly provided
|
||||
- Use physician templates when supplied
|
||||
- Do not invent exam findings; state "deferred" if not provided`,
|
||||
|
||||
wellVisitShort: `You are an expert pediatric physician.
|
||||
Generate a brief well child visit note (SOAP style). Keep concise.
|
||||
${CORE_RULES}
|
||||
S: Well child visit, brief interval history, parent concerns
|
||||
O: Vitals/measurements provided, exam findings (use template if provided)
|
||||
A: Well child, age-appropriate development, any additional diagnoses
|
||||
P: Vaccines, screenings, anticipatory guidance, follow-up`,
|
||||
|
||||
// ======================== SICK VISIT NOTE ========================
|
||||
sickVisitNote: `You are an expert pediatrician. Generate a concise, professional sick visit note from the provided data.
|
||||
${CORE_RULES}
|
||||
Include:
|
||||
Chief Complaint:
|
||||
History of Present Illness: (from transcript/dictation — onset, duration, associated symptoms, pertinent negatives)
|
||||
Review of Systems: (from ROS data — document both positive and negative findings for each system reviewed)
|
||||
Physical Examination: (from PE data — document normal and abnormal findings; state "not examined" where applicable)
|
||||
Assessment and Plan:
|
||||
- List diagnoses with ICD-10 codes
|
||||
- Treatments and medications prescribed (if mentioned)
|
||||
- Follow-up instructions
|
||||
- Return precautions
|
||||
|
||||
Format as a structured clinical note. Be factual. Only include information explicitly provided. Do not fabricate clinical data.`
|
||||
};
|
||||
|
||||
// ── DB override support ────────────────────────────────────────────────────
|
||||
// Routes still use PROMPTS.key synchronously — this patches them from DB on startup.
|
||||
// Admin edits call updatePrompt() to update in-memory immediately.
|
||||
|
||||
async function loadFromDb(db) {
|
||||
try {
|
||||
var keys = Object.keys(PROMPTS);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
var val = await db.getSetting('prompt.' + key);
|
||||
if (val && val.trim()) PROMPTS[key] = val;
|
||||
}
|
||||
console.log('✅ Prompts: DB overrides loaded');
|
||||
} catch (e) {
|
||||
console.warn('[Prompts] DB load failed, using hardcoded defaults:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePrompt(key, value) {
|
||||
if (Object.prototype.hasOwnProperty.call(PROMPTS, key) && value && value.trim()) {
|
||||
PROMPTS[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return Object.keys(PROMPTS)
|
||||
.filter(function(k) { return typeof PROMPTS[k] === 'string'; })
|
||||
.map(function(k) { return { key: k, value: PROMPTS[k] }; });
|
||||
}
|
||||
|
||||
PROMPTS.loadFromDb = loadFromDb;
|
||||
PROMPTS.updatePrompt = updatePrompt;
|
||||
PROMPTS.getAllPrompts = getAll;
|
||||
|
||||
module.exports = PROMPTS;
|
||||
// Note: actual additions below are appended after module.exports
|
||||
|
|
|
|||
Loading…
Reference in a new issue