pediatric-ai-scribe-v3/public/js/app.js
Daniel Onyejesi 90c7bc35af Feat: milestones under well visit, standalone schedule tabs, remove admin models
- Move Milestones from standalone tab to well visit subtab (wv-panel-milestones)
  with 'Copy to Note' button that fills Developmental Assessment card in Visit Note
- Sidebar Milestones button now navigates to Well Visit → Milestones subtab
- Move Full Vaccine Schedule → standalone sidebar tab (vaxschedule-tab)
- Move Catch-Up Schedule → standalone sidebar tab (catchup-tab)
- Add Developmental Assessment textarea (wv-milestones-text) to Visit Note panel
- Remove AI Models management from admin panel (was failing to load)
- switchSubtab scoped to wellvisit-tab so standalone schedule panels unaffected
- Expose window.wvSwitchSubtab for cross-module subtab switching
2026-03-22 18:52:11 -04:00

472 lines
19 KiB
JavaScript

// ============================================================
// APP.JS — Core utilities, tabs, model selector, helpers
// ============================================================
// ── Client-side error logging ──────────────────────────────
(function() {
function sendError(data) {
try {
var token = localStorage.getItem('ped_scribe_token') || '';
navigator.sendBeacon('/api/logs/client-error', new Blob(
[JSON.stringify(data)], { type: 'application/json' }
));
} catch(e) {}
}
window.onerror = function(msg, src, line, col, err) {
// Ignore errors from browser extensions
if (src && src.indexOf('moz-extension') !== -1) return;
if (src && src.indexOf('chrome-extension') !== -1) return;
sendError({ type: 'uncaught', message: String(msg), source: src, line: line, col: col, stack: err && err.stack });
};
window.addEventListener('unhandledrejection', function(e) {
var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection';
sendError({ type: 'unhandledrejection', message: msg, stack: e.reason && e.reason.stack });
});
})();
document.addEventListener('DOMContentLoaded', function() {
// --- TAB NAVIGATION ---
function activateTab(tabName) {
var btn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
if (!btn || btn.classList.contains('hidden')) return false;
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
btn.classList.add('active');
var tabEl = document.getElementById(tabName + '-tab');
if (tabEl) tabEl.classList.add('active');
// Persist last tab in localStorage (restored after login in auth.js)
try { localStorage.setItem('ped_last_tab', tabName); } catch(e) {}
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
// Close sidebar on mobile after tab click
var sidebar = document.getElementById('sidebar');
if (sidebar && window.innerWidth <= 768) {
sidebar.classList.remove('open');
}
return true;
}
// Expose activateTab globally so auth.js can call it after login
window.activateTab = activateTab;
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
activateTab(btn.getAttribute('data-tab'));
var subtab = btn.getAttribute('data-subtab');
if (subtab && typeof window.wvSwitchSubtab === 'function') {
setTimeout(function() { window.wvSwitchSubtab(subtab); }, 0);
}
});
});
// --- SIDEBAR TOGGLE ---
var sidebar = document.getElementById('sidebar');
var sidebarOverlay = document.getElementById('sidebar-overlay');
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-menu-toggle')) {
if (sidebar) sidebar.classList.toggle('open');
}
if (e.target.closest('#btn-sidebar-close') || e.target.closest('#sidebar-overlay')) {
if (sidebar) sidebar.classList.remove('open');
}
});
if (sidebarOverlay) {
sidebarOverlay.addEventListener('click', function() {
if (sidebar) sidebar.classList.remove('open');
});
}
// --- DESKTOP SIDEBAR COLLAPSE ---
var sidebarPinBtn = document.getElementById('btn-sidebar-pin');
var sidebarExpandBtn = document.getElementById('btn-sidebar-expand');
// Restore collapse state
try {
if (localStorage.getItem('ped_sidebar_collapsed') === '1') {
if (sidebar) sidebar.classList.add('collapsed');
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'flex';
if (sidebarPinBtn) sidebarPinBtn.style.display = 'none';
}
} catch(e) {}
if (sidebarPinBtn) {
sidebarPinBtn.addEventListener('click', function() {
if (sidebar) sidebar.classList.add('collapsed');
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'flex';
if (sidebarPinBtn) sidebarPinBtn.style.display = 'none';
try { localStorage.setItem('ped_sidebar_collapsed', '1'); } catch(e) {}
});
}
if (sidebarExpandBtn) {
sidebarExpandBtn.addEventListener('click', function() {
if (sidebar) sidebar.classList.remove('collapsed');
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'none';
if (sidebarPinBtn) sidebarPinBtn.style.display = 'flex';
try { localStorage.setItem('ped_sidebar_collapsed', '0'); } catch(e) {}
});
}
// 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');
window._currentModels = [];
window._currentProvider = 'openrouter';
fetch('/api/models')
.then(function(r) { return r.json(); })
.then(function(data) {
window._currentModels = data.models || [];
window._currentProvider = data.provider || 'openrouter';
var categories = { free: '🆓 Free', fast: '⚡ Fast & Cheap', smart: '🧠 Smart', premium: '👑 Premium' };
function buildModelOptions(selectEl) {
selectEl.innerHTML = '';
var grouped = {};
window._currentModels.forEach(function(m) {
var cat = m.category || 'smart';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(m);
});
Object.keys(categories).forEach(function(cat) {
if (!grouped[cat] || grouped[cat].length === 0) return;
var optgroup = document.createElement('optgroup');
optgroup.label = categories[cat];
grouped[cat].forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name + ' (' + m.cost + ')';
optgroup.appendChild(opt);
});
selectEl.appendChild(optgroup);
});
}
if (modelSelect && window._currentModels.length > 0) {
buildModelOptions(modelSelect);
if (costBadge) costBadge.textContent = window._currentProvider.toUpperCase() + ' | ' + window._currentModels[0].cost;
}
// Populate all per-tab model selectors
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
buildModelOptions(sel);
});
})
.catch(function(err) { console.warn('Models load failed:', err); });
if (modelSelect) {
modelSelect.addEventListener('change', function() {
var m = window._currentModels.find(function(x) { return x.id === modelSelect.value; });
if (m && costBadge) costBadge.textContent = window._currentProvider.toUpperCase() + ' | ' + m.cost;
showToast('Model: ' + modelSelect.value.split('/').pop(), 'info');
});
}
console.log('✅ App.js DOM ready');
}); // end DOMContentLoaded
// ============================================================
// 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');
if (activeTab) {
var tabSel = activeTab.querySelector('.tab-model-select');
if (tabSel && tabSel.value) return tabSel.value;
}
var sel = document.getElementById('global-model-select');
return sel ? sel.value : 'google/gemini-2.5-flash';
}
function showLoading(text) {
var overlay = document.getElementById('loading-overlay');
var textEl = document.getElementById('loading-text');
if (textEl) textEl.textContent = text || 'Processing...';
if (overlay) { overlay.style.display = 'flex'; overlay.className = overlay.className.replace('hidden', '').trim(); }
}
function hideLoading() {
var overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'none';
}
function showToast(message, type) {
var container = document.getElementById('toast-container');
if (!container) { console.log('Toast:', type, message); return; }
var toast = document.createElement('div');
toast.className = 'toast toast-' + (type || 'success');
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
toast.innerHTML = '<i class="fas fa-' + icon + '"></i> ' + message;
container.appendChild(toast);
setTimeout(function() { toast.remove(); }, 3500);
}
function copyText(elementId) {
var el = document.getElementById(elementId);
if (!el) return;
var text = el.innerText || el.textContent || '';
if (!text.trim()) { showToast('Nothing to copy', 'error'); return; }
// Try modern clipboard API (requires HTTPS or localhost)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
showToast('Copied!', 'success');
}).catch(function(err) {
// Fallback: select + execCommand
try {
var range = document.createRange();
range.selectNodeContents(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
var ok = document.execCommand('copy');
window.getSelection().removeAllRanges();
showToast(ok ? 'Copied!' : 'Copy failed — please select text manually', ok ? 'success' : 'error');
} catch (e) {
showToast('Copy not supported in this browser', 'error');
}
});
} else {
// No clipboard API — use execCommand directly
try {
var range = document.createRange();
range.selectNodeContents(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
var ok = document.execCommand('copy');
window.getSelection().removeAllRanges();
showToast(ok ? 'Copied!' : 'Copy failed — please select text manually', ok ? 'success' : 'error');
} catch (e) {
showToast('Copy not supported in this browser', 'error');
}
}
}
// Speak / Stop
var currentlyReadingId = null;
var currentAudio = null;
function speakText(elementId) {
if (currentlyReadingId === elementId) { stopReading(); return; }
stopReading();
var el = document.getElementById(elementId);
if (!el) return;
var text = (el.innerText || el.textContent).trim();
if (!text) { showToast('Nothing to read', 'error'); return; }
currentlyReadingId = elementId;
var btn = findReadButton(elementId);
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...'; }
fetch('/api/text-to-speech', {
method: 'POST',
headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text })
})
.then(function(r) {
if (!r.ok) throw new Error('TTS request failed (' + r.status + ')');
return r.blob();
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
currentAudio = new Audio(url);
currentAudio.onended = function() { URL.revokeObjectURL(url); stopReading(); };
currentAudio.onerror = function() { URL.revokeObjectURL(url); stopReading(); showToast('Audio playback error', 'error'); };
currentAudio.play();
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
showToast('Reading aloud (Adam/ElevenLabs)', 'info');
})
.catch(function(err) {
stopReading();
// Fallback to browser TTS if ElevenLabs fails
if ('speechSynthesis' in window) {
var utter = new SpeechSynthesisUtterance(text);
utter.rate = 0.9;
utter.onend = function() { stopReading(); };
currentlyReadingId = elementId;
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
window.speechSynthesis.speak(utter);
showToast('ElevenLabs unavailable — using browser voice', 'info');
} else {
showToast('Read aloud failed: ' + err.message, 'error');
}
});
}
function stopReading() {
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
if (currentlyReadingId) {
var btn = findReadButton(currentlyReadingId);
if (btn) { btn.classList.remove('btn-reading'); btn.innerHTML = '<i class="fas fa-volume-high"></i> Read'; }
currentlyReadingId = null;
}
}
function findReadButton(elementId) {
var el = document.getElementById(elementId);
if (!el) return null;
var card = el.closest('.output-card') || el.closest('.card');
if (!card) return null;
var buttons = card.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
var oc = buttons[i].getAttribute('onclick') || '';
if (oc.indexOf('speakText') !== -1 && oc.indexOf(elementId) !== -1) return buttons[i];
}
return null;
}
// Timer
function createTimer(el) {
var s = 0, iv = null;
return {
start: function() { s = 0; this.update(); var self = this; iv = setInterval(function() { s++; self.update(); }, 1000); },
stop: function() { if (iv) { clearInterval(iv); iv = null; } return s; },
update: function() { el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); }
};
}
// Audio Recorder
function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; }
AudioRecorder.prototype.start = function() {
var self = this; self.chunks = [];
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } })
.then(function(stream) {
self.stream = stream;
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
self.mediaRecorder.start(1000);
});
};
AudioRecorder.prototype.stop = function() {
var self = this;
return new Promise(function(resolve) {
if (!self.mediaRecorder || self.mediaRecorder.state === 'inactive') { resolve(null); return; }
self.mediaRecorder.onstop = function() {
var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType });
if (self.stream) self.stream.getTracks().forEach(function(t) { t.stop(); });
resolve(blob);
};
self.mediaRecorder.stop();
});
};
function transcribeAudio(blob) {
var formData = new FormData();
formData.append('audio', blob, 'audio.webm');
return fetch('/api/transcribe', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
body: formData
}).then(function(r) { return r.json(); });
}
function refineDocument(outputElementId, inputElementId) {
var doc = document.getElementById(outputElementId);
var input = document.getElementById(inputElementId);
if (!doc || !input) return;
var docText = doc.innerText.trim();
var instructions = input.value.trim();
if (!docText) { showToast('No document', 'error'); return; }
if (!instructions) { showToast('Enter instructions', 'error'); return; }
showLoading('Refining...');
fetch('/api/refine', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ currentDocument: docText, instructions: instructions, model: getSelectedModel() })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) { doc.textContent = data.refined; input.value = ''; showToast('Refined!', 'success'); }
else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
}
function shortenDocument(outputElementId) {
var doc = document.getElementById(outputElementId);
if (!doc) return;
var text = doc.innerText.trim();
if (!text) { showToast('No document', 'error'); return; }
showLoading('Shortening...');
fetch('/api/shorten', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ document: text, model: getSelectedModel() })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) { doc.textContent = data.shortened; showToast('Shortened!', 'success'); }
else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
}
function exportToNextcloud(elementId, docType) {
var el = document.getElementById(elementId);
if (!el) return;
var text = el.innerText.trim();
if (!text) { showToast('Nothing to export', 'error'); return; }
fetch('/api/nextcloud/export', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ content: text, filename: docType + '-' + Date.now(), type: docType })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) showToast(data.message, 'success');
else showToast(data.error || 'Export failed', 'error');
})
.catch(function() { showToast('Nextcloud not connected', 'error'); });
}
function createSpeechRecognition() {
if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null;
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
var rec = new SR();
rec.continuous = true;
rec.interimResults = true;
rec.lang = 'en-US';
return rec;
}
// PWA Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function() {});
}
console.log('✅ App.js loaded');