`).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 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)}
';
+ } 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)}