pediatric-ai-scribe-v3/public/js/encounters.js
Daniel Onyejesi 8317708bb6 Fix: load popover positioning, delete button, ICD-10 live search
- Wrap save-bar + popover in .save-bar-wrap (position:relative) so the
  absolute-positioned popover anchors correctly below the Load button
  (previously popovers were outside the save-bar, so had no positioned
  ancestor and appeared at the wrong place on screen)
- Remove confirm() dialog from deleteEncounter — encounter disappears
  immediately from the popover list after deletion
- ICD-10 diagnoses: replace static-only lookup with live NLM Clinical
  Tables API search (clinicaltables.nlm.nih.gov) with 280ms debounce;
  results appear in a dropdown below the search input; Enter/click selects
- Add clinicaltables.nlm.nih.gov to CSP connect-src
- Drop unused position:relative from .save-bar (now on .save-bar-wrap)
2026-03-22 16:56:28 -04:00

341 lines
15 KiB
JavaScript

// ============================================================
// 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) {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
console.log('✅ Encounters module loaded');
})();