1897 lines
79 KiB
JavaScript
1897 lines
79 KiB
JavaScript
let currentPatientId = null;
|
||
// The chart most recently loaded. Add-forms read the MRN from here so the
|
||
// generated identifiers can merge Patient ID + MRN without another fetch.
|
||
let currentChart = 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: 'conditionCode', label: 'Condition Code', type: 'text', required: true, placeholder: 'e.g. E11.9' },
|
||
{ key: 'conditionDisplay', label: 'Condition Display', type: 'text', required: true },
|
||
{ key: 'clinicalStatusCode', label: 'Clinical Status', type: 'select', options: ['active', 'inactive', 'resolved'], default: 'active' },
|
||
{ key: 'verificationStatusCode', label: 'Verification Status', type: 'select', options: ['confirmed', 'unconfirmed', 'provisional'], default: 'confirmed' },
|
||
{ key: 'severityCode', label: 'Severity', type: 'select', options: ['mild', 'moderate', 'severe'], default: 'moderate' },
|
||
{ key: 'onsetDateTime', label: 'Onset Date', type: 'date', required: true },
|
||
{ key: 'recordedDate', label: 'Recorded Date', type: 'date', required: true },
|
||
{ key: 'abatementDateTime', label: 'Abatement Date', type: 'date' }
|
||
],
|
||
// The API needs a *Display twin for every coded field, plus a recorder and
|
||
// asserter practitioner. Both are filled in by enrichPayload() so the form
|
||
// only has to ask for the code.
|
||
derive: async (p) => {
|
||
const pr = await Api.getPractitioners().catch(() => []);
|
||
return {
|
||
...p,
|
||
clinicalStatusDisplay: capitalise(p.clinicalStatusCode),
|
||
verificationStatusDisplay: capitalise(p.verificationStatusCode),
|
||
severityDisplay: capitalise(p.severityCode),
|
||
recorderId: pr[0]?.id ?? null,
|
||
asserterId: pr[0]?.id ?? null
|
||
};
|
||
},
|
||
add: (patientId, payload) => Api.addCondition(patientId, payload)
|
||
},
|
||
|
||
observation: {
|
||
title: 'Add Observation',
|
||
fields: [
|
||
{ key: 'observationCode', label: 'Observation Code', type: 'text', required: true, placeholder: 'e.g. 8480-6' },
|
||
{ key: 'observationDisplay', label: 'Observation Display', type: 'text', required: true },
|
||
{ key: 'status', label: 'Status', type: 'select', options: ['registered', 'preliminary', 'final', 'amended', 'corrected'], default: 'final' },
|
||
{ key: 'categoryCode', label: 'Category Code', type: 'text', placeholder: 'e.g. laboratory' },
|
||
{ key: 'categoryDisplay', label: 'Category Display', type: 'text', placeholder: 'e.g. Laboratory' },
|
||
{ key: 'effectiveDateTime', label: 'Effective Date Time', type: 'datetime-local', required: true },
|
||
{ key: 'issued', label: 'Issued', type: 'datetime-local' },
|
||
{ key: 'interpretationCode', label: 'Interpretation Code', type: 'select', options: ['', 'N', 'H', 'L'], default: '' },
|
||
{ key: 'interpretationDisplay', label: 'Interpretation Display', type: 'select', options: ['', 'Normal', 'High', 'Low'], default: '' },
|
||
{ key: 'noteText', label: 'Note / result text', type: 'textarea', placeholder: 'e.g. HbA1c 8.4% — above target' }
|
||
],
|
||
derive: async (p) => {
|
||
const pr = await Api.getPractitioners().catch(() => []);
|
||
return {
|
||
...p,
|
||
performerPractitionerId: pr[0]?.id ?? null,
|
||
noteAuthorPractitionerId: pr[0]?.id ?? null,
|
||
noteTime: new Date().toISOString()
|
||
};
|
||
},
|
||
add: (patientId, payload) => Api.addObservation(patientId, payload)
|
||
},
|
||
|
||
allergy: {
|
||
title: 'Add Allergy / Intolerance',
|
||
fields: [
|
||
{ key: 'type', label: 'Type', type: 'select', options: ['allergy', 'intolerance'], default: 'allergy' },
|
||
{ key: 'clinicalStatus', label: 'Clinical Status', type: 'select', options: ['active', 'inactive', 'resolved'], default: 'active' },
|
||
{ key: 'verificationStatus', label: 'Verification Status', type: 'select', options: ['confirmed', 'unconfirmed', 'refuted'], default: 'confirmed' },
|
||
{ key: 'criticality', label: 'Criticality', type: 'select', options: ['low', 'high', 'unable-to-assess'], default: 'low' }
|
||
],
|
||
derive: async (p, patientId) => {
|
||
const pr = await Api.getPractitioners().catch(() => []);
|
||
return {
|
||
...p,
|
||
recorderPractitionerId: pr[0]?.id ?? null,
|
||
asserterPatientId: patientId
|
||
};
|
||
},
|
||
add: (patientId, payload) => Api.addAllergy(patientId, payload)
|
||
},
|
||
|
||
medication: {
|
||
title: 'Add Medication',
|
||
fields: [
|
||
{ key: 'medicationCode', label: 'Medication Code', type: 'text', required: true, placeholder: 'e.g. 860975' },
|
||
{ key: 'medicationDisplay', label: 'Medication Name', type: 'text', required: true },
|
||
{ key: 'status', label: 'Status', type: 'select', options: ['active', 'on-hold', 'cancelled', 'completed', 'stopped', 'draft'], default: 'active' },
|
||
{ key: 'intent', label: 'Intent', type: 'select', options: ['proposal', 'plan', 'order', 'original-order'], default: 'order' },
|
||
{ key: 'priority', label: 'Priority', type: 'select', options: ['routine', 'urgent', 'asap', 'stat'], default: 'routine' }
|
||
],
|
||
add: (patientId, payload) => Api.addMedication(patientId, payload)
|
||
},
|
||
|
||
encounter: {
|
||
title: 'Add Encounter',
|
||
fields: [
|
||
{ key: 'status', label: 'Status', type: 'select', options: ['planned', 'arrived', 'in-progress', 'finished', 'cancelled'], default: 'planned' },
|
||
{ key: 'classCode', label: 'Encounter Class Code', type: 'select', options: ['AMB', 'IMP', 'EMER', 'HH'], default: 'AMB' },
|
||
{ key: 'classDisplay', label: 'Encounter Class', type: 'text', placeholder: 'e.g. Ambulatory' },
|
||
{ key: 'typeCode', label: 'Encounter Type Code', type: 'text', placeholder: 'e.g. 185349003' },
|
||
{ key: 'typeDisplay', label: 'Encounter Type', type: 'text', placeholder: 'e.g. Encounter for check-up' },
|
||
{ key: 'periodStart', label: 'Period Start', type: 'datetime-local', required: true },
|
||
{ key: 'periodEnd', label: 'Period End', type: 'datetime-local', required: true }
|
||
],
|
||
derive: async (p) => ({
|
||
...p,
|
||
classSystem: 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
|
||
classDisplay: p.classDisplay || (ENCOUNTER_CLASS_LABELS[p.classCode] || p.classCode),
|
||
typeSystem: p.typeCode ? 'http://snomed.info/sct' : null
|
||
}),
|
||
add: (patientId, payload) => Api.addEncounter(patientId, payload)
|
||
},
|
||
|
||
servicerequest: {
|
||
title: 'Add Service Request',
|
||
fields: [
|
||
{ key: 'status', label: 'Status', type: 'select', options: ['draft', 'active', 'on-hold', 'revoked', 'completed', 'unknown'], default: 'active' },
|
||
{ key: 'intent', label: 'Intent', type: 'select', options: ['proposal', 'plan', 'directive', 'order', 'original-order', 'reflex-order', 'filler-order', 'instance-order', 'option'], default: 'order' },
|
||
{ key: 'quantityComparator', label: 'Comparator', type: 'select', options: ['', '<', '<=', '>', '>=', 'ad'], default: '' },
|
||
{ key: 'quantityValue', label: 'Quantity Value', type: 'number' },
|
||
{ key: 'quantityUnit', label: 'Quantity Unit', type: 'text' },
|
||
{ key: 'occurrenceDateTime', label: 'Occurrence Date Time', type: 'datetime-local' },
|
||
{ key: 'patientInstruction', label: 'Patient Instruction', type: 'textarea' },
|
||
{ key: 'noteText', label: 'Note', type: 'textarea' }
|
||
],
|
||
derive: async (p, patientId) => {
|
||
const pr = await Api.getPractitioners().catch(() => []);
|
||
return {
|
||
...p,
|
||
identifierSystem: IDENTIFIER_SYSTEM,
|
||
identifierValue: await nextIdentifierValue('SR', patientId),
|
||
requesterPractitionerId: pr[0]?.id ?? null,
|
||
noteAuthorPractitionerId: pr[0]?.id ?? null,
|
||
noteTime: new Date().toISOString()
|
||
};
|
||
},
|
||
add: (patientId, payload) => Api.addServiceRequest(patientId, payload)
|
||
},
|
||
|
||
riskassessment: {
|
||
title: 'Add Risk Assessment',
|
||
fields: [
|
||
{ key: 'status', label: 'Status', type: 'select', options: ['registered', 'preliminary', 'final', 'amended', 'corrected'], default: 'final' },
|
||
{ key: 'occurrenceDateTime', label: 'Occurrence Date Time', type: 'datetime-local' },
|
||
{ key: 'noteText', label: 'Note', type: 'textarea' }
|
||
],
|
||
derive: async (p) => {
|
||
const pr = await Api.getPractitioners().catch(() => []);
|
||
return {
|
||
...p,
|
||
performerPractitionerId: pr[0]?.id ?? null,
|
||
noteAuthorPractitionerId: pr[0]?.id ?? null,
|
||
noteTime: new Date().toISOString()
|
||
};
|
||
},
|
||
add: (patientId, payload) => Api.addRiskAssessment(patientId, payload)
|
||
},
|
||
|
||
careplan: {
|
||
title: 'Add Care Plan',
|
||
fields: [
|
||
{ key: 'status', label: 'Status', type: 'select', options: ['draft', 'active', 'on-hold', 'revoked', 'completed', 'unknown'], default: 'active' },
|
||
{ key: 'intent', label: 'Intent', type: 'select', options: ['proposal', 'plan', 'order', 'option'], default: 'plan' },
|
||
{ key: 'periodStart', label: 'Period Start', type: 'date' },
|
||
{ key: 'periodEnd', label: 'Period End', type: 'date' },
|
||
{ key: 'activityStatus', label: 'Activity Status', type: 'select', options: ['not-started', 'scheduled', 'in-progress', 'on-hold', 'completed', 'cancelled'], default: 'scheduled' },
|
||
{ key: 'activityScheduledStart', label: 'Activity Scheduled Start', type: 'date' },
|
||
{ key: 'activityScheduledEnd', label: 'Activity Scheduled End', type: 'date' }
|
||
],
|
||
derive: async (p, patientId) => {
|
||
const pr = await Api.getPractitioners().catch(() => []);
|
||
return {
|
||
...p,
|
||
subjectPatientId: patientId,
|
||
authorPatientId: patientId,
|
||
activityPerformerPractitionerId: pr[0]?.id ?? null
|
||
};
|
||
},
|
||
add: (patientId, payload) => Api.addCarePlan(patientId, payload)
|
||
}
|
||
};
|
||
|
||
const SEVERITY_LABELS = { '255604002': 'Mild', '6736007': 'Moderate', '24484000': 'Severe' };
|
||
const INTERPRETATION_CODES = { 'Normal': 'N', 'High': 'H', 'Low': 'L' };
|
||
const ENCOUNTER_CLASS_LABELS = { AMB: 'Ambulatory', IMP: 'Inpatient Encounter', EMER: 'Emergency', HH: 'Home Health' };
|
||
|
||
function capitalise(str) {
|
||
if (!str) return '';
|
||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||
}
|
||
|
||
let activeResourceType = null;
|
||
let newPatientModal, addResourceModal, riskModal;
|
||
|
||
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, i) => `<option value="${o}" ${o === f.default ? 'selected' : ''}>${escapeHtml((f.labels && f.labels[i]) || o)}</option>`).join('')}
|
||
</select>
|
||
</div>`;
|
||
}
|
||
if (f.type === 'textarea') {
|
||
return `<div class="col-12">
|
||
<label class="form-label small">${escapeHtml(f.label)}</label>
|
||
<textarea class="form-control" rows="3" id="${id}" placeholder="${escapeHtml(f.placeholder || '')}"></textarea>
|
||
</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: [], servicerequests: [], riskassessments: [], cdsalerts: [], organizations: [], locations: [], practitioners: [], practitionerroles: [] };
|
||
let resourceBrowserModal;
|
||
|
||
async function loadDashboardStats() {
|
||
resourceCache = { conditions: [], observations: [], allergies: [], medications: [], encounters: [], careplans: [], servicerequests: [], riskassessments: [], cdsalerts: [], organizations: [], locations: [], practitioners: [], practitionerroles: [] };
|
||
|
||
// Pull anything new from Medplum before computing counts, so the
|
||
// dashboard always reflects what's actually in Medplum right now instead
|
||
// of requiring a manual "Sync from Medplum" click on each resource page
|
||
// first. Best-effort: a sync failure (e.g. Medplum offline) never blocks
|
||
// the rest of the dashboard from loading with whatever's already local.
|
||
await Promise.allSettled([
|
||
Api.syncPatientsFromMedplum?.(),
|
||
Api.syncPractitionersFromMedplum?.(),
|
||
Api.syncOrganizationsFromMedplum?.(),
|
||
Api.syncLocationsFromMedplum?.(),
|
||
Api.syncPractitionerRolesFromMedplum?.(),
|
||
Api.syncConditionsFromMedplum?.(),
|
||
Api.syncObservationsFromMedplum?.(),
|
||
Api.syncAllergiesFromMedplum?.(),
|
||
Api.syncMedicationsFromMedplum?.(),
|
||
Api.syncEncountersFromMedplum?.(),
|
||
Api.syncCarePlansFromMedplum?.(),
|
||
Api.syncServiceRequestsFromMedplum?.(),
|
||
Api.syncRiskAssessmentsFromMedplum?.()
|
||
]);
|
||
|
||
// Patient sync above may have pulled in new patients, so refresh the
|
||
// cache used everywhere below (Api.getChart calls, stat cards, etc.).
|
||
try {
|
||
patientsCache = await Api.getPatients();
|
||
} catch {
|
||
// Keep whatever patientsCache already had if this refresh fails.
|
||
}
|
||
|
||
try {
|
||
// Service requests, risk assessments and CDS alerts are cross-patient
|
||
// endpoints, so they're fetched once rather than per patient.
|
||
const [chartResults, carePlanResults, serviceRequests, riskAssessments, alerts,
|
||
organizations, locations, practitioners, practitionerRoles] =
|
||
await Promise.all([
|
||
Promise.all(patientsCache.map(p => Api.getChart(p.id).catch(() => null))),
|
||
Promise.all(patientsCache.map(p => Api.getCarePlans(p.id).catch(() => []))),
|
||
Api.listAllServiceRequests().catch(() => []),
|
||
Api.listAllRiskAssessments().catch(() => []),
|
||
Api.listAllCdsAlerts().catch(() => []),
|
||
Api.getOrganizations().catch(() => []),
|
||
Api.getLocations().catch(() => []),
|
||
Api.getPractitioners().catch(() => []),
|
||
Api.getPractitionerRoles().catch(() => [])
|
||
]);
|
||
|
||
const nameFor = (id) => patientsCache.find(p => p.id === id)?.fullName || id;
|
||
resourceCache.servicerequests = (serviceRequests ?? [])
|
||
.map(r => ({ ...r, patientName: r.patientName || nameFor(r.patientId) }));
|
||
resourceCache.riskassessments = (riskAssessments ?? [])
|
||
.map(r => ({ ...r, patientName: r.patientName || nameFor(r.patientId) }));
|
||
resourceCache.cdsalerts = (alerts ?? [])
|
||
.map(r => ({ ...r, patientName: r.patientName || nameFor(r.patientId) }));
|
||
|
||
// Organizations, locations, practitioners and practitioner roles belong to
|
||
// the whole system, not to a patient — so they are never filtered by the
|
||
// selected patient and their counts are always the full totals.
|
||
resourceCache.organizations = organizations ?? [];
|
||
resourceCache.locations = locations ?? [];
|
||
resourceCache.practitioners = practitioners ?? [];
|
||
resourceCache.practitionerroles = practitionerRoles ?? [];
|
||
|
||
patientChartsCache = {};
|
||
|
||
patientsCache.forEach((p, idx) => {
|
||
const chart = chartResults[idx];
|
||
if (chart) patientChartsCache[p.id] = chart;
|
||
const tag = (item) => ({ ...item, patientId: p.id, patientName: p.fullName });
|
||
|
||
if (chart) {
|
||
// ?? [] guards against an endpoint that returns the panel as null or
|
||
// omits it entirely — reading .forEach on undefined was what threw
|
||
// "Cannot read properties of undefined (reading 'length')".
|
||
(chart.conditions ?? []).forEach(c => resourceCache.conditions.push(tag(c)));
|
||
(chart.observations ?? []).forEach(o => resourceCache.observations.push(tag(o)));
|
||
(chart.allergies ?? []).forEach(a => resourceCache.allergies.push(tag(a)));
|
||
(chart.medications ?? []).forEach(m => resourceCache.medications.push(tag(m)));
|
||
(chart.encounters ?? []).forEach(e => resourceCache.encounters.push(tag(e)));
|
||
}
|
||
(carePlanResults[idx] ?? []).forEach(cp => resourceCache.careplans.push(tag(cp)));
|
||
});
|
||
} 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();
|
||
renderAnalytics();
|
||
}
|
||
|
||
// 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 => {
|
||
const fullName = (p.fullName || '').toLowerCase();
|
||
const nameParts = fullName.split(' ').filter(Boolean);
|
||
|
||
// Name matches if the search term is the start of the full name, OR
|
||
// the start of any individual name part (first name, last name, etc.)
|
||
// — so typing "N" matches "Nisha Meshram" and "Neha Patil", but not
|
||
// "Anjali Patil" (the 'n' isn't at the start of either name part).
|
||
const nameMatch = fullName.startsWith(term) || nameParts.some(part => part.startsWith(term));
|
||
|
||
// Strip generic label prefixes ("pt-", "MRN-") from BOTH the stored
|
||
// value and the typed term before matching. Stripping only the stored
|
||
// value would break searching the label itself (e.g. typing "MRN-100"
|
||
// wouldn't match "100234" once its own "MRN-" was stripped); stripping
|
||
// only the term would let a bare "n" match everyone's "MRN-..." label
|
||
// text. Stripping both sides keeps id/MRN search working whether you
|
||
// type the label or just the number.
|
||
const idValue = (p.id || '').toLowerCase().replace(/^pt-/, '');
|
||
const mrnValue = (p.medicalRecordNumber || '').toLowerCase().replace(/^mrn-/, '');
|
||
const idTerm = term.replace(/^pt-/, '');
|
||
const mrnTerm = term.replace(/^mrn-/, '');
|
||
const idMatch = idValue.includes(idTerm);
|
||
const mrnMatch = mrnValue.includes(mrnTerm);
|
||
|
||
return nameMatch || idMatch || mrnMatch;
|
||
});
|
||
}
|
||
|
||
// 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 = getActivePatientIds();
|
||
|
||
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);
|
||
setCount('stat_servicerequests', resourceCache.servicerequests.filter(r => activeIds.has(r.patientId)).length);
|
||
setCount('stat_riskassessments', resourceCache.riskassessments.filter(r => activeIds.has(r.patientId)).length);
|
||
setCount('stat_cdsalerts', resourceCache.cdsalerts.filter(r => activeIds.has(r.patientId)).length);
|
||
|
||
// System-wide resources: total counts, unaffected by patient selection.
|
||
setCount('stat_organizations', resourceCache.organizations.length);
|
||
setCount('stat_locations', resourceCache.locations.length);
|
||
setCount('stat_practitioners', resourceCache.practitioners.length);
|
||
setCount('stat_practitionerroles', resourceCache.practitionerroles.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>`;
|
||
}
|
||
|
||
// The single source of truth for "what patient(s) is the page currently
|
||
// scoped to" — a selected patient takes priority, otherwise it's whoever
|
||
// matches the search box (or everyone, if the box is empty). Used by both
|
||
// the stat cards and the "browse all" modal so they always agree.
|
||
function getActivePatientIds() {
|
||
if (currentPatientId) return new Set([currentPatientId]);
|
||
return new Set(getFilteredPatients().map(p => p.id));
|
||
}
|
||
|
||
const RESOURCE_BROWSER_CONFIG = {
|
||
patients: {
|
||
title: 'Patients',
|
||
columns: ['Name', 'Age', 'Gender', 'MRN'],
|
||
rows: (activeIds) => patientsCache.filter(p => activeIds.has(p.id)).map(p => [
|
||
patientChipHtml(p.id, p.fullName), p.age, escapeHtml(p.gender), escapeHtml(p.medicalRecordNumber)
|
||
])
|
||
},
|
||
conditions: {
|
||
title: 'Conditions',
|
||
columns: ['Patient', 'Display', 'Code', 'Status', 'Severity', 'Onset'],
|
||
rows: (activeIds) => resourceCache.conditions.filter(c => activeIds.has(c.patientId)).map(c => [
|
||
patientChipHtml(c.patientId, c.patientName), escapeHtml(c.display), escapeHtml(c.code),
|
||
escapeHtml(c.clinicalStatus), escapeHtml(c.severity), fmtDate(c.onsetDate)
|
||
])
|
||
},
|
||
medications: {
|
||
title: 'Medications',
|
||
columns: ['Patient', 'Medication', 'Code', 'Status', 'Intent', 'Priority'],
|
||
rows: (activeIds) => resourceCache.medications.filter(m => activeIds.has(m.patientId)).map(m => [
|
||
patientChipHtml(m.patientId, m.patientName), escapeHtml(m.medicationDisplay || m.medicationCode),
|
||
escapeHtml(m.medicationCode), escapeHtml(m.status), escapeHtml(m.intent), escapeHtml(m.priority)
|
||
])
|
||
},
|
||
allergies: {
|
||
title: 'Allergies',
|
||
columns: ['Patient', 'Type', 'Clinical status', 'Verification', 'Criticality'],
|
||
rows: (activeIds) => resourceCache.allergies.filter(a => activeIds.has(a.patientId)).map(a => [
|
||
patientChipHtml(a.patientId, a.patientName), escapeHtml(a.type), escapeHtml(a.clinicalStatus),
|
||
escapeHtml(a.verificationStatus), escapeHtml(a.criticality)
|
||
])
|
||
},
|
||
observations: {
|
||
title: 'Observations',
|
||
columns: ['Patient', 'Display', 'Code', 'Interpretation', 'Result / note', 'Date'],
|
||
rows: (activeIds) => resourceCache.observations.filter(o => activeIds.has(o.patientId)).map(o => [
|
||
patientChipHtml(o.patientId, o.patientName), escapeHtml(o.display), escapeHtml(o.code),
|
||
escapeHtml(o.interpretationDisplay), escapeHtml(o.noteText), fmtDate(o.effectiveDate)
|
||
])
|
||
},
|
||
encounters: {
|
||
title: 'Encounters',
|
||
columns: ['Patient', 'Class', 'Type', 'Start', 'End', 'Status'],
|
||
rows: (activeIds) => resourceCache.encounters.filter(e => activeIds.has(e.patientId)).map(e => [
|
||
patientChipHtml(e.patientId, e.patientName), escapeHtml(e.classDisplay), escapeHtml(e.typeDisplay),
|
||
fmtDate(e.periodStart), fmtDate(e.periodEnd), escapeHtml(e.status)
|
||
])
|
||
},
|
||
organizations: {
|
||
title: 'Organizations',
|
||
columns: ['Name', 'Identifier', 'City', 'State', 'Contact'],
|
||
rows: () => resourceCache.organizations.map(o => [
|
||
escapeHtml(o.name), escapeHtml(o.identifier), escapeHtml(o.city),
|
||
escapeHtml(o.state), escapeHtml(o.value)
|
||
])
|
||
},
|
||
locations: {
|
||
title: 'Locations',
|
||
columns: ['Name', 'Status', 'City', 'State', 'Contact'],
|
||
rows: () => resourceCache.locations.map(l => [
|
||
escapeHtml(l.name), escapeHtml(l.status), escapeHtml(l.city),
|
||
escapeHtml(l.state), escapeHtml(l.telecomValue)
|
||
])
|
||
},
|
||
practitioners: {
|
||
title: 'Practitioners',
|
||
columns: ['Name', 'Gender', 'Qualification', 'City', 'Contact'],
|
||
rows: () => resourceCache.practitioners.map(p => [
|
||
escapeHtml([p.prefix, p.givenName, p.familyName].filter(Boolean).join(' ')),
|
||
escapeHtml(p.gender), escapeHtml(p.qualificationDisplay),
|
||
escapeHtml(p.city), escapeHtml(p.telecomValue)
|
||
])
|
||
},
|
||
practitionerroles: {
|
||
title: 'Practitioner Roles',
|
||
columns: ['Practitioner', 'Role', 'Specialty', 'Organization', 'Location'],
|
||
rows: () => resourceCache.practitionerroles.map(r => [
|
||
escapeHtml(r.practitionerName), escapeHtml(r.roleDisplay),
|
||
escapeHtml(r.specialtyDisplay), escapeHtml(r.organizationName),
|
||
escapeHtml(r.locationName)
|
||
])
|
||
},
|
||
servicerequests: {
|
||
title: 'Service Requests',
|
||
columns: ['Patient', 'Identifier', 'Status', 'Intent', 'Quantity', 'Occurrence'],
|
||
rows: (activeIds) => resourceCache.servicerequests.filter(r => activeIds.has(r.patientId)).map(r => [
|
||
patientChipHtml(r.patientId, r.patientName), escapeHtml(r.identifierValue),
|
||
escapeHtml(r.status), escapeHtml(r.intent),
|
||
[r.quantityComparator, r.quantityValue, r.quantityUnit].filter(Boolean).join(' ') || '—',
|
||
fmtDate(r.occurrenceDateTime)
|
||
])
|
||
},
|
||
riskassessments: {
|
||
title: 'Risk Assessments',
|
||
columns: ['Patient', 'Status', 'Note', 'Occurrence'],
|
||
rows: (activeIds) => resourceCache.riskassessments.filter(r => activeIds.has(r.patientId)).map(r => [
|
||
patientChipHtml(r.patientId, r.patientName), escapeHtml(r.status),
|
||
escapeHtml(r.noteText), fmtDate(r.occurrenceDateTime)
|
||
])
|
||
},
|
||
cdsalerts: {
|
||
title: 'CDS Alerts',
|
||
columns: ['Patient', 'Severity', 'Summary', 'Recommendation', 'Actioned', 'Generated'],
|
||
rows: (activeIds) => resourceCache.cdsalerts.filter(r => activeIds.has(r.patientId)).map(r => [
|
||
patientChipHtml(r.patientId, r.patientName), escapeHtml(r.severity),
|
||
escapeHtml(r.summary), escapeHtml(r.recommendation),
|
||
r.actioned ? 'Yes' : 'No', fmtDate(r.generatedDate)
|
||
])
|
||
},
|
||
careplans: {
|
||
title: 'Care Plans',
|
||
columns: ['Patient', 'Status', 'Intent', 'Activity', 'Period', 'Created'],
|
||
rows: (activeIds) => resourceCache.careplans.filter(cp => activeIds.has(cp.patientId)).map(cp => [
|
||
patientChipHtml(cp.patientId, cp.patientName), escapeHtml(cp.status), escapeHtml(cp.intent),
|
||
escapeHtml(cp.activityStatus), `${fmtDate(cp.periodStart)} – ${fmtDate(cp.periodEnd)}`, fmtDate(cp.createdDate)
|
||
])
|
||
}
|
||
};
|
||
|
||
function openResourceBrowser(resourceType) {
|
||
const config = RESOURCE_BROWSER_CONFIG[resourceType];
|
||
if (!config) return;
|
||
|
||
const activeIds = getActivePatientIds();
|
||
const scopeSuffix = currentPatientId
|
||
? ` — ${patientsCache.find(p => p.id === currentPatientId)?.fullName || 'selected patient'}`
|
||
: (patientSearchTerm.trim() ? ` — matching "${patientSearchTerm.trim()}"` : ' — all patients');
|
||
|
||
document.getElementById('resourceBrowserTitle').textContent = config.title + scopeSuffix;
|
||
document.getElementById('resourceBrowserHead').innerHTML = config.columns.map(c => `<th>${c}</th>`).join('');
|
||
|
||
const rows = config.rows(activeIds);
|
||
const body = document.getElementById('resourceBrowserBody');
|
||
const emptyState = document.getElementById('resourceBrowserEmpty');
|
||
|
||
if (rows.length === 0) {
|
||
body.innerHTML = '';
|
||
emptyState.textContent = 'No data found.';
|
||
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;
|
||
|
||
let 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;
|
||
}
|
||
|
||
// Fill in the *Display / *System twins and the practitioner references the
|
||
// API DTOs require, so the form itself only has to ask for the code.
|
||
if (config.derive) payload = await config.derive(payload, currentPatientId);
|
||
payload.patientId = currentPatientId;
|
||
|
||
try {
|
||
showSpinner(true);
|
||
await config.add(currentPatientId, payload);
|
||
addResourceModal.hide();
|
||
showToast(`${config.title.replace('Add ', '')} added.`);
|
||
await loadChart(currentPatientId);
|
||
loadDashboardStats();
|
||
if (activeResourceType === 'riskassessment') await loadRiskHistory(currentPatientId);
|
||
if (activeResourceType === 'careplan') await loadCarePlans(currentPatientId);
|
||
} 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();
|
||
renderCdsOverview();
|
||
renderAnalytics();
|
||
});
|
||
|
||
document.getElementById('newPatientForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
// Keys must match CreatePatientRequestDto — the old firstName /
|
||
// lastName / dateOfBirth keys bound to nothing, so patients were being
|
||
// created with blank names.
|
||
const mrn = document.getElementById('np_mrn').value
|
||
|| `MRN-${Date.now().toString().slice(-6)}`;
|
||
|
||
const payload = {
|
||
givenName: document.getElementById('np_firstName').value,
|
||
familyName: document.getElementById('np_lastName').value,
|
||
birthDate: document.getElementById('np_dob').value,
|
||
gender: document.getElementById('np_gender').value,
|
||
medicalRecordNumber: mrn,
|
||
identifierSystem: 'http://clinicalinsightspro.in/mrn',
|
||
identifierValue: mrn,
|
||
nameUse: 'official',
|
||
telecomSystem: 'phone',
|
||
telecomUse: 'mobile',
|
||
telecomValue: document.getElementById('np_phone')?.value || '',
|
||
addressUse: 'home',
|
||
addressType: 'both',
|
||
addressLine: document.getElementById('np_addressLine')?.value || '',
|
||
city: document.getElementById('np_city')?.value || '',
|
||
state: document.getElementById('np_state')?.value || '',
|
||
postalCode: document.getElementById('np_postalCode')?.value || ''
|
||
};
|
||
try {
|
||
showSpinner(true);
|
||
// POST /api/patients returns the Patient itself, not { patient: ... }
|
||
const created = await Api.createPatient(payload);
|
||
newPatientModal.hide();
|
||
document.getElementById('newPatientForm').reset();
|
||
showToast('Patient created.');
|
||
await loadPatientList();
|
||
loadDashboardStats();
|
||
if (created?.id) selectPatient(created.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();
|
||
renderCdsOverview();
|
||
renderAnalytics();
|
||
});
|
||
|
||
// 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()).
|
||
// Delegated on #patientContent (not just #tab-chart) so the "+ Add" buttons
|
||
// now sitting in the Risk and Care Plans tabs are covered by the same handler.
|
||
document.getElementById('patientContent').addEventListener('click', (e) => {
|
||
// "View" on a count tile lists that resource for the selected patient.
|
||
const viewBtn = e.target.closest('[data-view]');
|
||
if (viewBtn) { openResourceBrowser(viewBtn.dataset.view); return; }
|
||
|
||
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, delBtn.dataset.deleteId); }
|
||
});
|
||
}
|
||
|
||
// ---------- Identifier generation ----------
|
||
// ServiceRequest.IdentifierSystem / IdentifierValue are [Required] on the
|
||
// model, so leaving them blank in the form produced
|
||
// "Validation failed. IdentifierSystem: The IdentifierSystem field is required."
|
||
// They are generated here instead of being asked for: the value merges the
|
||
// patient's ID and MRN with a running sequence number, so it is unique per
|
||
// patient and readable at a glance (e.g. SR-pat-002-MRN-IN-100002-0003).
|
||
const IDENTIFIER_SYSTEM = 'http://clinicalinsightspro.in/service-request';
|
||
|
||
async function nextIdentifierValue(prefix, patientId) {
|
||
const mrn = currentChart?.medicalRecordNumber || 'NO-MRN';
|
||
|
||
// Sequence = how many of this resource the patient already has, + 1.
|
||
let existing = 0;
|
||
try {
|
||
const all = (await Api.listAllServiceRequests()) ?? [];
|
||
existing = all.filter(r => r.patientId === patientId).length;
|
||
} catch {
|
||
// If the lookup fails, fall back to a timestamp suffix rather than
|
||
// blocking the save on a number that is only cosmetic.
|
||
return `${prefix}-${patientId}-${mrn}-${Date.now().toString().slice(-4)}`;
|
||
}
|
||
|
||
const seq = String(existing + 1).padStart(4, '0');
|
||
return `${prefix}-${patientId}-${mrn}-${seq}`;
|
||
}
|
||
|
||
// ---------- Shared chart helpers ----------
|
||
const SEVERITY_COLOURS = { critical: '#c0392b', warning: '#d68910', info: '#2e86ab' };
|
||
const RISK_COLOURS = { High: '#c0392b', Moderate: '#d68910', Low: '#1e8449' };
|
||
|
||
// Builds a CSS conic-gradient from {label: count} pairs. Using conic-gradient
|
||
// keeps the pie dependency-free — no Chart.js, no extra network request.
|
||
function buildPieGradient(counts, colours) {
|
||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||
if (total === 0) return null;
|
||
|
||
let cursor = 0;
|
||
const stops = [];
|
||
for (const [key, value] of Object.entries(counts)) {
|
||
if (!value) continue;
|
||
const start = (cursor / total) * 360;
|
||
cursor += value;
|
||
const end = (cursor / total) * 360;
|
||
stops.push(`${colours[key] || '#9aa8a8'} ${start}deg ${end}deg`);
|
||
}
|
||
return `conic-gradient(${stops.join(', ')})`;
|
||
}
|
||
|
||
function renderPie(el, counts, colours) {
|
||
const gradient = buildPieGradient(counts, colours);
|
||
if (gradient) {
|
||
el.style.background = gradient;
|
||
el.classList.remove('is-empty');
|
||
} else {
|
||
el.style.background = '';
|
||
el.classList.add('is-empty');
|
||
}
|
||
}
|
||
|
||
function legendRow(colour, label, value, total) {
|
||
const pct = total ? Math.round((value / total) * 100) : 0;
|
||
return `<li><span class="pie-swatch" style="background:${colour}"></span>
|
||
<span>${escapeHtml(label)}: ${value} (${pct}%)</span></li>`;
|
||
}
|
||
|
||
// ---------- Selected reporting period ----------
|
||
// The month/year pickers above the pie now drive EVERY chart in the panel,
|
||
// not just the alert breakdown.
|
||
function selectedPeriod() {
|
||
return {
|
||
month: document.getElementById('cdsOverviewMonth')?.value ?? 'all',
|
||
year: document.getElementById('cdsOverviewYear')?.value ?? 'all'
|
||
};
|
||
}
|
||
|
||
function inPeriod(dateValue, period) {
|
||
if (period.month === 'all' && period.year === 'all') return true;
|
||
const d = new Date(dateValue);
|
||
if (isNaN(d)) return false;
|
||
if (period.year !== 'all' && d.getFullYear() !== Number(period.year)) return false;
|
||
if (period.month !== 'all' && d.getMonth() !== Number(period.month)) return false;
|
||
return true;
|
||
}
|
||
|
||
// A patient counts as "in the period" if any dated item on their chart falls
|
||
// inside it. Medications and allergies carry no date in this schema, so the
|
||
// test uses encounters, observations and conditions.
|
||
function chartInPeriod(chart, period) {
|
||
if (period.month === 'all' && period.year === 'all') return true;
|
||
|
||
const id = chart.patientId;
|
||
const dates = [];
|
||
|
||
(chart.encounters ?? []).forEach(e => { dates.push(e.periodStart); dates.push(e.periodEnd); });
|
||
(chart.observations ?? []).forEach(o => dates.push(o.effectiveDate));
|
||
(chart.conditions ?? []).forEach(c => { dates.push(c.recordedDate); dates.push(c.onsetDate); });
|
||
|
||
// Also count the resources that live outside the chart payload.
|
||
resourceCache.careplans.filter(r => r.patientId === id)
|
||
.forEach(r => { dates.push(r.periodStart); dates.push(r.createdDate); });
|
||
resourceCache.servicerequests.filter(r => r.patientId === id)
|
||
.forEach(r => dates.push(r.occurrenceDateTime));
|
||
resourceCache.riskassessments.filter(r => r.patientId === id)
|
||
.forEach(r => dates.push(r.occurrenceDateTime));
|
||
resourceCache.cdsalerts.filter(r => r.patientId === id)
|
||
.forEach(r => dates.push(r.generatedDate));
|
||
|
||
return dates.some(d => d && inPeriod(d, period));
|
||
}
|
||
|
||
function periodLabel(period) {
|
||
const monthName = period.month === 'all' ? null : MONTH_FULL_NAMES[Number(period.month)];
|
||
const yearName = period.year === 'all' ? null : period.year;
|
||
if (!monthName && !yearName) return 'all time';
|
||
return [monthName, yearName].filter(Boolean).join(' ');
|
||
}
|
||
|
||
const MONTH_FULL_NAMES = ['January', 'February', 'March', 'April', 'May', 'June',
|
||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||
|
||
// ---------- CDS Alert Overview ----------
|
||
let cdsAlertsAll = [];
|
||
// Every patient's chart, keyed by patient id. loadDashboardStats() already
|
||
// fetches these; keeping them lets the cohort charts be built without a
|
||
// second round of requests.
|
||
let patientChartsCache = {};
|
||
|
||
async function loadCdsAlertData() {
|
||
try {
|
||
cdsAlertsAll = (await Api.listAllCdsAlerts()) ?? [];
|
||
} catch {
|
||
cdsAlertsAll = [];
|
||
}
|
||
populateOverviewFilters();
|
||
renderCdsOverview();
|
||
renderAnalytics();
|
||
}
|
||
|
||
// Month/year dropdowns are built from the alert dates actually present, so
|
||
// the filter never offers a period with nothing in it.
|
||
function populateOverviewFilters() {
|
||
const monthSel = document.getElementById('cdsOverviewMonth');
|
||
const yearSel = document.getElementById('cdsOverviewYear');
|
||
if (!monthSel || !yearSel || monthSel.dataset.built === '1') return;
|
||
|
||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
|
||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||
|
||
monthSel.innerHTML = '<option value="all">All months</option>' +
|
||
monthNames.map((m, i) => `<option value="${i}">${m}</option>`).join('');
|
||
|
||
const years = [...new Set(collectAllYears())].sort((a, b) => b - a);
|
||
|
||
const now = new Date();
|
||
if (!years.includes(now.getFullYear())) {
|
||
years.unshift(now.getFullYear());
|
||
years.sort((a, b) => b - a);
|
||
}
|
||
|
||
yearSel.innerHTML = '<option value="all">All years</option>' +
|
||
years.map(y => `<option value="${y}">${y}</option>`).join('');
|
||
|
||
// Open on the current month and year. Pick "All months" / "All years" to
|
||
// see the whole history — the year list above now covers every year that
|
||
// appears in the data, not just the current one.
|
||
monthSel.value = String(now.getMonth());
|
||
yearSel.value = String(now.getFullYear());
|
||
monthSel.dataset.built = '1';
|
||
|
||
monthSel.addEventListener('change', () => { renderCdsOverview(); renderAnalytics(); });
|
||
yearSel.addEventListener('change', () => { renderCdsOverview(); renderAnalytics(); });
|
||
}
|
||
|
||
// Every year that appears anywhere in the loaded data, so the dropdown
|
||
// covers the full history rather than just the year CDS alerts happen to
|
||
// carry. Conditions go back to 2019 in the seed data, encounters to 2025.
|
||
function collectAllYears() {
|
||
const years = [];
|
||
|
||
const push = (value) => {
|
||
if (!value) return;
|
||
const d = new Date(value);
|
||
if (!isNaN(d)) years.push(d.getFullYear());
|
||
};
|
||
|
||
cdsAlertsAll.forEach(a => push(a.generatedDate));
|
||
|
||
Object.values(patientChartsCache).forEach(chart => {
|
||
(chart.encounters ?? []).forEach(e => { push(e.periodStart); push(e.periodEnd); });
|
||
(chart.observations ?? []).forEach(o => { push(o.effectiveDate); push(o.issued); });
|
||
(chart.conditions ?? []).forEach(c => {
|
||
push(c.onsetDate); push(c.recordedDate); push(c.abatementDate);
|
||
});
|
||
});
|
||
|
||
resourceCache.careplans.forEach(r => { push(r.periodStart); push(r.periodEnd); push(r.createdDate); });
|
||
resourceCache.servicerequests.forEach(r => push(r.occurrenceDateTime));
|
||
resourceCache.riskassessments.forEach(r => push(r.occurrenceDateTime));
|
||
|
||
return years;
|
||
}
|
||
|
||
function getFilteredAlerts() {
|
||
const period = selectedPeriod();
|
||
// Always scope to the visible patient list, never to one selected patient.
|
||
const ids = new Set(getFilteredPatients().map(p => p.id));
|
||
|
||
return cdsAlertsAll.filter(a =>
|
||
ids.has(a.patientId) && inPeriod(a.generatedDate, period));
|
||
}
|
||
|
||
function renderCdsOverview() {
|
||
const pie = document.getElementById('cdsOverviewPie');
|
||
const legend = document.getElementById('cdsOverviewLegend');
|
||
const totalEl = document.getElementById('cdsOverviewTotal');
|
||
const titleEl = document.getElementById('cdsOverviewTitle');
|
||
if (!pie || !legend) return;
|
||
|
||
// The card lives in the "no patient selected" panel, so it always reports
|
||
// across whoever is currently in view rather than a single patient.
|
||
titleEl.textContent = patientSearchTerm.trim()
|
||
? `CDS Alert Overview — ${getFilteredPatients().length} matching patients`
|
||
: 'CDS Alert Overview — all patients';
|
||
|
||
const alerts = getFilteredAlerts();
|
||
const counts = {
|
||
critical: alerts.filter(a => a.severity === 'critical').length,
|
||
warning: alerts.filter(a => a.severity === 'warning').length,
|
||
info: alerts.filter(a => a.severity === 'info').length
|
||
};
|
||
const total = alerts.length;
|
||
|
||
renderPie(pie, counts, SEVERITY_COLOURS);
|
||
|
||
const patientCount = getFilteredPatients().length;
|
||
totalEl.textContent = `${patientCount} patient${patientCount === 1 ? '' : 's'} · ${total} alert${total === 1 ? '' : 's'}`;
|
||
|
||
legend.innerHTML = total
|
||
? legendRow(SEVERITY_COLOURS.critical, 'Critical', counts.critical, total) +
|
||
legendRow(SEVERITY_COLOURS.warning, 'Warning', counts.warning, total) +
|
||
legendRow(SEVERITY_COLOURS.info, 'Info', counts.info, total)
|
||
: '<li class="text-muted-ci">No alerts in the selected period.</li>';
|
||
}
|
||
|
||
// ---------- Cohort analytics ----------
|
||
// All four charts describe the patients currently in view (the search box
|
||
// narrows them), and are only visible while no single patient is selected.
|
||
|
||
// Horizontal bar chart from [{label, value}] — no charting library needed.
|
||
function barChartHtml(rows) {
|
||
if (!rows.length) return '<div class="bar-empty">No data.</div>';
|
||
const max = Math.max(...rows.map(r => r.value), 1);
|
||
|
||
return rows.map(r => `
|
||
<div class="bar-row">
|
||
<div class="bar-label" title="${escapeHtml(r.label)}">${escapeHtml(r.label)}</div>
|
||
<div class="bar-track"><div class="bar-fill" style="width:${(r.value / max) * 100}%"></div></div>
|
||
<div class="bar-value">${r.value}</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
// Counts occurrences of a key across the visible patients' charts.
|
||
function tallyBy(charts, keyFn) {
|
||
const counts = {};
|
||
charts.forEach(c => {
|
||
const key = keyFn(c);
|
||
if (!key) return;
|
||
counts[key] = (counts[key] || 0) + 1;
|
||
});
|
||
return counts;
|
||
}
|
||
|
||
const AGE_BANDS = [
|
||
{ label: '0–17', test: a => a < 18 },
|
||
{ label: '18–29', test: a => a >= 18 && a < 30 },
|
||
{ label: '30–44', test: a => a >= 30 && a < 45 },
|
||
{ label: '45–59', test: a => a >= 45 && a < 60 },
|
||
{ label: '60–74', test: a => a >= 60 && a < 75 },
|
||
{ label: '75+', test: a => a >= 75 }
|
||
];
|
||
|
||
const GENDER_COLOURS = { female: '#c0392b', male: '#2e86ab', other: '#d68910', unknown: '#7f8c8d' };
|
||
|
||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
|
||
function renderAnalytics() {
|
||
const genderPie = document.getElementById('genderPie');
|
||
if (!genderPie) return; // panel isn't on the page
|
||
|
||
const period = selectedPeriod();
|
||
|
||
// Patients currently in view, narrowed to those with activity in the
|
||
// selected month/year — so every chart below reflects the same period as
|
||
// the alert breakdown above.
|
||
const charts = getFilteredPatients()
|
||
.map(p => patientChartsCache[p.id])
|
||
.filter(Boolean)
|
||
.filter(c => chartInPeriod(c, period));
|
||
|
||
const scopeEl = document.getElementById('analyticsScope');
|
||
if (scopeEl) {
|
||
scopeEl.textContent = period.month === 'all' && period.year === 'all'
|
||
? `${charts.length} patient${charts.length === 1 ? '' : 's'} — all time`
|
||
: `${charts.length} patient${charts.length === 1 ? '' : 's'} with activity in ${periodLabel(period)}`;
|
||
}
|
||
|
||
// ---- Gender (pie) ----
|
||
const genderCounts = tallyBy(charts, c => (c.gender || 'unknown').toLowerCase());
|
||
renderPie(genderPie, genderCounts, GENDER_COLOURS);
|
||
|
||
const genderTotal = Object.values(genderCounts).reduce((a, b) => a + b, 0);
|
||
document.getElementById('genderLegend').innerHTML = genderTotal
|
||
? Object.entries(genderCounts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([g, n]) => legendRow(GENDER_COLOURS[g] || '#9aa8a8', capitalise(g), n, genderTotal))
|
||
.join('')
|
||
: `<li class="text-muted-ci">No patients in ${escapeHtml(periodLabel(period))}.</li>`;
|
||
|
||
// ---- Age groups (bars) ----
|
||
const ageRows = AGE_BANDS.map(band => ({
|
||
label: band.label,
|
||
value: charts.filter(c => typeof c.age === 'number' && band.test(c.age)).length
|
||
}));
|
||
document.getElementById('ageBars').innerHTML =
|
||
ageRows.some(r => r.value) ? barChartHtml(ageRows)
|
||
: `<div class="bar-empty">No patients in ${escapeHtml(periodLabel(period))}.</div>`;
|
||
|
||
// ---- Cities (bars, biggest first, top 8) ----
|
||
const cityCounts = tallyBy(charts, c => c.city);
|
||
const cityRows = Object.entries(cityCounts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 8)
|
||
.map(([label, value]) => ({ label, value }));
|
||
document.getElementById('cityBars').innerHTML = cityRows.length
|
||
? barChartHtml(cityRows)
|
||
: `<div class="bar-empty">No patients in ${escapeHtml(periodLabel(period))}.</div>`;
|
||
|
||
// ---- Patients seen per month, for the year picked above ----
|
||
// Registration dates aren't exposed by the API, so this counts DISTINCT
|
||
// patients who had at least one encounter in each month — i.e. how many
|
||
// patients were actually seen.
|
||
// This one deliberately ignores the month picker — it IS the month
|
||
// breakdown — but it follows the year. "All years" aggregates every year
|
||
// rather than silently falling back to the current one, which is why it
|
||
// used to report "no encounters" while 2025 data existed.
|
||
const year = period.year !== 'all' ? Number(period.year) : null;
|
||
|
||
const yearLabel = document.getElementById('monthChartYear');
|
||
if (yearLabel) yearLabel.textContent = year ? `· ${year}` : '· all years';
|
||
|
||
const seen = MONTH_NAMES.map(() => new Set());
|
||
const yearCharts = getFilteredPatients()
|
||
.map(p => patientChartsCache[p.id])
|
||
.filter(Boolean);
|
||
|
||
yearCharts.forEach(c => {
|
||
(c.encounters ?? []).forEach(e => {
|
||
const d = new Date(e.periodStart);
|
||
if (isNaN(d)) return;
|
||
if (year !== null && d.getFullYear() !== year) return;
|
||
seen[d.getMonth()].add(c.patientId);
|
||
});
|
||
});
|
||
|
||
const monthRows = MONTH_NAMES.map((m, i) => ({ label: m, value: seen[i].size }));
|
||
document.getElementById('monthBars').innerHTML =
|
||
monthRows.some(r => r.value)
|
||
? barChartHtml(monthRows)
|
||
: `<div class="bar-empty">No encounters recorded${year ? ` in ${year}` : ''}.</div>`;
|
||
}
|
||
|
||
// ---------- Risk assessment history ----------
|
||
// The API stores each assessment's contributing factors as one text field,
|
||
// e.g. "Current smoker (+10); Prior hospitalization (+12)". The score is the
|
||
// sum of those weights, so it can be recovered here without a schema change.
|
||
function parseRiskNote(noteText) {
|
||
const factors = (noteText || '')
|
||
.split(';')
|
||
.map(s => s.trim())
|
||
.filter(Boolean);
|
||
|
||
const score = Math.min(
|
||
factors.reduce((sum, f) => {
|
||
const m = f.match(/\(\+(\d+(?:\.\d+)?)\)/);
|
||
return sum + (m ? parseFloat(m[1]) : 0);
|
||
}, 0),
|
||
100
|
||
);
|
||
|
||
return { factors, score: Math.round(score) };
|
||
}
|
||
|
||
function riskLevelFor(score) {
|
||
if (score >= 50) return 'High';
|
||
if (score >= 25) return 'Moderate';
|
||
return 'Low';
|
||
}
|
||
|
||
async function loadRiskHistory(patientId) {
|
||
const listEl = document.getElementById('riskHistoryList');
|
||
const pieEl = document.getElementById('riskHistoryPie');
|
||
const legendEl = document.getElementById('riskHistoryLegend');
|
||
if (!listEl) return;
|
||
|
||
let history = [];
|
||
try {
|
||
const all = (await Api.listAllRiskAssessments()) ?? [];
|
||
history = all.filter(r => r.patientId === patientId);
|
||
} catch (err) {
|
||
listEl.innerHTML = `<div class="text-muted-ci">Could not load history: ${escapeHtml(err.message)}</div>`;
|
||
return;
|
||
}
|
||
|
||
const rows = history
|
||
.map(r => ({ ...r, ...parseRiskNote(r.noteText) }))
|
||
.map(r => ({ ...r, level: riskLevelFor(r.score) }))
|
||
.sort((a, b) => new Date(b.occurrenceDateTime) - new Date(a.occurrenceDateTime));
|
||
|
||
const counts = {
|
||
High: rows.filter(r => r.level === 'High').length,
|
||
Moderate: rows.filter(r => r.level === 'Moderate').length,
|
||
Low: rows.filter(r => r.level === 'Low').length
|
||
};
|
||
|
||
renderPie(pieEl, counts, RISK_COLOURS);
|
||
|
||
legendEl.innerHTML = rows.length
|
||
? ['High', 'Moderate', 'Low']
|
||
.filter(k => counts[k] > 0)
|
||
.map(k => `<li><span class="pie-swatch" style="background:${RISK_COLOURS[k]}"></span>
|
||
<span>${k}: ${counts[k]} of ${rows.length}</span></li>`).join('')
|
||
: '<li class="text-muted-ci">No assessments recorded yet.</li>';
|
||
|
||
listEl.innerHTML = rows.length
|
||
? rows.map(r => `
|
||
<div class="risk-history-item">
|
||
<div class="risk-score-chip ${r.level}">${r.score}%</div>
|
||
<div class="flex-grow-1">
|
||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||
<div class="fw-semibold">${escapeHtml(r.level)}</div>
|
||
<div class="small text-muted-ci">Assessed ${fmtDate(r.occurrenceDateTime)}</div>
|
||
</div>
|
||
${r.factors.length
|
||
? `<ul class="risk-factor-list">${r.factors.map(f => `<li>${escapeHtml(f)}</li>`).join('')}</ul>`
|
||
: '<div class="small text-muted-ci">No contributing factors recorded.</div>'}
|
||
</div>
|
||
</div>`).join('')
|
||
: '<div class="text-muted-ci text-center py-3">No assessments recorded yet.</div>';
|
||
}
|
||
|
||
// ---------- Patient hero + demographics ----------
|
||
function initialsFor(name) {
|
||
return (name || '')
|
||
.split(' ')
|
||
.filter(Boolean)
|
||
.filter(part => !/^(dr|mr|mrs|ms|miss)\.?$/i.test(part))
|
||
.slice(0, 2)
|
||
.map(part => part[0].toUpperCase())
|
||
.join('') || '?';
|
||
}
|
||
|
||
function renderPatientHero(chart, fallbackId) {
|
||
const set = (id, value) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = (value === 0 || value) ? value : '—';
|
||
};
|
||
|
||
const fullName = chart.fullName || '—';
|
||
const patientId = chart.patientId || fallbackId;
|
||
|
||
set('patientName', fullName);
|
||
set('patientAvatar', initialsFor(fullName));
|
||
|
||
// Everything identifying the patient goes on this one line, so the blocks
|
||
// below never repeat it: age, gender, patient ID, mobile, MRN.
|
||
document.getElementById('patientMeta').textContent = [
|
||
chart.age != null ? `${chart.age} yrs` : null,
|
||
chart.gender || null,
|
||
patientId ? `Patient ID: ${patientId}` : null,
|
||
chart.telecomValue || null,
|
||
chart.medicalRecordNumber ? `MRN: ${chart.medicalRecordNumber}` : null
|
||
].filter(Boolean).join(' · ');
|
||
|
||
// Blocks: clinical counts + the two details not in the hero line.
|
||
set('demo_appointments', (chart.encounters ?? []).length);
|
||
set('demo_conditions', (chart.conditions ?? []).length);
|
||
set('demo_medications', (chart.medications ?? []).length);
|
||
set('demo_allergies', (chart.allergies ?? []).length);
|
||
set('demo_observations', (chart.observations ?? []).length);
|
||
set('demo_city', [chart.city, chart.state].filter(Boolean).join(', '));
|
||
set('demo_blood', chart.bloodGroup);
|
||
set('demo_email', chart.email);
|
||
// Care plans are fetched separately; loadCarePlans() fills this in.
|
||
set('demo_careplans', '…');
|
||
}
|
||
|
||
// ---------- Init ----------
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
const token = sessionStorage.getItem('cip_token');
|
||
if (!token) {
|
||
window.location.href = 'login.html';
|
||
return;
|
||
}
|
||
|
||
renderSidebar('dashboard');
|
||
|
||
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();
|
||
// Awaited: populateOverviewFilters() below reads patientChartsCache and
|
||
// resourceCache to work out which years to offer, so those must be filled
|
||
// in first — otherwise the year list collapses to just the current year.
|
||
await loadDashboardStats();
|
||
await loadCdsAlertData();
|
||
|
||
// A resource page (Conditions, Medications, etc.) can link back here with
|
||
// ?patient=<id> to jump straight to that patient's chart — takes
|
||
// priority over whatever patient was last open in this session.
|
||
const queryPatientId = new URLSearchParams(window.location.search).get('patient');
|
||
const launchPatientId = queryPatientId || 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 class="patient-tile-text">
|
||
<div class="fw-semibold">${escapeHtml(p.fullName)}</div>
|
||
<div class="small text-muted-ci">ID: ${escapeHtml(p.id)}</div>
|
||
<div class="small text-muted-ci">MRN: ${escapeHtml(p.medicalRecordNumber)}</div>
|
||
</div>
|
||
<button class="btn btn-sm btn-outline-danger flex-shrink-0" 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();
|
||
|
||
await loadChart(id);
|
||
await loadCarePlans(id);
|
||
await loadRiskHistory(id);
|
||
renderCdsAlertsForPatient(id);
|
||
}
|
||
|
||
// ---------- Chart ----------
|
||
async function loadChart(id) {
|
||
try {
|
||
showSpinner(true);
|
||
const chart = await Api.getChart(id);
|
||
currentChart = chart;
|
||
|
||
renderPatientHero(chart, id);
|
||
|
||
// Every list is defaulted with ?? [] so one missing panel can never take
|
||
// down the whole chart render.
|
||
const conditions = chart.conditions ?? [];
|
||
const allergies = chart.allergies ?? [];
|
||
const medications = chart.medications ?? [];
|
||
const observations = chart.observations ?? [];
|
||
const encounters = chart.encounters ?? [];
|
||
|
||
document.getElementById('conditionsList').innerHTML = conditions.length
|
||
? 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)} · ${escapeHtml(c.severity)} · 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 = allergies.length
|
||
? allergies.map(a => `
|
||
<div class="chart-list-item d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<div class="fw-semibold text-danger">${escapeHtml(capitalise(a.type))}</div>
|
||
<div class="small text-muted-ci">${escapeHtml(a.clinicalStatus)} · ${escapeHtml(a.verificationStatus)} · criticality: ${escapeHtml(a.criticality || 'not assessed')}</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 = medications.length
|
||
? medications.map(m => `
|
||
<div class="chart-list-item d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<div class="fw-semibold">${escapeHtml(m.medicationDisplay || m.medicationCode)}</div>
|
||
<div class="small text-muted-ci">${escapeHtml(m.medicationCode)} · ${escapeHtml(m.status)} · ${escapeHtml(m.intent)}${m.priority ? ` · ${escapeHtml(m.priority)}` : ''}</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 = observations.length
|
||
? 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.code)}${o.interpretationDisplay ? ` <span class="badge bg-secondary-subtle text-secondary">${escapeHtml(o.interpretationDisplay)}</span>` : ''}</div>
|
||
<div class="small text-muted-ci">${escapeHtml(o.noteText || o.categoryDisplay || '')}</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 = encounters.length
|
||
? encounters.map(e => `
|
||
<div class="chart-list-item d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<div class="fw-semibold">${escapeHtml(e.classDisplay)}${e.typeDisplay ? ` — ${escapeHtml(e.typeDisplay)}` : ''}</div>
|
||
<div class="small text-muted-ci">${fmtDate(e.periodStart)} → ${fmtDate(e.periodEnd)} · ${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>';
|
||
|
||
} catch (err) {
|
||
showToast(err.message, 'danger');
|
||
} finally {
|
||
showSpinner(false);
|
||
}
|
||
}
|
||
|
||
// ---------- Risk ----------
|
||
// Answers to the generated questions live here between opening the modal and
|
||
// submitting it, so the payload can be assembled from one place.
|
||
let riskQuestionSpec = [];
|
||
|
||
function resetRiskTab() {
|
||
document.getElementById('riskResultEmpty').classList.remove('d-none');
|
||
document.getElementById('riskResultBody').classList.add('d-none');
|
||
}
|
||
|
||
// Builds the question list for THIS patient. Three groups:
|
||
// 1. core — asked of everyone
|
||
// 2. sex — only for female patients (pregnancy / breastfeeding)
|
||
// 3. chart — derived from the patient's own conditions and medications
|
||
function buildRiskQuestions(chart) {
|
||
const q = [];
|
||
const gender = (chart.gender || '').toLowerCase();
|
||
const conditions = (chart.conditions ?? []).filter(c =>
|
||
(c.clinicalStatus || '').toLowerCase().startsWith('active'));
|
||
const medications = (chart.medications ?? []).filter(m =>
|
||
(m.status || '').toLowerCase() === 'active');
|
||
|
||
q.push({ group: 'General health', key: 'smoker', type: 'check', label: 'Current smoker' });
|
||
q.push({ group: 'General health', key: 'familyHistoryHeartDisease', type: 'check', label: 'Family history of heart disease' });
|
||
q.push({ group: 'General health', key: 'priorHospitalization', type: 'check', label: 'Hospitalised within the last 12 months' });
|
||
q.push({ group: 'General health', key: 'restingHeartRateOver100', type: 'check', label: 'Resting heart rate over 100 bpm' });
|
||
q.push({ group: 'General health', key: 'bmi', type: 'number', label: 'BMI', placeholder: 'e.g. 28.4', step: '0.1' });
|
||
q.push({ group: 'General health', key: 'painScore', type: 'range', label: 'Pain score (0–10)', min: 0, max: 10 });
|
||
|
||
// --- Sex-specific. Only female patients are asked these. ---
|
||
if (gender === 'female') {
|
||
q.push({ group: 'Reproductive health', key: 'pregnant', type: 'check', label: 'Are you currently pregnant?' });
|
||
q.push({ group: 'Reproductive health', key: 'breastfeeding', type: 'check', label: 'Are you currently breastfeeding?' });
|
||
}
|
||
|
||
// --- Driven by the problem list ---
|
||
if (conditions.length) {
|
||
q.push({
|
||
group: 'Conditions',
|
||
key: 'uncontrolledCondition',
|
||
type: 'check',
|
||
label: `Is any active condition currently NOT well controlled? (${conditions.map(c => c.display).join(', ')})`
|
||
});
|
||
q.push({
|
||
group: 'Conditions',
|
||
key: 'newChronicConditionLast6Months',
|
||
type: 'check',
|
||
label: 'New chronic condition diagnosed in the past 6 months'
|
||
});
|
||
|
||
// One targeted follow-up per recognised condition family.
|
||
conditions.forEach(c => {
|
||
const code = (c.code || '').toUpperCase();
|
||
let label = null;
|
||
if (code.startsWith('E11')) label = `Diabetes — HbA1c checked in the last 3 months? (${c.display})`;
|
||
else if (code.startsWith('I10')) label = `Hypertension — monitoring blood pressure at home? (${c.display})`;
|
||
else if (code.startsWith('J45')) label = `Asthma — using a rescue inhaler more than twice a week? (${c.display})`;
|
||
else if (code.startsWith('N18')) label = `Kidney disease — attending all scheduled dialysis/reviews? (${c.display})`;
|
||
else if (code.startsWith('D50')) label = `Anaemia — still experiencing fatigue or breathlessness? (${c.display})`;
|
||
if (label) q.push({ group: 'Conditions', key: `cond_${c.id}`, type: 'check', label, extra: true });
|
||
});
|
||
}
|
||
|
||
// --- Driven by the medication list ---
|
||
if (medications.length) {
|
||
q.push({
|
||
group: 'Medications',
|
||
key: 'missedMedicationDoses',
|
||
type: 'check',
|
||
label: `Any missed doses in the last week? (${medications.map(m => m.medicationDisplay || m.medicationCode).join(', ')})`
|
||
});
|
||
medications.forEach(m => {
|
||
q.push({
|
||
group: 'Medications',
|
||
key: `med_${m.id}`,
|
||
type: 'check',
|
||
label: `Side effects reported from ${m.medicationDisplay || m.medicationCode}?`,
|
||
extra: true
|
||
});
|
||
});
|
||
}
|
||
|
||
// --- Allergy history ---
|
||
if ((chart.allergies ?? []).some(a => (a.criticality || '').toLowerCase() === 'high')) {
|
||
q.push({
|
||
group: 'Allergies',
|
||
key: 'severeAllergyHistory',
|
||
type: 'check',
|
||
label: 'History of a severe or anaphylactic allergic reaction'
|
||
});
|
||
}
|
||
|
||
return q;
|
||
}
|
||
|
||
function renderRiskModal(chart) {
|
||
riskQuestionSpec = buildRiskQuestions(chart);
|
||
|
||
document.getElementById('riskModalTitle').textContent =
|
||
`Risk Assessment — ${chart.fullName || 'patient'}`;
|
||
|
||
const groups = [];
|
||
riskQuestionSpec.forEach(q => {
|
||
let g = groups.find(x => x.name === q.group);
|
||
if (!g) { g = { name: q.group, items: [] }; groups.push(g); }
|
||
g.items.push(q);
|
||
});
|
||
|
||
document.getElementById('riskModalBody').innerHTML = groups.map(g => `
|
||
<div class="mb-4">
|
||
<div class="fw-semibold mb-2">${escapeHtml(g.name)}</div>
|
||
${g.items.map(q => riskFieldHtml(q)).join('')}
|
||
</div>`).join('');
|
||
|
||
// Keep the pain-score readout in sync with the slider.
|
||
const pain = document.getElementById('rq_painScore');
|
||
if (pain) {
|
||
pain.addEventListener('input', () => {
|
||
document.getElementById('rq_painScore_val').textContent = pain.value;
|
||
});
|
||
}
|
||
}
|
||
|
||
function riskFieldHtml(q) {
|
||
const id = `rq_${q.key}`;
|
||
if (q.type === 'check') {
|
||
return `<div class="form-check mb-2">
|
||
<input class="form-check-input" type="checkbox" id="${id}">
|
||
<label class="form-check-label" for="${id}">${escapeHtml(q.label)}</label>
|
||
</div>`;
|
||
}
|
||
if (q.type === 'number') {
|
||
return `<div class="mb-3">
|
||
<label class="form-label small" for="${id}">${escapeHtml(q.label)}</label>
|
||
<input type="number" step="${q.step || '1'}" class="form-control" id="${id}" placeholder="${escapeHtml(q.placeholder || '')}">
|
||
</div>`;
|
||
}
|
||
if (q.type === 'range') {
|
||
return `<div class="mb-3">
|
||
<label class="form-label small" for="${id}">${escapeHtml(q.label)}</label>
|
||
<input type="range" min="${q.min}" max="${q.max}" value="0" class="form-range" id="${id}">
|
||
<div class="text-muted-ci small text-center" id="${id}_val">0</div>
|
||
</div>`;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function collectRiskAnswers() {
|
||
const payload = { additionalFactors: [] };
|
||
|
||
riskQuestionSpec.forEach(q => {
|
||
const el = document.getElementById(`rq_${q.key}`);
|
||
if (!el) return;
|
||
|
||
if (q.type === 'check') {
|
||
const checked = el.checked;
|
||
// Questions generated from the chart aren't scored fields on the API —
|
||
// they're recorded as free text so the saved note shows what was asked.
|
||
if (q.extra) {
|
||
if (checked) payload.additionalFactors.push(`${q.label} — yes`);
|
||
} else {
|
||
payload[q.key] = checked;
|
||
}
|
||
} else if (q.type === 'number') {
|
||
payload[q.key] = el.value === '' ? null : parseFloat(el.value);
|
||
} else if (q.type === 'range') {
|
||
payload[q.key] = parseInt(el.value || '0', 10);
|
||
}
|
||
});
|
||
|
||
return payload;
|
||
}
|
||
|
||
function setupRiskForm() {
|
||
riskModal = new bootstrap.Modal(document.getElementById('riskModal'));
|
||
|
||
document.getElementById('openRiskModalBtn').addEventListener('click', async () => {
|
||
if (!currentPatientId) { showToast('Select a patient first.', 'danger'); return; }
|
||
try {
|
||
showSpinner(true);
|
||
const chart = await Api.getChart(currentPatientId);
|
||
renderRiskModal(chart);
|
||
riskModal.show();
|
||
} catch (err) {
|
||
showToast(err.message, 'danger');
|
||
} finally {
|
||
showSpinner(false);
|
||
}
|
||
});
|
||
|
||
document.getElementById('riskModalForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
if (!currentPatientId) return;
|
||
|
||
try {
|
||
showSpinner(true);
|
||
const result = await Api.assessRisk(currentPatientId, collectRiskAnswers());
|
||
riskModal.hide();
|
||
renderRiskResult(result);
|
||
await loadRiskHistory(currentPatientId);
|
||
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 level = result.riskLevel || 'Low';
|
||
const gauge = document.getElementById('scoreGauge');
|
||
gauge.textContent = result.score;
|
||
gauge.style.background = level === 'High'
|
||
? 'radial-gradient(circle, #e74c3c, #c0392b)'
|
||
: level === 'Moderate'
|
||
? 'radial-gradient(circle, #f0ad4e, #d68910)'
|
||
: 'radial-gradient(circle, #27ae60, #1e8449)';
|
||
|
||
const badge = document.getElementById('riskLevelBadge');
|
||
badge.textContent = `${level} Risk`;
|
||
badge.className = `risk-badge ${level}`;
|
||
|
||
document.getElementById('riskFactorsList').innerHTML =
|
||
(result.contributingFactors ?? []).map(f => `<li>${escapeHtml(f)}</li>`).join('')
|
||
|| '<li class="text-muted-ci">No contributing factors recorded.</li>';
|
||
}
|
||
|
||
// ---------- CDS ----------
|
||
function resetCdsTab() {
|
||
document.getElementById('cdsAlertsContainer').innerHTML =
|
||
'<div class="text-muted-ci text-center py-5">Select a patient to see their alerts.</div>';
|
||
document.getElementById('alertCountBadge').classList.add('d-none');
|
||
}
|
||
|
||
// Severity maps to a meter fill so the card carries a visual weight, the way
|
||
// a real CDS surface grades urgency.
|
||
const SEVERITY_WEIGHT = { critical: 90, warning: 60, info: 30 };
|
||
|
||
// Renders the alerts already saved for this patient (including ones already
|
||
// actioned) straight from the cross-patient cache — so the tab shows content
|
||
// as soon as it is opened, instead of waiting for "Evaluate Encounter".
|
||
function renderCdsAlertsForPatient(patientId) {
|
||
const alerts = cdsAlertsAll
|
||
.filter(a => a.patientId === patientId)
|
||
.sort((a, b) => new Date(b.generatedDate) - new Date(a.generatedDate));
|
||
|
||
renderCdsAlertCards(alerts);
|
||
}
|
||
|
||
function renderCdsAlertCards(alerts) {
|
||
const container = document.getElementById('cdsAlertsContainer');
|
||
const badge = document.getElementById('alertCountBadge');
|
||
|
||
const open = alerts.filter(a => !a.actioned);
|
||
|
||
if (open.length) {
|
||
badge.textContent = open.length;
|
||
badge.classList.remove('d-none');
|
||
} else {
|
||
badge.classList.add('d-none');
|
||
}
|
||
|
||
if (!alerts.length) {
|
||
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 patient.</div></div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = alerts.map(a => {
|
||
const severity = (a.severity || 'info').toLowerCase();
|
||
const weight = SEVERITY_WEIGHT[severity] ?? 30;
|
||
|
||
return `
|
||
<div class="alert-card ${severity}" data-alert-id="${a.id}">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<span class="severity-tag ${severity}">${escapeHtml(severity)}</span>
|
||
${a.actioned
|
||
? '<span class="alert-actioned">Actioned</span>'
|
||
: `<span class="small text-muted-ci">${escapeHtml(a.ruleId || '')}</span>`}
|
||
</div>
|
||
|
||
<div class="alert-meter-row">
|
||
<div class="alert-meter"><div class="alert-meter-fill ${severity}" style="width:${weight}%"></div></div>
|
||
<span class="small text-muted-ci">${weight}%</span>
|
||
</div>
|
||
|
||
<div class="fw-semibold mt-2">${escapeHtml(a.summary)}</div>
|
||
<div class="small text-muted-ci mt-1">${escapeHtml(a.recommendation)}</div>
|
||
<div class="small text-muted-ci mt-1">Generated ${fmtDate(a.generatedDate)}</div>
|
||
|
||
${a.actioned ? '' : `
|
||
<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 evaluateCds() {
|
||
if (!currentPatientId) return;
|
||
try {
|
||
showSpinner(true);
|
||
await Api.evaluateCds(currentPatientId);
|
||
// Re-pull the full alert list so both the tab and the overview pie
|
||
// reflect anything the rules engine just created.
|
||
await loadCdsAlertData();
|
||
renderCdsAlertsForPatient(currentPatientId);
|
||
showToast('Encounter evaluated.');
|
||
} catch (err) {
|
||
showToast(err.message, 'danger');
|
||
} finally {
|
||
showSpinner(false);
|
||
}
|
||
}
|
||
|
||
async function createCarePlanFromAlert(alertId) {
|
||
try {
|
||
showSpinner(true);
|
||
// status + intent are sent explicitly: CreateCarePlanRequestDto used to
|
||
// mark them [Required], and [ApiController] validates the model BEFORE
|
||
// the action runs — so a missing value came back as "Validation failed."
|
||
await Api.createCarePlan({
|
||
patientId: currentPatientId,
|
||
alertId,
|
||
createServiceRequest: true,
|
||
status: 'active',
|
||
intent: 'plan'
|
||
});
|
||
showToast('Care Plan and Service Request created.');
|
||
|
||
await loadCdsAlertData();
|
||
renderCdsAlertsForPatient(currentPatientId);
|
||
await loadCarePlans(currentPatientId);
|
||
} catch (err) {
|
||
showToast(err.message, 'danger');
|
||
} finally {
|
||
showSpinner(false);
|
||
}
|
||
}
|
||
|
||
// ---------- Care Plans ----------
|
||
async function loadCarePlans(patientId) {
|
||
if (!patientId) return;
|
||
const tbody = document.getElementById('carePlansTableBody');
|
||
|
||
try {
|
||
let carePlans;
|
||
try {
|
||
carePlans = (await Api.getCarePlans(patientId)) ?? [];
|
||
} catch (inner) {
|
||
// If /careplans/{patientId} isn't available (older backend build),
|
||
// fall back to the cross-patient list and filter client-side rather
|
||
// than showing the tab as broken.
|
||
const all = (await Api.listAllCarePlans()) ?? [];
|
||
carePlans = all.filter(cp => cp.patientId === patientId);
|
||
}
|
||
|
||
const countEl = document.getElementById('demo_careplans');
|
||
if (countEl) countEl.textContent = carePlans.length;
|
||
|
||
tbody.innerHTML = carePlans.length
|
||
? carePlans.map(cp => `
|
||
<tr>
|
||
<td><span class="badge bg-success-subtle text-success">${escapeHtml(cp.status)}</span></td>
|
||
<td>${escapeHtml(cp.intent)}</td>
|
||
<td>${escapeHtml(cp.activityStatus || '—')}</td>
|
||
<td class="small text-muted-ci">${fmtDate(cp.periodStart)} → ${fmtDate(cp.periodEnd)}</td>
|
||
<td class="small text-muted-ci">${escapeHtml(cp.performerName || '—')}</td>
|
||
<td class="small text-muted-ci">${fmtDate(cp.createdDate)}</td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="6" class="text-muted-ci text-center py-4">No care plans yet.</td></tr>';
|
||
} catch (err) {
|
||
// A failure here shouldn't block the rest of the chart — show it in the
|
||
// table itself rather than as a toast over the whole page.
|
||
const countEl = document.getElementById('demo_careplans');
|
||
if (countEl) countEl.textContent = '—';
|
||
tbody.innerHTML =
|
||
`<tr><td colspan="6" class="text-muted-ci text-center py-4">Could not load care plans: ${escapeHtml(err.message)}</td></tr>`;
|
||
}
|
||
}
|