diff --git a/.Net Capstone Project/dashboard.js b/.Net Capstone Project/dashboard.js new file mode 100644 index 0000000..d73f49f --- /dev/null +++ b/.Net Capstone Project/dashboard.js @@ -0,0 +1,1896 @@ +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)}`; + } +}