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 = `
${message}
`; 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 `
`; } if (f.type === 'textarea') { return `
`; } return `
`; }).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 ${escapeHtml(selected.fullName)}`; } 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} · Show all patients`; scopeLabel.classList.remove('d-none'); } else { scopeLabel.innerHTML = ''; scopeLabel.classList.add('d-none'); } } } function patientChipHtml(patientId, name) { return `👤 ${escapeHtml(name)}`; } // 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 => `${c}`).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 => `${cells.map(c => `${c}`).join('')}`).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 `
  • ${escapeHtml(label)}: ${value} (${pct}%)
  • `; } // ---------- 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 = '' + monthNames.map((m, i) => ``).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 = '' + years.map(y => ``).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) : '
  • No alerts in the selected period.
  • '; } // ---------- 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 '
    No data.
    '; const max = Math.max(...rows.map(r => r.value), 1); return rows.map(r => `
    ${escapeHtml(r.label)}
    ${r.value}
    `).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('') : `
  • No patients in ${escapeHtml(periodLabel(period))}.
  • `; // ---- 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) : `
    No patients in ${escapeHtml(periodLabel(period))}.
    `; // ---- 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) : `
    No patients in ${escapeHtml(periodLabel(period))}.
    `; // ---- 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) : `
    No encounters recorded${year ? ` in ${year}` : ''}.
    `; } // ---------- 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 = `
    Could not load history: ${escapeHtml(err.message)}
    `; 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 => `
  • ${k}: ${counts[k]} of ${rows.length}
  • `).join('') : '
  • No assessments recorded yet.
  • '; listEl.innerHTML = rows.length ? rows.map(r => `
    ${r.score}%
    ${escapeHtml(r.level)}
    Assessed ${fmtDate(r.occurrenceDateTime)}
    ${r.factors.length ? `` : '
    No contributing factors recorded.
    '}
    `).join('') : '
    No assessments recorded yet.
    '; } // ---------- 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= 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 => `
    ${escapeHtml(p.fullName)}
    ID: ${escapeHtml(p.id)}
    MRN: ${escapeHtml(p.medicalRecordNumber)}
    `).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 => `
    ${escapeHtml(c.display)}
    ${escapeHtml(c.code)} · ${escapeHtml(c.clinicalStatus)} · ${escapeHtml(c.severity)} · onset ${fmtDate(c.onsetDate)}
    `).join('') : '
    No active conditions on file.
    '; document.getElementById('allergiesList').innerHTML = allergies.length ? allergies.map(a => `
    ${escapeHtml(capitalise(a.type))}
    ${escapeHtml(a.clinicalStatus)} · ${escapeHtml(a.verificationStatus)} · criticality: ${escapeHtml(a.criticality || 'not assessed')}
    `).join('') : '
    No known allergies (NKA).
    '; document.getElementById('medicationsList').innerHTML = medications.length ? medications.map(m => `
    ${escapeHtml(m.medicationDisplay || m.medicationCode)}
    ${escapeHtml(m.medicationCode)} · ${escapeHtml(m.status)} · ${escapeHtml(m.intent)}${m.priority ? ` · ${escapeHtml(m.priority)}` : ''}
    `).join('') : '
    No active medications.
    '; document.getElementById('observationsList').innerHTML = observations.length ? observations.map(o => `
    ${escapeHtml(o.display || o.code)}${o.interpretationDisplay ? ` ${escapeHtml(o.interpretationDisplay)}` : ''}
    ${escapeHtml(o.noteText || o.categoryDisplay || '')}
    ${fmtDate(o.effectiveDate)}
    `).join('') : '
    No recent observations.
    '; document.getElementById('encountersList').innerHTML = encounters.length ? encounters.map(e => `
    ${escapeHtml(e.classDisplay)}${e.typeDisplay ? ` — ${escapeHtml(e.typeDisplay)}` : ''}
    ${fmtDate(e.periodStart)} → ${fmtDate(e.periodEnd)} · ${escapeHtml(e.status)}
    `).join('') : '
    No encounters on file.
    '; } 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 => `
    ${escapeHtml(g.name)}
    ${g.items.map(q => riskFieldHtml(q)).join('')}
    `).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 `
    `; } if (q.type === 'number') { return `
    `; } if (q.type === 'range') { return `
    0
    `; } 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 => `
  • ${escapeHtml(f)}
  • `).join('') || '
  • No contributing factors recorded.
  • '; } // ---------- CDS ---------- function resetCdsTab() { document.getElementById('cdsAlertsContainer').innerHTML = '
    Select a patient to see their alerts.
    '; 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 = '
    Nothing triggered — no CDS alerts for this patient.
    '; return; } container.innerHTML = alerts.map(a => { const severity = (a.severity || 'info').toLowerCase(); const weight = SEVERITY_WEIGHT[severity] ?? 30; return `
    ${escapeHtml(severity)} ${a.actioned ? 'Actioned' : `${escapeHtml(a.ruleId || '')}`}
    ${weight}%
    ${escapeHtml(a.summary)}
    ${escapeHtml(a.recommendation)}
    Generated ${fmtDate(a.generatedDate)}
    ${a.actioned ? '' : ` `}
    `; }).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 => ` ${escapeHtml(cp.status)} ${escapeHtml(cp.intent)} ${escapeHtml(cp.activityStatus || '—')} ${fmtDate(cp.periodStart)} → ${fmtDate(cp.periodEnd)} ${escapeHtml(cp.performerName || '—')} ${fmtDate(cp.createdDate)} `).join('') : 'No care plans yet.'; } 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 = `Could not load care plans: ${escapeHtml(err.message)}`; } }