Dashboard
This commit is contained in:
parent
f20fa61d58
commit
5c37184063
802
.Net Capstone Project/dashboard27082026.js
Normal file
802
.Net Capstone Project/dashboard27082026.js
Normal file
@ -0,0 +1,802 @@
|
|||||||
|
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 & 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user