DotNet_Capstone_Project/.Net Capstone Project/dashboard.js

1897 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

let currentPatientId = null;
// 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: '017', test: a => a < 18 },
{ label: '1829', test: a => a >= 18 && a < 30 },
{ label: '3044', test: a => a >= 30 && a < 45 },
{ label: '4559', test: a => a >= 45 && a < 60 },
{ label: '6074', 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 (010)', 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 &amp; 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>`;
}
}