// Generic engine for a "browse one resource type across all patients" page // (Conditions, Observations, Allergies, Medications, Encounters). Each page // just defines window.RESOURCE_PAGE_CONFIG and includes this script — no // per-page JS needed. See conditions.html for the config shape. let rpPatientsCache = []; let rpSearchTerm = ''; let rpAddModal, rpEditingRowId = null, rpEditingPatientId = null; function rpEscapeHtml(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function rpFmtDate(value) { if (!value) return '—'; const d = new Date(value); if (isNaN(d)) return '—'; return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } function rpShowToast(message, variant = 'success') { const container = document.getElementById('toastContainer'); if (!container) { alert(message); return; } const el = document.createElement('div'); el.className = `toast align-items-center text-bg-${variant === 'danger' ? 'danger' : 'success'} border-0 show mb-2`; el.innerHTML = `
${rpEscapeHtml(message)}
`; container.appendChild(el); setTimeout(() => el.remove(), 5000); } function rpFieldInputHtml(field, value) { const id = `rp_field_${field.key}`; const val = value !== undefined && value !== null ? value : (field.default || ''); if (field.type === 'select') { return `
`; } if (field.type === 'checkbox') { const checked = value === true || value === 'true'; return `
`; } if (field.type === 'textarea') { return `
`; } const inputVal = field.type === 'date' && val ? String(val).slice(0, 10) : field.type === 'datetime-local' && val ? String(val).slice(0, 16) : val; return `
`; } async function rpLoadPatientsForPicker() { try { rpPatientsCache = await Api.getPatients(); const select = document.getElementById('rp_patientSelect'); if (select) { select.innerHTML = rpPatientsCache.map(p => ``).join(''); } } catch { /* patient picker just won't populate; add flow will fail loudly instead */ } } function rpOpenAddModal() { rpEditingRowId = null; rpEditingPatientId = null; const config = window.RESOURCE_PAGE_CONFIG; document.getElementById('rp_modalTitle').textContent = `Add ${config.title.replace(/s$/, '')}`; document.getElementById('rp_patientPickerRow').classList.remove('d-none'); document.getElementById('rp_fieldsBody').innerHTML = config.fields.map(f => rpFieldInputHtml(f, null)).join(''); rpAddModal.show(); } function rpOpenEditModal(row) { rpEditingRowId = row.id; rpEditingPatientId = row.patientId; const config = window.RESOURCE_PAGE_CONFIG; document.getElementById('rp_modalTitle').textContent = `Edit ${config.title.replace(/s$/, '')} — ${row.patientName}`; document.getElementById('rp_patientPickerRow').classList.add('d-none'); document.getElementById('rp_fieldsBody').innerHTML = config.fields.map(f => rpFieldInputHtml(f, row[f.key])).join(''); rpAddModal.show(); } async function rpSubmitModal(e) { e.preventDefault(); const config = window.RESOURCE_PAGE_CONFIG; const payload = {}; for (const f of config.fields) { const el = document.getElementById(`rp_field_${f.key}`); let v; if (f.type === 'checkbox') { v = el.checked; } else { v = el.value; if (f.type === 'number') v = v === '' ? null : parseFloat(v); v = v === '' ? null : v; } payload[f.key] = v; } try { if (rpEditingRowId) { await config.update(rpEditingPatientId, rpEditingRowId, payload); rpShowToast(`${config.title} updated.`); } else { const patientId = document.getElementById('rp_patientSelect').value; if (!patientId) { rpShowToast('Select a patient first.', 'danger'); return; } await config.add(patientId, payload); rpShowToast(`${config.title} added.`); } rpAddModal.hide(); await rpLoadGrid(); } catch (err) { rpShowToast(err.message, 'danger'); } } async function rpDeleteRow(row) { const config = window.RESOURCE_PAGE_CONFIG; if (!confirm(`Delete this ${config.title.replace(/s$/, '').toLowerCase()} for ${row.patientName}?`)) return; try { await config.remove(row.patientId, row.id); rpShowToast('Deleted.'); await rpLoadGrid(); // The row's Delete button lives inside the popup modal — refresh (or // close, if that was their last record) so it doesn't show stale data. const container = document.getElementById('rp_gridBody'); const stillHasRows = (container._rpRows || []).some(r => r.patientId === row.patientId); if (stillHasRows) rpOpenViewModal(row.patientId); else if (rpViewModalInstance) rpViewModalInstance.hide(); } catch (err) { rpShowToast(err.message, 'danger'); } } // Bootstrap "bg-*" classes cycled per tile so patients are visually easy to // tell apart at a glance — not tied to any data meaning, just variety. const RP_TILE_COLORS = ['primary', 'success', 'info', 'warning', 'danger', 'secondary']; // The pages ship a ... wrapper (from // before this was a tile grid). Swap that whole table out for a plain grid // div, once, so none of the 7 HTML pages that use this engine need editing. function rpEnsureTileContainer() { const existing = document.getElementById('rp_gridBody'); if (existing.tagName.toLowerCase() === 'div') return existing; const table = existing.closest('table'); const div = document.createElement('div'); div.id = 'rp_gridBody'; div.className = 'rp-tile-grid'; table.replaceWith(div); return div; } let rpViewModalInstance = null; // The pages don't ship a "view details" modal in their HTML — build it once // and reuse it, same approach as rpEnsureTileContainer above. Scrollable so // patients with a lot of records don't blow out the page. function rpEnsureViewModal() { let el = document.getElementById('rp_viewModal'); if (el) return el; el = document.createElement('div'); el.className = 'modal fade'; el.id = 'rp_viewModal'; el.tabIndex = -1; el.innerHTML = ` `; document.body.appendChild(el); rpViewModalInstance = new bootstrap.Modal(el); return el; } function rpOpenViewModal(patientId) { const config = window.RESOURCE_PAGE_CONFIG; const container = document.getElementById('rp_gridBody'); const rows = (container._rpRows || []).filter(r => r.patientId === patientId); if (rows.length === 0) return; rpEnsureViewModal(); document.getElementById('rp_viewModalTitle').textContent = rows[0].patientName; const detailColumns = config.columns.filter(c => c.key !== 'patientName'); const detailRows = rows.map(row => { const cells = detailColumns.map(c => { if (c.render) return ``; let v = row[c.key]; v = c.type === 'date' ? rpFmtDate(v) : rpEscapeHtml(v ?? '—'); return ``; }).join(''); const actions = config.readOnly ? '' : ` `; return `${cells}${actions}`; }).join(''); document.getElementById('rp_viewModalBody').innerHTML = `
${c.render(row[c.key], row)}${v}
${detailColumns.map(c => ``).join('')}${config.readOnly ? '' : ''}${detailRows}
${rpEscapeHtml(c.label)}
`; rpViewModalInstance.show(); } async function rpLoadGrid() { const config = window.RESOURCE_PAGE_CONFIG; const container = rpEnsureTileContainer(); const empty = document.getElementById('rp_gridEmpty'); const countLabel = document.getElementById('rp_countLabel'); try { const rows = await config.listAll(rpSearchTerm.trim()); const patientCount = new Set(rows.map(r => r.patientId)).size; if (countLabel) countLabel.textContent = `${rows.length} record${rows.length === 1 ? '' : 's'} · ${patientCount} patient${patientCount === 1 ? '' : 's'}`; if (rows.length === 0) { container.innerHTML = ''; empty.classList.remove('d-none'); return; } empty.classList.add('d-none'); const groups = new Map(); rows.forEach(r => { if (!groups.has(r.patientId)) groups.set(r.patientId, { patientId: r.patientId, patientName: r.patientName, items: [] }); groups.get(r.patientId).items.push(r); }); container.innerHTML = [...groups.values()].map((g, i) => { const color = RP_TILE_COLORS[i % RP_TILE_COLORS.length]; const initials = g.patientName.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase(); return `
${rpEscapeHtml(g.patientName)}
${g.items.length} record${g.items.length === 1 ? '' : 's'}
`; }).join(''); // Stash rows for the View/Edit/Delete handlers (avoids a second fetch per click). container._rpRows = rows; // Lets a page compute something from the freshly-loaded rows (e.g. an // "X% actioned" stat) without the generic engine needing to know about // page-specific fields like "Actioned". if (typeof config.afterLoad === 'function') config.afterLoad(rows); } catch (err) { rpShowToast(err.message, 'danger'); } } function rpInit() { const config = window.RESOURCE_PAGE_CONFIG; if (!config) return; if (!sessionStorage.getItem('cip_token')) { window.location.href = 'login.html'; return; } renderSidebar(config.key); document.getElementById('rp_pageIcon').textContent = config.icon; document.getElementById('rp_pageTitle').textContent = config.title; document.title = `${config.title} — Clinical Insight Pro`; document.getElementById('clinicianLabel').textContent = `👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`; document.getElementById('logoutBtn').addEventListener('click', () => { sessionStorage.clear(); window.location.href = 'login.html'; }); if (config.readOnly) { document.getElementById('rp_addBtn').classList.add('d-none'); } // "View" opens a scrollable popup with that patient's records, instead of // expanding inline (which used to push the rest of the grid down). Works // on every page, including read-only ones (Risk Assessment, Care Plans). rpEnsureTileContainer().addEventListener('click', (e) => { const viewEl = e.target.closest('[data-view-patient]'); if (!viewEl) return; rpOpenViewModal(viewEl.dataset.viewPatient); }); if (!config.readOnly) { rpAddModal = new bootstrap.Modal(document.getElementById('rp_modal')); document.getElementById('rp_addBtn').addEventListener('click', rpOpenAddModal); document.getElementById('rp_form').addEventListener('submit', rpSubmitModal); rpLoadPatientsForPicker(); rpEnsureViewModal().addEventListener('click', (e) => { const editBtn = e.target.closest('[data-edit-row]'); const delBtn = e.target.closest('[data-delete-row]'); const tbody = document.getElementById('rp_gridBody'); if (editBtn) { const row = (tbody._rpRows || []).find(r => String(r.id) === editBtn.dataset.editRow && r.patientId === editBtn.dataset.editPatient); if (row) { rpViewModalInstance.hide(); rpOpenEditModal(row); } } else if (delBtn) { const row = (tbody._rpRows || []).find(r => String(r.id) === delBtn.dataset.deleteRow && r.patientId === delBtn.dataset.deletePatient); if (row) rpDeleteRow(row); } }); } document.getElementById('rp_searchInput').addEventListener('input', (e) => { rpSearchTerm = e.target.value; rpLoadGrid(); }); rpLoadGrid(); } document.addEventListener('DOMContentLoaded', rpInit);