DotNet_Capstone_Project/.Net Capstone Project/dashboard27082026.js
2026-08-27 09:22:45 +00:00

803 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

let currentPatientId = null;
let patientsCache = [];
let patientSearchTerm = '';
function showSpinner(show) {
document.getElementById('spinnerOverlay').style.display = show ? 'flex' : 'none';
}
function showToast(message, variant = 'success') {
const container = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = `toast align-items-center text-bg-${variant} border-0 show mb-2`;
el.innerHTML = `<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" onclick="this.closest('.toast').remove()"></button>
</div>`;
container.appendChild(el);
setTimeout(() => el.remove(), 5000);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str ?? '';
return div.innerHTML;
}
function fmtDate(dateStr) {
if (!dateStr) return '—';
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
// ---------- Dynamic resource forms ----------
// Field config per FHIR resource type. Drives the generic #addResourceModal
// so patients, conditions, observations, allergies, medications and
// encounters can all be added from the UI and hit the CRUD API — nothing
// here is limited to the three seeded demo patients.
const RESOURCE_FORMS = {
condition: {
title: 'Add Condition',
fields: [
{ key: 'code', label: 'Code (ICD-10, e.g. I10)', type: 'text', required: true },
{ key: 'display', label: 'Display name', type: 'text', required: true },
{ key: 'clinicalStatus', label: 'Clinical status', type: 'select', options: ['active', 'inactive', 'resolved', 'remission'], default: 'active' },
{ key: 'onsetDate', label: 'Onset date', type: 'date', required: true }
],
add: (patientId, payload) => Api.addCondition(patientId, payload)
},
observation: {
title: 'Add Observation',
fields: [
{ key: 'code', label: 'Code (LOINC, e.g. 8480-6)', type: 'text', required: true },
{ key: 'display', label: 'Display name', type: 'text', required: true },
{ key: 'value', label: 'Value', type: 'number', required: true },
{ key: 'unit', label: 'Unit', type: 'text', required: true },
{ key: 'effectiveDate', label: 'Effective date/time', type: 'datetime-local', required: true }
],
add: (patientId, payload) => Api.addObservation(patientId, payload)
},
allergy: {
title: 'Add Allergy / Intolerance',
fields: [
{ key: 'substance', label: 'Substance', type: 'text', required: true },
{ key: 'reaction', label: 'Reaction', type: 'text' },
{ key: 'criticality', label: 'Criticality', type: 'select', options: ['low', 'high', 'unable-to-assess'], default: 'low' }
],
add: (patientId, payload) => Api.addAllergy(patientId, payload)
},
medication: {
title: 'Add Medication',
fields: [
{ key: 'medicationName', label: 'Medication name', type: 'text', required: true },
{ key: 'dosage', label: 'Dosage', type: 'text' },
{ key: 'status', label: 'Status', type: 'select', options: ['active', 'completed', 'cancelled', 'on-hold', 'stopped'], default: 'active' },
{ key: 'authoredOn', label: 'Authored on', type: 'date' }
],
add: (patientId, payload) => Api.addMedication(patientId, payload)
},
encounter: {
title: 'Add Encounter',
fields: [
{ key: 'encounterType', label: 'Encounter type', type: 'text', required: true, placeholder: 'e.g. Outpatient' },
{ key: 'reason', label: 'Reason', type: 'text' },
{ key: 'encounterDate', label: 'Date/time', type: 'datetime-local', required: true },
{ key: 'status', label: 'Status', type: 'select', options: ['planned', 'in-progress', 'finished', 'cancelled'], default: 'in-progress' }
],
add: (patientId, payload) => Api.addEncounter(patientId, payload)
}
};
let activeResourceType = null;
let newPatientModal, addResourceModal;
function openResourceModal(type) {
if (!currentPatientId) {
showToast('Select a patient first.', 'danger');
return;
}
activeResourceType = type;
const config = RESOURCE_FORMS[type];
document.getElementById('addResourceModalTitle').textContent = config.title;
const body = document.getElementById('addResourceModalBody');
body.innerHTML = config.fields.map(f => {
const id = `res_${f.key}`;
if (f.type === 'select') {
return `<div class="col-12">
<label class="form-label small">${escapeHtml(f.label)}</label>
<select class="form-select" id="${id}">
${f.options.map(o => `<option value="${o}" ${o === f.default ? 'selected' : ''}>${o}</option>`).join('')}
</select>
</div>`;
}
return `<div class="col-12">
<label class="form-label small">${escapeHtml(f.label)}</label>
<input type="${f.type}" ${f.required ? 'required' : ''} class="form-control" id="${id}" placeholder="${escapeHtml(f.placeholder || '')}">
</div>`;
}).join('');
addResourceModal.show();
}
// ---------- Dashboard stats + resource browser ----------
// Aggregates every patient's chart + care plans into flat arrays (each item
// tagged with its owning patient) so the top stat cards can show real
// counts, and clicking one can browse that resource across ALL patients —
// not just the one currently selected.
let resourceCache = { conditions: [], observations: [], allergies: [], medications: [], encounters: [], careplans: [] };
let resourceBrowserModal;
async function loadDashboardStats() {
resourceCache = { conditions: [], observations: [], allergies: [], medications: [], encounters: [], careplans: [] };
try {
const [chartResults, carePlanResults] = await Promise.all([
Promise.all(patientsCache.map(p => Api.getChart(p.id).catch(() => null))),
Promise.all(patientsCache.map(p => Api.getCarePlans(p.id).catch(() => [])))
]);
patientsCache.forEach((p, idx) => {
const chart = chartResults[idx];
if (chart) {
chart.conditions.forEach(c => resourceCache.conditions.push({ ...c, patientId: p.id, patientName: p.fullName }));
chart.observations.forEach(o => resourceCache.observations.push({ ...o, patientId: p.id, patientName: p.fullName }));
chart.allergies.forEach(a => resourceCache.allergies.push({ ...a, patientId: p.id, patientName: p.fullName }));
chart.medications.forEach(m => resourceCache.medications.push({ ...m, patientId: p.id, patientName: p.fullName }));
chart.encounters.forEach(e => resourceCache.encounters.push({ ...e, patientId: p.id, patientName: p.fullName }));
}
(carePlanResults[idx] || []).forEach(cp => resourceCache.careplans.push({ ...cp, patientId: p.id, patientName: p.fullName }));
});
} catch {
// Stats are a nice-to-have on top of the core dashboard — don't block
// the rest of the page if aggregation fails for some reason.
}
updateStatCardsForContext();
}
// Returns the patients matching the current search box text (or everyone,
// if the box is empty). Shared by the sidebar list and the stat cards so
// they always agree on "what's currently in view".
function getFilteredPatients() {
const term = patientSearchTerm.trim().toLowerCase();
if (!term) return patientsCache;
return patientsCache.filter(p =>
(p.fullName || '').toLowerCase().includes(term) ||
(p.id || '').toLowerCase().includes(term) ||
(p.medicalRecordNumber || '').toLowerCase().includes(term));
}
// Drives the top stat cards. Three states:
// 1. A patient is selected (currentPatientId set) -> counts for just them.
// 2. Nothing selected but the search box has text -> counts for the
// patients currently matching the search.
// 3. Neither -> counts across everyone.
function updateStatCardsForContext() {
const setCount = (id, value) => { const el = document.getElementById(id); if (el) el.textContent = value; };
const scopeLabel = document.getElementById('statsScopeLabel');
let activePatients;
let scopeText = null;
if (currentPatientId) {
const selected = patientsCache.find(p => p.id === currentPatientId);
activePatients = selected ? [selected] : [];
if (selected) scopeText = `Showing data for <strong>${escapeHtml(selected.fullName)}</strong>`;
} else {
activePatients = getFilteredPatients();
if (patientSearchTerm.trim()) {
scopeText = `Showing ${activePatients.length} of ${patientsCache.length} patients matching "${escapeHtml(patientSearchTerm.trim())}"`;
}
}
const activeIds = new Set(activePatients.map(p => p.id));
setCount('stat_patients', activePatients.length);
setCount('stat_conditions', resourceCache.conditions.filter(c => activeIds.has(c.patientId)).length);
setCount('stat_medications', resourceCache.medications.filter(m => activeIds.has(m.patientId)).length);
setCount('stat_allergies', resourceCache.allergies.filter(a => activeIds.has(a.patientId)).length);
setCount('stat_observations', resourceCache.observations.filter(o => activeIds.has(o.patientId)).length);
setCount('stat_encounters', resourceCache.encounters.filter(e => activeIds.has(e.patientId)).length);
setCount('stat_careplans', resourceCache.careplans.filter(cp => activeIds.has(cp.patientId)).length);
if (scopeLabel) {
if (scopeText) {
scopeLabel.innerHTML = `${scopeText} · <a href="#" id="clearStatsScopeLink">Show all patients</a>`;
scopeLabel.classList.remove('d-none');
} else {
scopeLabel.innerHTML = '';
scopeLabel.classList.add('d-none');
}
}
}
function patientChipHtml(patientId, name) {
return `<span class="patient-chip" data-jump-patient="${patientId}">👤 ${escapeHtml(name)}</span>`;
}
const RESOURCE_BROWSER_CONFIG = {
patients: {
title: 'All Patients',
columns: ['Name', 'Age', 'Gender', 'MRN'],
rows: () => patientsCache.map(p => [
patientChipHtml(p.id, p.fullName), p.age, escapeHtml(p.gender), escapeHtml(p.medicalRecordNumber)
])
},
conditions: {
title: 'All Conditions',
columns: ['Patient', 'Display', 'Code', 'Status', 'Onset'],
rows: () => resourceCache.conditions.map(c => [
patientChipHtml(c.patientId, c.patientName), escapeHtml(c.display), escapeHtml(c.code),
escapeHtml(c.clinicalStatus), fmtDate(c.onsetDate)
])
},
medications: {
title: 'All Medications',
columns: ['Patient', 'Medication', 'Dosage', 'Status', 'Authored'],
rows: () => resourceCache.medications.map(m => [
patientChipHtml(m.patientId, m.patientName), escapeHtml(m.medicationName), escapeHtml(m.dosage),
escapeHtml(m.status), fmtDate(m.authoredOn)
])
},
allergies: {
title: 'All Allergies',
columns: ['Patient', 'Substance', 'Reaction', 'Criticality'],
rows: () => resourceCache.allergies.map(a => [
patientChipHtml(a.patientId, a.patientName), escapeHtml(a.substance), escapeHtml(a.reaction), escapeHtml(a.criticality)
])
},
observations: {
title: 'All Observations',
columns: ['Patient', 'Display', 'Value', 'Date'],
rows: () => resourceCache.observations.map(o => [
patientChipHtml(o.patientId, o.patientName), escapeHtml(o.display), `${o.value} ${escapeHtml(o.unit)}`, fmtDate(o.effectiveDate)
])
},
encounters: {
title: 'All Encounters',
columns: ['Patient', 'Type', 'Reason', 'Date', 'Status'],
rows: () => resourceCache.encounters.map(e => [
patientChipHtml(e.patientId, e.patientName), escapeHtml(e.encounterType), escapeHtml(e.reason),
fmtDate(e.encounterDate), escapeHtml(e.status)
])
},
careplans: {
title: 'All Care Plans',
columns: ['Patient', 'Title', 'Triggered By', 'Status', 'Created'],
rows: () => resourceCache.careplans.map(cp => [
patientChipHtml(cp.patientId, cp.patientName), escapeHtml(cp.title), escapeHtml(cp.triggeredByRule),
escapeHtml(cp.status), fmtDate(cp.createdDate)
])
}
};
function openResourceBrowser(resourceType) {
const config = RESOURCE_BROWSER_CONFIG[resourceType];
if (!config) return;
document.getElementById('resourceBrowserTitle').textContent = config.title;
document.getElementById('resourceBrowserHead').innerHTML = config.columns.map(c => `<th>${c}</th>`).join('');
const rows = config.rows();
const body = document.getElementById('resourceBrowserBody');
const emptyState = document.getElementById('resourceBrowserEmpty');
if (rows.length === 0) {
body.innerHTML = '';
emptyState.classList.remove('d-none');
} else {
emptyState.classList.add('d-none');
body.innerHTML = rows.map(cells => `<tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr>`).join('');
}
resourceBrowserModal.show();
}
async function submitResourceForm(e) {
e.preventDefault();
const config = RESOURCE_FORMS[activeResourceType];
if (!config) return;
const payload = {};
for (const f of config.fields) {
const el = document.getElementById(`res_${f.key}`);
let val = el.value;
if (f.type === 'number') val = val === '' ? null : parseFloat(val);
payload[f.key] = val === '' ? null : val;
}
try {
showSpinner(true);
await config.add(currentPatientId, payload);
addResourceModal.hide();
showToast(`${config.title.replace('Add ', '')} added.`);
await loadChart(currentPatientId);
loadDashboardStats();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
async function deleteResource(type, id) {
if (!currentPatientId || !confirm('Delete this item?')) return;
const deleteFns = {
condition: Api.deleteCondition,
observation: Api.deleteObservation,
allergy: Api.deleteAllergy,
medication: Api.deleteMedication,
encounter: Api.deleteEncounter
};
try {
showSpinner(true);
await deleteFns[type](currentPatientId, id);
showToast('Deleted.');
await loadChart(currentPatientId);
loadDashboardStats();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
function setupDynamicDataControls() {
newPatientModal = new bootstrap.Modal(document.getElementById('newPatientModal'));
addResourceModal = new bootstrap.Modal(document.getElementById('addResourceModal'));
resourceBrowserModal = new bootstrap.Modal(document.getElementById('resourceBrowserModal'));
document.getElementById('newPatientBtn').addEventListener('click', () => newPatientModal.show());
// Live search: filters the cached patient list by name, patient ID, or
// MRN as the user types — no server round-trip needed.
document.getElementById('patientSearchInput')?.addEventListener('input', (e) => {
patientSearchTerm = e.target.value;
renderPatientList();
updateStatCardsForContext();
});
document.getElementById('newPatientForm').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
firstName: document.getElementById('np_firstName').value,
lastName: document.getElementById('np_lastName').value,
dateOfBirth: document.getElementById('np_dob').value,
gender: document.getElementById('np_gender').value,
medicalRecordNumber: document.getElementById('np_mrn').value || null
};
try {
showSpinner(true);
const created = await Api.createPatient(payload);
newPatientModal.hide();
document.getElementById('newPatientForm').reset();
showToast('Patient created.');
await loadPatientList();
loadDashboardStats();
selectPatient(created.patient.id);
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
});
document.getElementById('addResourceForm').addEventListener('submit', submitResourceForm);
// Stat cards -> open the "browse this resource across all patients" modal.
document.getElementById('statsRow').addEventListener('click', (e) => {
const card = e.target.closest('.stat-card');
if (card) openResourceBrowser(card.dataset.resource);
});
// "Show all patients" link inside the stats scope label — clears both
// the search box and the selected patient, reverting the stat cards to
// totals across everyone.
document.addEventListener('click', (e) => {
if (e.target.id !== 'clearStatsScopeLink') return;
e.preventDefault();
patientSearchTerm = '';
const input = document.getElementById('patientSearchInput');
if (input) input.value = '';
currentPatientId = null;
sessionStorage.removeItem('cip_patientId');
document.getElementById('patientContent')?.classList.add('d-none');
document.getElementById('noPatientState')?.classList.remove('d-none');
renderPatientList();
updateStatCardsForContext();
});
// Inside the resource browser: clicking a patient chip jumps straight to
// that patient's Chart tab.
document.getElementById('resourceBrowserBody').addEventListener('click', (e) => {
const chip = e.target.closest('[data-jump-patient]');
if (!chip) return;
resourceBrowserModal.hide();
selectPatient(chip.dataset.jumpPatient);
document.querySelectorAll('#dashboardTabs .nav-link').forEach(b => b.classList.remove('active'));
document.querySelector('#dashboardTabs .nav-link[data-tab="chart"]')?.classList.add('active');
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('d-none'));
document.getElementById('tab-chart')?.classList.remove('d-none');
});
// Event delegation: "+ Add" buttons in the chart tab, and per-item delete
// buttons rendered inside the chart lists (see loadChart()).
document.getElementById('tab-chart').addEventListener('click', (e) => {
const addBtn = e.target.closest('[data-add]');
if (addBtn) { openResourceModal(addBtn.dataset.add); return; }
const delBtn = e.target.closest('[data-delete-type]');
if (delBtn) { deleteResource(delBtn.dataset.deleteType, parseInt(delBtn.dataset.deleteId, 10)); }
});
}
// ---------- Init ----------
document.addEventListener('DOMContentLoaded', async () => {
const token = sessionStorage.getItem('cip_token');
if (!token) {
window.location.href = 'login.html';
return;
}
document.getElementById('clinicianLabel').textContent =
`👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
document.getElementById('logoutBtn').addEventListener('click', () => {
sessionStorage.clear();
window.location.href = 'index.html';
});
setupTabs();
setupRiskForm();
setupDynamicDataControls();
document.getElementById('evaluateCdsBtn').addEventListener('click', evaluateCds);
document.getElementById('refreshCarePlansBtn').addEventListener('click', () => loadCarePlans(currentPatientId));
await loadPatientList();
loadDashboardStats();
const launchPatientId = sessionStorage.getItem('cip_patientId');
if (launchPatientId) {
selectPatient(launchPatientId);
}
});
function setupTabs() {
document.querySelectorAll('#dashboardTabs .nav-link').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#dashboardTabs .nav-link').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('d-none'));
document.getElementById(`tab-${btn.dataset.tab}`).classList.remove('d-none');
});
});
}
// ---------- Patients ----------
async function loadPatientList() {
try {
showSpinner(true);
patientsCache = await Api.getPatients();
renderPatientList();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
function renderPatientList() {
const container = document.getElementById('patientList');
const emptyState = document.getElementById('patientSearchEmpty');
const emptyTerm = document.getElementById('patientSearchEmptyTerm');
const term = patientSearchTerm.trim();
const filtered = getFilteredPatients();
if (filtered.length === 0 && term) {
container.innerHTML = '';
container.classList.add('d-none');
if (emptyTerm) emptyTerm.textContent = patientSearchTerm.trim();
emptyState?.classList.remove('d-none');
return;
}
container.classList.remove('d-none');
emptyState?.classList.add('d-none');
container.innerHTML = filtered.map(p => `
<div class="card-ci patient-tile p-2 mb-2 d-flex justify-content-between align-items-start ${p.id === currentPatientId ? 'active' : ''}" data-id="${p.id}">
<div>
<div class="fw-semibold">${escapeHtml(p.fullName)}</div>
<div class="small text-muted-ci">${p.age} yrs · ${escapeHtml(p.gender)} · ${escapeHtml(p.medicalRecordNumber)}</div>
<div class="small text-muted-ci">ID: ${escapeHtml(p.id)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-patient="${p.id}" title="Delete patient">×</button>
</div>
`).join('');
container.querySelectorAll('.patient-tile').forEach(tile => {
tile.addEventListener('click', (e) => {
if (e.target.closest('[data-delete-patient]')) return;
selectPatient(tile.dataset.id);
});
});
container.querySelectorAll('[data-delete-patient]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = btn.dataset.deletePatient;
if (!confirm('Delete this patient and all their chart data?')) return;
try {
showSpinner(true);
await Api.deletePatient(id);
showToast('Patient deleted.');
if (currentPatientId === id) {
currentPatientId = null;
sessionStorage.removeItem('cip_patientId');
document.getElementById('patientContent').classList.add('d-none');
document.getElementById('noPatientState').classList.remove('d-none');
}
await loadPatientList();
loadDashboardStats();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
});
});
}
async function selectPatient(id) {
currentPatientId = id;
sessionStorage.setItem('cip_patientId', id);
renderPatientList();
updateStatCardsForContext();
document.getElementById('noPatientState').classList.add('d-none');
document.getElementById('patientContent').classList.remove('d-none');
resetRiskTab();
resetCdsTab();
await loadChart(id);
await loadCarePlans(id);
}
// ---------- Chart ----------
async function loadChart(id) {
try {
showSpinner(true);
const chart = await Api.getChart(id);
document.getElementById('patientName').textContent = chart.fullName;
document.getElementById('patientMeta').textContent =
`${chart.age} yrs · ${chart.gender} · ${chart.medicalRecordNumber} · Patient ID: ${chart.patientId}`;
document.getElementById('conditionsList').innerHTML = chart.conditions.length
? chart.conditions.map(c => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(c.display)}</div>
<div class="small text-muted-ci">${escapeHtml(c.code)} · ${escapeHtml(c.clinicalStatus)} · onset ${fmtDate(c.onsetDate)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="condition" data-delete-id="${c.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No active conditions on file.</div>';
document.getElementById('allergiesList').innerHTML = chart.allergies.length
? chart.allergies.map(a => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold text-danger">${escapeHtml(a.substance)}</div>
<div class="small text-muted-ci">Reaction: ${escapeHtml(a.reaction)} · Criticality: ${escapeHtml(a.criticality)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="allergy" data-delete-id="${a.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No known allergies (NKA).</div>';
document.getElementById('medicationsList').innerHTML = chart.medications.length
? chart.medications.map(m => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(m.medicationName)}</div>
<div class="small text-muted-ci">${escapeHtml(m.dosage)} · ${escapeHtml(m.status)} · authored ${fmtDate(m.authoredOn)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="medication" data-delete-id="${m.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No active medications.</div>';
document.getElementById('observationsList').innerHTML = chart.observations.length
? chart.observations.map(o => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(o.display)}: ${o.value} ${escapeHtml(o.unit)}</div>
<div class="small text-muted-ci">${fmtDate(o.effectiveDate)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="observation" data-delete-id="${o.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No recent observations.</div>';
document.getElementById('encountersList').innerHTML = chart.encounters.length
? chart.encounters.map(e => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(e.encounterType)}${escapeHtml(e.reason)}</div>
<div class="small text-muted-ci">${fmtDate(e.encounterDate)} · ${escapeHtml(e.status)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="encounter" data-delete-id="${e.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No encounters on file.</div>';
document.getElementById('validationNotes').innerHTML = chart.validationNotes.length
? chart.validationNotes.map(n => `<div class="validation-note">✓ ${escapeHtml(n)}</div>`).join('')
: '<div class="text-muted-ci">No normalization actions were required.</div>';
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
// ---------- Risk ----------
function resetRiskTab() {
document.getElementById('riskForm').reset();
document.getElementById('q_pain_val').textContent = '0';
document.getElementById('riskResultEmpty').classList.remove('d-none');
document.getElementById('riskResultBody').classList.add('d-none');
}
function setupRiskForm() {
const painInput = document.getElementById('q_pain');
painInput.addEventListener('input', () => {
document.getElementById('q_pain_val').textContent = painInput.value;
});
document.getElementById('riskForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPatientId) return;
const questionnaire = {
smoker: document.getElementById('q_smoker').checked,
familyHistoryHeartDisease: document.getElementById('q_family').checked,
priorHospitalization: document.getElementById('q_hosp').checked,
bmi: document.getElementById('q_bmi').value ? parseFloat(document.getElementById('q_bmi').value) : null,
painScore: parseInt(document.getElementById('q_pain').value || '0', 10)
};
try {
showSpinner(true);
const result = await Api.assessRisk(currentPatientId, questionnaire);
renderRiskResult(result);
showToast('Risk assessment complete.');
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
});
}
function renderRiskResult(result) {
document.getElementById('riskResultEmpty').classList.add('d-none');
document.getElementById('riskResultBody').classList.remove('d-none');
const gauge = document.getElementById('scoreGauge');
gauge.textContent = result.score;
gauge.style.background = result.riskLevel === 'High'
? 'radial-gradient(circle, #e74c3c, #c0392b)'
: 'radial-gradient(circle, #27ae60, #1e8449)';
const badge = document.getElementById('riskLevelBadge');
badge.textContent = `${result.riskLevel} Risk`;
badge.className = `risk-badge ${result.riskLevel}`;
document.getElementById('riskFactorsList').innerHTML = result.contributingFactors
.map(f => `<li>${escapeHtml(f)}</li>`).join('');
}
// ---------- CDS ----------
function resetCdsTab() {
document.getElementById('cdsAlertsContainer').innerHTML =
'<div class="text-muted-ci text-center py-5">Click "Evaluate Encounter" to screen for drug-allergy conflicts and guideline gaps.</div>';
document.getElementById('alertCountBadge').classList.add('d-none');
}
async function evaluateCds() {
if (!currentPatientId) return;
try {
showSpinner(true);
const result = await Api.evaluateCds(currentPatientId);
renderCdsAlerts(result);
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
function renderCdsAlerts(result) {
const container = document.getElementById('cdsAlertsContainer');
const badge = document.getElementById('alertCountBadge');
if (!result.hasAlerts) {
container.innerHTML = '<div class="text-center py-5"><div class="fs-1">✅</div><div class="text-muted-ci">Nothing triggered — no CDS alerts for this encounter.</div></div>';
badge.classList.add('d-none');
return;
}
badge.textContent = result.alerts.length;
badge.classList.remove('d-none');
container.innerHTML = result.alerts.map(a => `
<div class="alert-card ${a.severity}" data-alert-id="${a.id}">
<div class="d-flex justify-content-between align-items-start">
<span class="severity-tag ${a.severity}">${escapeHtml(a.severity)}</span>
<span class="small text-muted-ci">${escapeHtml(a.ruleId)}</span>
</div>
<div class="fw-semibold mt-2">${escapeHtml(a.summary)}</div>
<div class="small text-muted-ci mt-1">${escapeHtml(a.recommendation)}</div>
<button class="btn btn-ci-primary btn-sm mt-3 create-careplan-btn" data-alert-id="${a.id}">
Create Care Plan &amp; Service Request
</button>
</div>
`).join('');
container.querySelectorAll('.create-careplan-btn').forEach(btn => {
btn.addEventListener('click', () => createCarePlanFromAlert(parseInt(btn.dataset.alertId, 10)));
});
}
async function createCarePlanFromAlert(alertId) {
try {
showSpinner(true);
await Api.createCarePlan({
patientId: currentPatientId,
alertId,
createServiceRequest: true,
priority: 'urgent'
});
showToast('Care Plan and Service Request created and written back to the FHIR store.');
document.querySelector(`.alert-card[data-alert-id="${alertId}"]`)?.remove();
const badge = document.getElementById('alertCountBadge');
const remaining = document.querySelectorAll('.alert-card').length;
if (remaining === 0) {
resetCdsTab();
document.getElementById('cdsAlertsContainer').innerHTML =
'<div class="text-center py-5"><div class="fs-1">✅</div><div class="text-muted-ci">All alerts for this encounter have been actioned.</div></div>';
} else {
badge.textContent = remaining;
}
await loadCarePlans(currentPatientId);
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
// ---------- Care Plans ----------
async function loadCarePlans(patientId) {
if (!patientId) return;
try {
const carePlans = await Api.getCarePlans(patientId);
const tbody = document.getElementById('carePlansTableBody');
tbody.innerHTML = carePlans.length
? carePlans.map(cp => `
<tr>
<td>${escapeHtml(cp.title)}</td>
<td><span class="small text-muted-ci">${escapeHtml(cp.triggeredByRule)}</span></td>
<td><span class="badge bg-success-subtle text-success">${escapeHtml(cp.status)}</span></td>
<td class="small text-muted-ci">${fmtDate(cp.createdDate)}</td>
</tr>`).join('')
: '<tr><td colspan="4" class="text-muted-ci text-center py-4">No care plans yet.</td></tr>';
} catch (err) {
showToast(err.message, 'danger');
}
}