// 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, rpDetailModal, 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 === '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 */ } } // Fills the three reference dropdowns on the Practitioner Role form. `row` is // passed on Edit so the record's current practitioner / organization / // location come back selected instead of resetting to the first option. async function rpLoadPractitionerRoleDropdowns(row = null) { const look = await rpEnsureLookups(true); const fill = (fieldKey, list, labelFn, selected) => { const el = document.getElementById(`rp_field_${fieldKey}`); if (!el || el.tagName !== 'SELECT') return; el.innerHTML = '' + list.map(item => { const id = item.id ?? item.Id ?? ''; const label = labelFn(item) || id; const isSel = selected && String(selected) === String(id) ? ' selected' : ''; return ``; }).join(''); if (selected) el.value = String(selected); }; fill('practitionerId', look.practitioners, rpPractitionerLabel, row?.practitionerId); fill('organizationId', look.organizations, o => o.name, row?.organizationId); fill('locationId', look.locations, l => l.name, row?.locationId); } // A short banner at the top of the form: icon, what the resource is, and how // the score is worked out for risk assessments. Injected once and reused. const RP_FORM_INTRO = { riskassessments: { icon: '📊', title: 'Clinical risk assessment', body: 'Tick the factors that apply. The percentage and risk level are calculated from their weights, and the note is written for you.' }, servicerequests: { icon: '📄', title: 'Service request', body: 'The identifier is generated automatically from the resource name and patient id.' }, careplans: { icon: '📋', title: 'Care plan', body: 'Period covers the whole plan; the activity schedule covers the next step.' }, conditions: { icon: '🩺', title: 'Condition', body: 'Use an ICD-10 code (e.g. E11.9) and its display name.' }, observations: { icon: '📈', title: 'Observation', body: 'Use a LOINC code (e.g. 8480-6). Put the measured value in the note.' } }; function rpRenderFormIntro(config, mode) { const host = document.getElementById('rp_formIntro'); if (!host) return; const meta = RP_FORM_INTRO[config.key]; if (!meta) { host.innerHTML = ''; host.classList.add('d-none'); return; } host.classList.remove('d-none'); host.innerHTML = `
${meta.icon}
${rpEscapeHtml(meta.title)}
${meta.body}
`; } async function rpOpenAddModal() { rpEditingRowId = null; rpEditingPatientId = null; const config = window.RESOURCE_PAGE_CONFIG; document.getElementById('rp_modalTitle').textContent = `Add ${config.title.replace(/s$/, '')}`; rpRenderFormIntro(config, 'add'); // Resources that don't hang off a patient (organizations, locations, // practitioners, CDS rules, users) shouldn't show a patient dropdown at all. document.getElementById('rp_patientPickerRow') .classList.toggle('d-none', rpIsStandalone(config)); document.getElementById('rp_fieldsBody').innerHTML = config.fields .filter(f => !RP_AUTO_FIELDS.includes(f.key)) .filter(f => !(rpNormKey(config.key) === 'riskassessment' && f.key === 'noteText')) .map(f => rpFieldInputHtml(f, null)).join('') + (rpNormKey(config.key) === 'riskassessment' ? rpRiskFactorsHtml() : ''); if (rpNormKey(config.key) === 'riskassessment') { rpRiskPatientId = null; // Add mode follows the dropdown rpWireRiskForm(); } if (rpNormKey(config.key) === 'practitionerrole') { try { await rpLoadPractitionerRoleDropdowns(); } catch (err) { rpShowToast(`Could not load reference lists: ${err.message}`, 'danger'); } } rpAddModal.show(); } // Keeps the live total in sync, and re-renders the reproductive questions // when a different patient is picked (they only apply to female patients). function rpWireRiskForm() { document.querySelectorAll('.rp-risk-check') .forEach(el => el.addEventListener('change', rpUpdateRiskPreview)); document.getElementById('rp_riskFreeText') ?.addEventListener('input', rpUpdateRiskPreview); const picker = document.getElementById('rp_patientSelect'); if (picker && !picker.dataset.riskWired) { picker.dataset.riskWired = '1'; picker.addEventListener('change', () => { if (window.RESOURCE_PAGE_CONFIG?.key !== 'riskassessments') return; // Preserve what's already ticked across the re-render. const checked = [...document.querySelectorAll('.rp-risk-check:checked')] .map(el => el.dataset.label); const extra = document.getElementById('rp_riskFreeText')?.value || ''; const host = document.getElementById('rp_riskScorePreview')?.closest('.col-12'); if (!host) return; // Rebuild just the questionnaire portion. const fields = document.getElementById('rp_fieldsBody'); const keep = [...fields.children].filter(c => !c.querySelector('.rp-risk-check, #rp_riskScoreChip, #rp_riskFreeText')); fields.innerHTML = ''; keep.forEach(c => fields.appendChild(c)); fields.insertAdjacentHTML('beforeend', rpRiskFactorsHtml(checked, extra)); rpWireRiskForm(); }); } rpUpdateRiskPreview(); } async function rpOpenEditModal(row) { rpEditingRowId = row.id; rpEditingPatientId = rpRowPatientId(row); const config = window.RESOURCE_PAGE_CONFIG; const key = rpNormKey(config.key); // Standalone records are identified by their own name, not a patient's. const label = rpIsStandalone(config) ? rpStandaloneTitle(config, row, 0) : ''; document.getElementById('rp_modalTitle').textContent = `Edit ${config.title.replace(/s$/, '')}${label ? ` — ${label}` : ''}`; rpRenderFormIntro(config, 'edit'); document.getElementById('rp_patientPickerRow').classList.add('d-none'); if (key === 'riskassessment') rpRiskPatientId = rpRowPatientId(row); const split = key === 'riskassessment' ? rpSplitRiskNote(row.noteText) : null; document.getElementById('rp_fieldsBody').innerHTML = config.fields .filter(f => !RP_AUTO_FIELDS.includes(f.key)) .filter(f => !(key === 'riskassessment' && f.key === 'noteText')) .map(f => rpFieldInputHtml(f, row[f.key])).join('') + (split ? rpRiskFactorsHtml(split.known, split.other) : ''); if (key === 'riskassessment') rpWireRiskForm(); // Reference dropdowns have to be rebuilt after the fields are drawn, with // this record's values reselected. if (key === 'practitionerrole') { try { await rpLoadPractitionerRoleDropdowns(row); } catch (err) { rpShowToast(`Could not load reference lists: ${err.message}`, 'danger'); } } 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}`); console.log(f.key, el); if (!el) { // Auto-generated fields have no input; everything else is a real bug. if (!RP_AUTO_FIELDS.includes(f.key)) console.error(`Missing field: ${f.key}`); continue; } let v = el.value; if (f.type === 'number') v = v === '' ? null : parseFloat(v); if (f.type === 'boolean' || f.key === 'active' || f.key === 'enabled' || f.key === 'isActive') { v = v === 'true' || v === true; } payload[f.key] = v === '' ? null : v; } if (rpNormKey(config.key) === 'riskassessment') { // The note is assembled from the ticked factors so the weights are always // well-formed and the score parses correctly. payload.noteText = rpBuildRiskNote(); } try { if (config.key === 'conditions') { const practitioners = await Api.getPractitioners(); console.log("Practitioners:", practitioners); if (practitioners.length > 0) { payload.recorderId = practitioners[0].id; payload.asserterId = practitioners[0].id; } console.log("Payload after practitioner:", payload); payload.clinicalStatusDisplay = payload.clinicalStatusCode; payload.verificationStatusDisplay = payload.verificationStatusCode; payload.severityDisplay = payload.severityCode; } if (config.key === 'observations') { const practitioners = await Api.getPractitioners(); if (practitioners.length > 0) { payload.performerPractitionerId = practitioners[0].id; payload.noteAuthorPractitionerId = practitioners[0].id; } payload.noteTime = new Date().toISOString(); } const patientSelect = document.getElementById('rp_patientSelect'); const patientId = patientSelect ? patientSelect.value : null; if (config.key === 'observations') { console.log("Selected patientId:", patientId); console.log("Patients Cache:", rpPatientsCache); const practitioners = await Api.getPractitioners(); if (practitioners.length > 0) { payload.performerPractitionerId = practitioners[0].id; payload.noteAuthorPractitionerId = practitioners[0].id; } payload.noteTime = new Date().toISOString(); } if (config.key === 'allergies') { if (config.key === 'allergies') { payload.patientId = patientId; const practitioners = await Api.getPractitioners(); if (practitioners.length > 0) { payload.recorderPractitionerId = practitioners[0].id; } payload.asserterPatientId = patientId; } } if (config.key === 'servicerequests') { const practitioners = await Api.getPractitioners(); if (practitioners.length > 0) { payload.requesterPractitionerId = practitioners[0].id; payload.noteAuthorPractitionerId = practitioners[0].id; } payload.noteTime = new Date().toISOString(); payload.patientId = patientId; } if (config.key === 'riskassessments') { const practitioners = await Api.getPractitioners(); if (practitioners.length > 0) { payload.performerPractitionerId = practitioners[0].id; payload.noteAuthorPractitionerId = practitioners[0].id; } payload.noteTime = new Date().toISOString(); } if (config.key === 'careplans') { console.log("Selected Patient:", patientId); payload.subjectPatientId = patientId; payload.authorPatientId = patientId; const practitioners = await Api.getPractitioners(); if (practitioners.length > 0) { payload.activityPerformerPractitionerId = practitioners[0].id; } console.log("CAREPLAN PAYLOAD", payload); } // Identifiers are set on create only; editing keeps the original. if (!rpEditingRowId) { const picker = document.getElementById('rp_patientSelect'); const pid = (!rpIsStandalone(config) && picker) ? picker.value : null; const ids = rpBuildIdentifiers(config, pid); if (config.fields.some(f => f.key === 'identifierSystem')) { payload.identifierSystem = ids.identifierSystem; } if (config.fields.some(f => f.key === 'identifierValue')) { payload.identifierValue = ids.identifierValue; } } if (rpEditingRowId) { await config.update(rpEditingPatientId, rpEditingRowId, payload); rpShowToast(`${config.title} updated.`); } else { const patientSelect = document.getElementById('rp_patientSelect'); const patientId = patientSelect ? patientSelect.value : null; if (config.key === 'allergies') { const practitioners = await Api.getPractitioners(); if (practitioners.length > 0) { payload.recorderPractitionerId = practitioners[0].id; } payload.asserterPatientId = patientId; } if ( !rpIsStandalone(config) && !RP_STANDALONE_KEYS.includes(rpNormKey(config.key)) && !patientId ) { rpShowToast('Select a patient first.', 'danger'); return; } // Standalone resources have no patient dropdown — sending '' as the id // makes the API build a URL like /patients//organizations. const ownerId = rpIsStandalone(config) ? null : (patientId || null); await config.add(ownerId, 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; const noun = config.title.replace(/s$/, '').toLowerCase(); // "…for undefined" is what this used to say on a standalone record, which // has no patient. Name the record itself instead. const subject = rpIsStandalone(config) ? `"${rpStandaloneTitle(config, row, 0)}"` : `for ${row.patientName || 'this patient'}`; if (!confirm(`Delete this ${noun} ${subject}?`)) return; try { await config.remove( rpRowPatientId(row), row.id ); rpShowToast('Deleted.'); await rpLoadGrid(); } catch (err) { rpShowToast(err.message, 'danger'); } } // ---------- Card rendering ---------- // Rows are shown as avatar cards with a count and a View button instead of a // flat table. Patient-scoped resources group every row for one patient into a // single card; standalone resources (organizations, practitioners, ...) get // one card each. The old table is kept in the DOM but hidden, so the existing // markup on every page still works untouched. // The card styles are injected from here rather than relying on // css/styles.css. Every previous round showed the external stylesheet not // being picked up (avatars rendered as full-width bars because .rp-avatar // had no size), so the layout now ships with the script that draws it. const RP_CARD_STYLES = ` .rp-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; align-items: start; } .rp-card { background: #fff; border: 1px solid #e3e9e9; border-radius: 14px; padding: 1.4rem 1rem 1.2rem; display: flex; flex-direction: column; align-items: center; text-align: center; gap: .7rem; box-shadow: 0 1px 3px rgba(16,40,40,.06); transition: box-shadow .12s ease, transform .12s ease; } .rp-card:hover { box-shadow: 0 6px 18px rgba(16,40,40,.12); transform: translateY(-2px); } .rp-avatar { width: 64px; height: 64px; min-width: 64px; min-height: 64px; flex: 0 0 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 1.15rem; letter-spacing: .5px; line-height: 1; } .rp-card-name { font-weight: 600; font-size: 1rem; color: #1f2d2d; line-height: 1.25; max-width: 100%; word-break: break-word; } .rp-card-badge { font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; border-radius: 999px; padding: .2rem .7rem; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: #eef3f3; color: #5f7373; } .rp-card-badge.is-high { background: #fdecea; color: #c0392b; } .rp-card-badge.is-warning { background: #fdf3e3; color: #b9770e; } .rp-card-badge.is-normal { background: #eafaf1; color: #1e8449; } .rp-card-badge.is-info { background: #e8f2fb; color: #2471a3; } .rp-card-sub { font-size: .78rem; color: #5f7373; } .rp-card-actions { display: flex; gap: .35rem; flex-wrap: wrap; justify-content: center; } .rp-card-view { display: inline-block; font-size: .85rem; padding: .3rem 1rem; border: 1px solid #0f6e6e; color: #0f6e6e; background: #fff; border-radius: 8px; cursor: pointer; text-decoration: none; line-height: 1.4; } .rp-card-view:hover { background: #0f6e6e; color: #fff !important; } /* --- Risk score chips (grid + detail table) --- */ .rp-score-chip { display: inline-block; min-width: 52px; text-align: center; font-weight: 700; font-size: .95rem; padding: .25rem .55rem; border-radius: 8px; line-height: 1.2; } .rp-score-chip.is-high { background: #fdecea; color: #c0392b; } .rp-score-chip.is-moderate { background: #fdf3e3; color: #b9770e; } .rp-score-chip.is-low { background: #eafaf1; color: #1e8449; } .rp-score-level { font-size: .68rem; text-transform: uppercase; letter-spacing: .5px; color: #5f7373; margin-top: .2rem; text-align: center; } /* --- Single-record detail panel (organizations, locations, practitioners) --- */ .rp-detail-head { display: flex; align-items: center; gap: .9rem; padding-bottom: .9rem; margin-bottom: .9rem; border-bottom: 1px solid #eef3f3; } .rp-detail-avatar { width: 52px; height: 52px; min-width: 52px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 1rem; line-height: 1; } .rp-detail-name { font-weight: 700; font-size: 1.05rem; color: #1f2d2d; } .rp-detail-sub { font-size: .74rem; text-transform: uppercase; letter-spacing: .5px; color: #5f7373; margin-top: .15rem; } .rp-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: .75rem 1.25rem; margin: 0; } .rp-detail-item { border-bottom: 1px solid #f2f6f6; padding-bottom: .5rem; } .rp-detail-item dt { font-size: .7rem; text-transform: uppercase; letter-spacing: .5px; font-weight: 600; color: #5f7373; margin-bottom: .15rem; } .rp-detail-item dd { margin: 0; font-size: .92rem; color: #1f2d2d; word-break: break-word; } .rp-detail-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: 1.1rem; padding-top: .9rem; border-top: 1px solid #eef3f3; } /* --- Notes rendered as bullets instead of one long line --- */ .rp-note-list { margin: 0; padding-left: 1.05rem; font-size: .85rem; } .rp-note-list li { margin-bottom: .15rem; } /* --- Add / Edit form chrome --- */ #rp_modal .modal-header { background: linear-gradient(135deg, #0a4f4f, #0f6e6e); color: #fff; border-bottom: none; } #rp_modal .modal-header .modal-title { font-weight: 700; } #rp_modal .modal-header .btn-close { filter: invert(1) grayscale(1) brightness(2); } #rp_modal .modal-body { padding-top: 1.1rem; } #rp_modal .form-label { font-size: .74rem; text-transform: uppercase; letter-spacing: .5px; font-weight: 600; color: #5f7373; margin-bottom: .25rem; } #rp_modal .form-control, #rp_modal .form-select { border-radius: 9px; border-color: #dfe7e7; padding: .5rem .75rem; } #rp_modal .form-control:focus, #rp_modal .form-select:focus { border-color: #0f6e6e; box-shadow: 0 0 0 .18rem rgba(15,110,110,.15); } #rp_modal .modal-footer { border-top: 1px solid #eef3f3; } .rp-form-intro { display: flex; gap: .8rem; align-items: flex-start; background: #f2f8f8; border: 1px solid #d9e8e8; border-radius: 12px; padding: .8rem .9rem; margin-bottom: 1rem; } .rp-form-intro-icon { font-size: 1.5rem; line-height: 1; } .rp-form-intro-title { font-weight: 700; color: #0a4f4f; margin-bottom: .15rem; } .rp-form-intro-body { font-size: .82rem; color: #5f7373; line-height: 1.45; } /* --- Risk factor questionnaire inside the Add/Edit form --- */ .rp-risk-score { display: flex; align-items: center; gap: .6rem; background: #f2f8f8; border: 1px solid #d9e8e8; border-radius: 10px; padding: .55rem .8rem; margin-bottom: .3rem; } .rp-risk-score-label { font-weight: 600; color: #0a4f4f; font-size: .9rem; } .rp-risk-group { font-size: .74rem; text-transform: uppercase; letter-spacing: .5px; font-weight: 700; color: #0a4f4f; margin: .6rem 0 .35rem; } .rp-risk-item { display: flex; align-items: center; gap: .6rem; padding: .45rem .6rem; border: 1px solid #e6ecec; border-radius: 9px; margin-bottom: .35rem; cursor: pointer; font-size: .9rem; transition: background .1s ease, border-color .1s ease; } .rp-risk-item:hover { background: #f6fafa; border-color: #cfe0e0; } .rp-risk-item input { margin: 0; flex-shrink: 0; } .rp-risk-item span:first-of-type { flex: 1; } .rp-risk-weight { font-size: .74rem; font-weight: 700; color: #5f7373; background: #eef3f3; border-radius: 999px; padding: .1rem .45rem; } .rp-risk-item input:checked ~ .rp-risk-weight { background: #d9ece4; color: #1e8449; } .rp-risk-hint { font-size: .76rem; color: #5f7373; margin-top: .3rem; line-height: 1.45; } .rp-risk-hint code { background: #f2f8f8; border: 1px solid #dfe7e7; border-radius: 4px; padding: .02rem .25rem; color: #0f6e6e; } .rp-form-intro-body code { background: #fff; border: 1px solid #dfe7e7; border-radius: 5px; padding: .05rem .3rem; color: #0f6e6e; font-size: .78rem; } /* Long add-forms (Practitioner, Location, Encounter) must scroll inside the dialog instead of running off the bottom of the screen. */ #rp_modal .modal-content, #rp_detailModal .modal-content { max-height: 90vh; } #rp_modal .modal-body, #rp_detailModal .modal-body { max-height: 70vh; overflow-y: auto; } #rp_modal .modal-body::-webkit-scrollbar, #rp_detailModal .modal-body::-webkit-scrollbar { width: 12px; } #rp_modal .modal-body::-webkit-scrollbar-track, #rp_detailModal .modal-body::-webkit-scrollbar-track { background: #e3e9e9; border-radius: 6px; } #rp_modal .modal-body::-webkit-scrollbar-thumb, #rp_detailModal .modal-body::-webkit-scrollbar-thumb { background: #1a1a1a; border-radius: 6px; border: 2px solid #e3e9e9; } #rp_modal .modal-body, #rp_detailModal .modal-body { scrollbar-width: thin; scrollbar-color: #1a1a1a #e3e9e9; } `; function rpInjectCardStyles() { if (document.getElementById('rp-card-styles')) return; const style = document.createElement('style'); style.id = 'rp-card-styles'; style.textContent = RP_CARD_STYLES; document.head.appendChild(style); } // Colour the badge from its own text, the way a status pill should read. function rpBadgeClass(text) { const t = (text || '').toLowerCase(); if (/high|critical|severe|urgent|stat/.test(t)) return 'is-high'; if (/warning|moderate|on-hold|pending/.test(t)) return 'is-warning'; if (/normal|low|active|final|completed|confirmed/.test(t)) return 'is-normal'; if (/info/.test(t)) return 'is-info'; return ''; } const RP_AVATAR_COLOURS = [ '#2e86ab', '#1e8449', '#d68910', '#7d3c98', '#c0392b', '#117864', '#a04000', '#5499c7', '#b7950b', '#34495e' ]; function rpInitials(name) { return (name || '') .split(' ') .filter(Boolean) .filter(part => !/^(dr|mr|mrs|ms|miss)\.?$/i.test(part)) .slice(0, 2) .map(p => p[0].toUpperCase()) .join('') || '?'; } // Same name always gets the same colour. function rpAvatarColour(name) { let hash = 0; for (const ch of (name || '')) hash = (hash * 31 + ch.charCodeAt(0)) % 100000; return RP_AVATAR_COLOURS[hash % RP_AVATAR_COLOURS.length]; } // The badge under the name: whatever the config nominates, otherwise the // first column that isn't the patient's name. function rpCardBadge(config, rows, title) { if (config.cardBadge) return config.cardBadge(rows); // Risk assessments show the patient's highest score, not just a status. if (rpNormKey(config.key) === 'riskassessment') { const best = rows .map(r => rpParseRiskNote(r.noteText).score) .reduce((a, b) => Math.max(a, b), 0); return `${rpRiskLevel(best)} · ${best}%`; } const row = rows[0]; // Standalone records: show a second, different detail — the name is already // the card title, so repeating it in the badge tells the user nothing. if (rpIsStandalone(config)) { const subFn = RP_CARD_SUBTITLE[rpNormKey(config.key)]; const sub = subFn ? subFn(row) : ''; if (sub) return sub; const col = (config.columns || []).find(c => c.key !== 'patientName' && row?.[c.key] !== null && row?.[c.key] !== undefined && row?.[c.key] !== '' && String(row[c.key]) !== String(title)); return col ? String(row[col.key]) : ''; } const col = config.columns.find(c => c.key !== 'patientName'); if (!col) return ''; const value = row?.[col.key]; return value === null || value === undefined || value === '' ? '' : String(value); } // ---------- Identifier generation ---------- // IdentifierSystem / IdentifierValue are [Required] on several models, so the // forms used to ask for them and a blank box meant "Validation failed". They // are generated here instead: // system = http://clinicalinsightspro.in// // value = - (e.g. SerReq-pat-002) // A suffix is added if that value is already taken, so values stay unique. const RP_IDENTIFIER_PREFIX = { conditions: 'Cond', observations: 'Obs', allergies: 'Alg', medications: 'Med', encounters: 'Enc', servicerequests: 'SerReq', riskassessments: 'Risk', careplans: 'CarePlan', cdsalerts: 'Alert', organizations: 'Org', locations: 'Loc', practitioners: 'Prac', practitionerroles: 'PracRole', cdsrules: 'Rule' }; // Fields the user should never have to fill in. const RP_AUTO_FIELDS = ['identifierSystem', 'identifierValue']; // ---------- Risk factor questionnaire ---------- // The note is what actually stores the factors, but typing // "Current smoker (+10); ..." by hand is error-prone — one wrong bracket and // the score comes out wrong. So the Add/Edit form asks the same tick-box // questions the dashboard does, and assembles the note from the answers. // Weights match RiskScoringService exactly, so both routes agree. const RP_RISK_FACTORS = [ { group: 'General health', label: 'Current smoker', weight: 10 }, { group: 'General health', label: 'Family history of heart disease', weight: 8 }, { group: 'General health', label: 'Prior hospitalization', weight: 12 }, { group: 'General health', label: 'Resting heart rate over 100 bpm', weight: 8 }, { group: 'General health', label: 'BMI 30 or above', weight: 8 }, { group: 'General health', label: 'Pain score 7 or above', weight: 5 }, { group: 'Reproductive health', label: 'Currently pregnant', weight: 6, femaleOnly: true }, { group: 'Reproductive health', label: 'Currently breastfeeding', weight: 4, femaleOnly: true }, { group: 'Conditions & medications', label: 'Active condition reported as not well controlled', weight: 10 }, { group: 'Conditions & medications', label: 'New chronic condition diagnosed in the past 6 months', weight: 10 }, { group: 'Conditions & medications', label: 'Reported missed medication doses', weight: 8 }, { group: 'Conditions & medications', label: 'History of severe/anaphylactic allergic reaction', weight: 10 } ]; // Gender of whoever is picked in the patient dropdown, so the reproductive // questions only appear for female patients — same rule as the dashboard. // Whose gender gates the reproductive questions. On Add that's the dropdown; // on Edit the dropdown is hidden and stale, so the row's own patient wins. let rpRiskPatientId = null; function rpSelectedGender() { const id = rpRiskPatientId || document.getElementById('rp_patientSelect')?.value || null; if (!id) return ''; const patient = rpPatientsCache.find(p => p.id === id); return (patient?.gender || '').toLowerCase(); } function rpRiskFactorsHtml(checkedLabels = [], freeText = '') { const gender = rpSelectedGender(); const visible = RP_RISK_FACTORS.filter(f => !f.femaleOnly || gender === 'female'); const groups = []; visible.forEach(f => { let g = groups.find(x => x.name === f.group); if (!g) { g = { name: f.group, items: [] }; groups.push(g); } g.items.push(f); }); return `
0% Low risk
${groups.map(g => `
${rpEscapeHtml(g.name)}
${g.items.map(f => { const id = `rp_risk_${f.label.replace(/[^a-z0-9]/gi, '_')}`; const checked = checkedLabels.includes(f.label) ? 'checked' : ''; return ``; }).join('')}
`).join('')}
Factors the rules engine derived from the chart land here. Any (+n) weight in this box is counted in the score above.
`; } // Live total as boxes are ticked. function rpUpdateRiskPreview() { const chip = document.getElementById('rp_riskScoreChip'); if (!chip) return; let score = 0; document.querySelectorAll('.rp-risk-check:checked') .forEach(el => { score += Number(el.dataset.weight || 0); }); // Weights can also sit in the free-text box — the rules engine adds factors // the form has no box for, such as "3 active condition(s) on chart (+15)". // Counting them keeps the preview equal to what the grid will show. const extra = document.getElementById('rp_riskFreeText')?.value || ''; extra.split(';').forEach(part => { const m = part.match(/\(\+(\d+(?:\.\d+)?)\)/); if (m) score += parseFloat(m[1]); }); score = Math.min(Math.round(score), 100); const level = rpRiskLevel(score); chip.textContent = `${score}%`; chip.className = `rp-score-chip is-${level.toLowerCase()}`; document.getElementById('rp_riskScoreLabel').textContent = `${level} risk`; } // Turns the ticked boxes back into the note the API stores. function rpBuildRiskNote() { const parts = []; document.querySelectorAll('.rp-risk-check:checked').forEach(el => { parts.push(`${el.dataset.label} (+${el.dataset.weight})`); }); const extra = document.getElementById('rp_riskFreeText')?.value.trim(); if (extra) parts.push(extra); return parts.join('; '); } // Splits an existing note back into ticked boxes + leftover free text, so // editing an assessment doesn't lose anything that wasn't a known factor. function rpSplitRiskNote(noteText) { const parts = (noteText || '').split(';').map(s => s.trim()).filter(Boolean); const known = []; const other = []; parts.forEach(part => { const match = RP_RISK_FACTORS.find(f => part.toLowerCase().startsWith(f.label.toLowerCase())); if (match) known.push(match.label); else other.push(part); }); return { known, other: other.join('; ') }; } // ---------- Risk scoring ---------- // RiskAssessment has no score column; the contributing factors are stored as // one semicolon-joined note ("Current smoker (+10); Prior hospitalization // (+12)"). The percentage is the sum of those weights, capped at 100 — the // same rule the dashboard's Assessment History uses, so both agree. function rpParseRiskNote(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 rpRiskLevel(score) { if (score >= 50) return 'High'; if (score >= 25) return 'Moderate'; return 'Low'; } let rpAllRows = []; function rpBuildIdentifiers(config, patientId) { const prefix = RP_IDENTIFIER_PREFIX[config.key] || config.key; // Running number = one past the highest already in use for this resource. const seq = rpAllRows.length + 1; const system = `http://clinicalinsightspro.in/${config.key}/${String(seq).padStart(4, '0')}`; const base = patientId ? `${prefix}-${patientId}` : `${prefix}-${String(seq).padStart(4, '0')}`; // Guarantee uniqueness against what's already loaded. const taken = new Set(rpAllRows.map(r => r.identifierValue).filter(Boolean)); let value = base; let n = 2; while (taken.has(value)) value = `${base}-${n++}`; return { identifierSystem: system, identifierValue: value }; } // Resources that don't belong to a patient. Their pages don't set // `standalone: true` in RESOURCE_PAGE_CONFIG, so the key list is what makes // them render one card per record instead of one lumped "unassigned" card. // // Keys are compared after normalising (lower-cased, letters only, trailing // "s" dropped), so 'Organizations', 'organization', 'practitioner-roles' and // 'practitionerRoles' all match. The old exact-string comparison is why an // Organizations page whose config.key was spelled differently fell through to // the patient branch and rendered a single "unassigned" card. const RP_STANDALONE_KEYS = [ 'organization', 'location', 'practitioner', 'practitionerrole', 'cdsrule', 'user', 'device', 'healthcareservice' ]; // Normalised comparison key: 'PractitionerRoles' -> 'practitionerrole'. function rpNormKey(value) { return String(value || '') .toLowerCase() .replace(/[^a-z]/g, '') .replace(/s$/, ''); } // Any field that would tie a row to a patient. If a row has none of these, // there is nothing to group by and the record must stand on its own. const RP_PATIENT_LINK_KEYS = [ 'patientId', 'patientID', 'PatientId', 'subjectPatientId', 'asserterPatientId', 'authorPatientId', 'patientName' ]; function rpRowPatientId(row) { if (!row) return null; return row.patientId || row.PatientId || row.patientID || row.subjectPatientId || null; } function rpRowHasPatient(row) { return !!row && RP_PATIENT_LINK_KEYS.some(k => row[k]); } function rpIsStandalone(config, rows) { if (!config) return false; if (config.standalone === true) return true; if (config.standalone === false) return false; const key = rpNormKey(config.key); if (RP_STANDALONE_KEYS.includes(key)) return true; // Title is checked too, in case the page's key is something bespoke. if (RP_STANDALONE_KEYS.includes(rpNormKey(config.title))) return true; // Last resort: the page's own filename (organizations.html -> organization). const page = rpNormKey((window.location.pathname.split('/').pop() || '').replace(/\.html?$/i, '')); if (page && RP_STANDALONE_KEYS.includes(page)) return true; // Data-driven fallback: no row is linked to a patient => nothing to group by. const sample = Array.isArray(rows) ? rows : rpAllRows; if (Array.isArray(sample) && sample.length > 0) { return !sample.some(rpRowHasPatient); } return false; } // ---------- Reference lookups ---------- // Practitioner roles and locations store ids, not names. The cards and the // detail panel resolve those ids once per page load so a card reads // "Dr. Meera Joshi — Cardiology", not a bare GUID. let rpLookups = { loaded: false, practitioners: [], organizations: [], locations: [] }; async function rpSafeList(name) { try { if (!window.Api || typeof Api[name] !== 'function') return []; const result = await Api[name](); return Array.isArray(result) ? result : []; } catch { return []; } } async function rpEnsureLookups(force = false) { if (rpLookups.loaded && !force) return rpLookups; const [practitioners, organizations, locations] = await Promise.all([ rpSafeList('getPractitioners'), rpSafeList('getOrganizations'), rpSafeList('getLocations') ]); rpLookups = { loaded: true, practitioners, organizations, locations }; return rpLookups; } function rpPractitionerLabel(p) { if (!p) return ''; return [p.prefix, p.givenName, p.familyName].filter(Boolean).join(' ') || p.name || p.fullName || ''; } function rpLookupName(list, id, labelFn) { if (!id) return ''; const hit = (list || []).find(x => String(x.id ?? x.Id) === String(id)); return hit ? (labelFn ? labelFn(hit) : (hit.name || '')) : ''; } // Fills in the display names a row is missing, without overwriting anything // the API already sent. async function rpDecorateRows(config, rows) { const key = rpNormKey(config.key); if (!Array.isArray(rows) || !rows.length) return rows; if (!['practitionerrole', 'location', 'organization'].includes(key)) return rows; const look = await rpEnsureLookups(); rows.forEach(r => { if (!r) return; if (!r.practitionerName) { r.practitionerName = rpLookupName(look.practitioners, r.practitionerId || r.practitionerID, rpPractitionerLabel); } if (!r.organizationName) { r.organizationName = rpLookupName(look.organizations, r.organizationId || r.managingOrganizationId || r.partOfId); } if (!r.locationName) { r.locationName = rpLookupName(look.locations, r.locationId); } }); return rows; } // What to put on the card for a standalone record — the first column alone // is often not the useful label (a practitioner's card should read // "Dr. Meera Joshi", not just "Meera"). const RP_CARD_TITLE = { practitioner: r => rpPractitionerLabel(r), practitionerrole: r => r.practitionerName || r.roleDisplay || r.roleText || r.specialtyDisplay || r.code || '', organization: r => r.name || r.organizationName || r.alias || '', location: r => r.name || r.locationName || r.alias || '', cdsrule: r => r.ruleId || r.name || r.title || '', user: r => [r.firstName, r.lastName].filter(Boolean).join(' ') || r.username || '' }; // Generic title: the first field that actually reads like a name. const RP_TITLE_FALLBACK_KEYS = ['name', 'displayName', 'title', 'fullName', 'label', 'code']; function rpStandaloneTitle(config, row, index) { const fn = RP_CARD_TITLE[rpNormKey(config.key)]; const fromFn = fn ? fn(row) : ''; if (fromFn) return fromFn; for (const k of RP_TITLE_FALLBACK_KEYS) { if (row[k]) return String(row[k]); } const firstCol = config.columns?.find(c => c.key !== 'patientName'); if (firstCol && row[firstCol.key]) return String(row[firstCol.key]); return `${config.title.replace(/s$/, '')} ${index + 1}`; } // The line under the name on a standalone card — the second useful column, // never a repeat of the title itself. const RP_CARD_SUBTITLE = { practitioner: r => r.qualificationDisplay || r.specialtyDisplay || r.gender || '', practitionerrole: r => [r.roleDisplay || r.specialtyDisplay, r.organizationName] .filter(Boolean).join(' · '), organization: r => r.typeDisplay || r.type || r.city || r.identifierValue || '', location: r => [r.typeDisplay || r.type, r.organizationName || r.city] .filter(Boolean).join(' · '), cdsrule: r => r.description || r.severity || '', user: r => r.role || r.email || '' }; let rpGroups = {}; function rpBuildGroups(rows) { const config = window.RESOURCE_PAGE_CONFIG; const groups = {}; const list = Array.isArray(rows) ? rows : []; if (rpIsStandalone(config, list)) { // One card per record — organizations, locations, practitioners and // practitioner roles are never lumped together. list.forEach((r, i) => { // The index is always part of the key, so two records that share an id // (or send none at all) can never collapse onto the same card. const id = r.id ?? r.Id ?? r.uuid ?? ''; const key = `rec-${i}-${id}`; groups[key] = { title: rpStandaloneTitle(config, r, i) || '—', rows: [r], single: true }; }); } else { list.forEach(r => { const key = rpRowPatientId(r) || 'unassigned'; if (!groups[key]) groups[key] = { title: r.patientName || key, rows: [], single: false }; groups[key].rows.push(r); }); } return groups; } async function rpLoadGrid() { const config = window.RESOURCE_PAGE_CONFIG; const grid = document.getElementById('rp_cardGrid'); const empty = document.getElementById('rp_gridEmpty'); const countLabel = document.getElementById('rp_countLabel'); try { const result = await config.listAll(rpSearchTerm.trim()); // Some endpoints wrap the collection ({ items: [...] } / .NET { $values }). const rows = Array.isArray(result) ? result : (result?.items || result?.data || result?.$values || result?.results || []); rpAllRows = rows; await rpDecorateRows(config, rows); rpGroups = rpBuildGroups(rows); const keys = Object.keys(rpGroups); const standalone = rpIsStandalone(config, rows); if (countLabel) { const noun = config.title.toLowerCase(); const one = noun.replace(/s$/, ''); countLabel.textContent = standalone ? `${rows.length} ${rows.length === 1 ? one : noun}` : `${rows.length} record${rows.length === 1 ? '' : 's'} · ${keys.length} patient${keys.length === 1 ? '' : 's'}`; } if (!rows.length) { grid.innerHTML = ''; empty.classList.remove('d-none'); return; } empty.classList.add('d-none'); grid.innerHTML = keys.map(key => { const g = rpGroups[key]; const badge = rpCardBadge(config, g.rows, g.title); const colour = rpAvatarColour(g.title); // A standalone card holds exactly one record, so "View (1)" is noise — // it opens that record's own details instead. const label = g.single ? 'View details' : `View (${g.rows.length})`; return `
${rpEscapeHtml(rpInitials(g.title))}
${rpEscapeHtml(g.title)}
${badge ? `
${rpEscapeHtml(badge)}
` : ''}
`; }).join(''); } catch (err) { rpShowToast(err.message, 'danger'); } } // ---------- Detail modal ---------- // Rows currently shown in the detail modal, for the Edit / Delete handlers. let rpDetailRows = []; // Field labels come from the page config; anything the config doesn't name // gets a readable label derived from its key (managingOrganizationId -> // "Managing Organization"). function rpFieldLabel(config, key) { const fromCol = (config.columns || []).find(c => c.key === key); if (fromCol?.label) return fromCol.label; const fromField = (config.fields || []).find(f => f.key === key); if (fromField?.label) return fromField.label; return key .replace(/Id$/, '') .replace(/([A-Z])/g, ' $1') .replace(/^./, c => c.toUpperCase()) .trim(); } // Ids are shown as the name they point at, with the raw id kept as a tooltip. function rpDisplayValue(config, row, key, type) { const idToName = { practitionerId: row.practitionerName, organizationId: row.organizationName, managingOrganizationId: row.organizationName, partOfId: row.organizationName, locationId: row.locationName }; if (idToName[key]) return idToName[key]; const v = row[key]; if (v === null || v === undefined || v === '') return '—'; if (typeof v === 'boolean') return v ? 'Yes' : 'No'; if (type === 'date' || type === 'datetime-local') return rpFmtDate(v); if (typeof v === 'object') return JSON.stringify(v); return String(v); } // One organization / location / practitioner / role, laid out as its own // labelled field list rather than a single squashed table row. function rpRenderSingleDetail(config, row, title) { const list = document.getElementById('rp_detailSingle'); const tableWrap = document.getElementById('rp_detailTableWrap'); if (!list) return false; list.classList.remove('d-none'); tableWrap?.classList.add('d-none'); // Every key worth showing: the configured columns first, then any remaining // form fields, then the generated identifiers. const seen = new Set(['patientName', 'patientId']); const entries = []; const push = (key, type) => { if (!key || seen.has(key)) return; seen.add(key); if (!(key in row) && !['practitionerId', 'organizationId', 'locationId'].includes(key)) return; entries.push({ key, label: rpFieldLabel(config, key), value: rpDisplayValue(config, row, key, type) }); }; (config.columns || []).forEach(c => push(c.key, c.type)); (config.fields || []).forEach(f => push(f.key, f.type)); Object.keys(row).forEach(k => { if (/^(id|Id)$/.test(k)) return; if (/Name$/.test(k) && ['practitionerName', 'organizationName', 'locationName'].includes(k)) return; push(k); }); const showActions = !(config.readOnly || config.canEdit === false); list.innerHTML = `
${rpEscapeHtml(rpInitials(title))}
${rpEscapeHtml(title)}
${rpEscapeHtml(config.title.replace(/s$/, ''))}${ row.id ? ` · ${rpEscapeHtml(String(row.id))}` : ''}
${entries.map(e => `
${rpEscapeHtml(e.label)}
${rpEscapeHtml(e.value)}
`).join('')}
${showActions ? `
` : ''}`; return true; } function rpOpenDetail(groupKey) { const config = window.RESOURCE_PAGE_CONFIG; const group = rpGroups[groupKey]; if (!group) return; rpDetailRows = group.rows; // One record per card => show that record's own fields. if (group.single && group.rows.length === 1) { document.getElementById('rp_detailTitle').textContent = group.title; if (rpRenderSingleDetail(config, group.rows[0], group.title)) { const body = document.getElementById('rp_detailBody'); if (body) body._rpRows = group.rows; rpDetailModal.show(); return; } } document.getElementById('rp_detailSingle')?.classList.add('d-none'); document.getElementById('rp_detailTableWrap')?.classList.remove('d-none'); document.getElementById('rp_detailTitle').textContent = `${config.title} — ${group.title}`; const showActions = !(config.readOnly || config.canEdit === false); const isRisk = rpNormKey(config.key) === 'riskassessment'; document.getElementById('rp_detailHead').innerHTML = (isRisk ? 'Score' : '') + config.columns .filter(c => c.key !== 'patientName') .map(c => `${rpEscapeHtml(c.label)}`).join('') + (showActions ? '' : ''); document.getElementById('rp_detailBody').innerHTML = group.rows.map(row => { const risk = isRisk ? rpParseRiskNote(row.noteText) : null; const level = risk ? rpRiskLevel(risk.score) : ''; const scoreCell = isRisk ? `${risk.score}%
${level}
` : ''; const cells = config.columns .filter(c => c.key !== 'patientName') .map(c => { // Notes hold semicolon-separated factors — render them as bullets so // a long line doesn't become an unreadable wall of text. if ((c.key === 'noteText' || c.key === 'note') && row[c.key]) { const items = String(row[c.key]).split(';').map(s => s.trim()).filter(Boolean); return `
    ${ items.map(i => `
  • ${rpEscapeHtml(i)}
  • `).join('') }
`; } let v = row[c.key]; v = c.type === 'date' ? rpFmtDate(v) : rpEscapeHtml(v ?? '—'); return `${v}`; }).join(''); const rowCells = scoreCell + cells; const actions = showActions ? ` ` : ''; return `${rowCells}${actions}`; }).join(''); document.getElementById('rp_detailBody')._rpRows = group.rows; rpDetailModal.show(); } // Builds the card container and the detail modal so the existing page markup // doesn't have to change. function rpBuildCardShell() { rpInjectCardStyles(); const table = document.getElementById('rp_gridBody')?.closest('.table-responsive'); if (table) { table.classList.add('d-none'); // Banner host at the top of the Add/Edit form. const fields = document.getElementById('rp_fieldsBody'); if (fields && !document.getElementById('rp_formIntro')) { const intro = document.createElement('div'); intro.id = 'rp_formIntro'; intro.className = 'd-none'; fields.parentNode.insertBefore(intro, fields.previousElementSibling || fields); } const grid = document.createElement('div'); grid.id = 'rp_cardGrid'; grid.className = 'rp-card-grid'; table.parentNode.insertBefore(grid, table); } if (!document.getElementById('rp_detailModal')) { const wrap = document.createElement('div'); wrap.innerHTML = ` `; document.body.appendChild(wrap.firstElementChild); } // If the page shipped its own detail modal, make sure the pieces the // single-record view needs are present. const detailBody = document.getElementById('rp_detailBody'); const tableWrap = detailBody?.closest('.table-responsive'); if (tableWrap && !tableWrap.id) tableWrap.id = 'rp_detailTableWrap'; if (tableWrap && !document.getElementById('rp_detailSingle')) { const single = document.createElement('div'); single.id = 'rp_detailSingle'; single.className = 'rp-detail-list d-none'; tableWrap.parentNode.insertBefore(single, tableWrap); } } 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'); } // The table is hidden behind the card grid now; its header is still filled // in so the markup stays valid for anything that inspects it. const head = document.getElementById('rp_gridHead'); if (head) { head.innerHTML = config.columns.map(c => `${rpEscapeHtml(c.label)}`).join(''); } rpBuildCardShell(); rpDetailModal = new bootstrap.Modal(document.getElementById('rp_detailModal')); // "View (n)" on a card opens that group's records. document.getElementById('rp_cardGrid').addEventListener('click', (e) => { const btn = e.target.closest('[data-group]'); if (btn) rpOpenDetail(btn.dataset.group); }); if (!config.readOnly) { document.querySelector('#rp_modal .modal-dialog')?.classList.add('modal-dialog-scrollable'); rpAddModal = new bootstrap.Modal(document.getElementById('rp_modal')); document.getElementById('rp_addBtn').addEventListener('click', rpOpenAddModal); document.getElementById('rp_form').addEventListener('submit', rpSubmitModal); if (!rpIsStandalone(config)) rpLoadPatientsForPicker(); // Edit / Delete live inside the detail modal — the listener sits on the // modal itself so it covers both the grouped table and the single-record // panel. document.getElementById('rp_detailModal').addEventListener('click', (e) => { const editBtn = e.target.closest('[data-edit-row]'); const delBtn = e.target.closest('[data-delete-row]'); if (!editBtn && !delBtn) return; const rows = rpDetailRows.length ? rpDetailRows : (document.getElementById('rp_detailBody')?._rpRows || []); if (editBtn) { const row = rows.find(r => String(r.id) === editBtn.dataset.editRow) || rows[0]; if (row) { rpDetailModal.hide(); rpOpenEditModal(row); } } else if (delBtn) { const row = rows.find(r => String(r.id) === delBtn.dataset.deleteRow) || rows[0]; if (row) { rpDetailModal.hide(); rpDeleteRow(row); } } }); } document.getElementById('rp_searchInput').addEventListener('input', (e) => { rpSearchTerm = e.target.value; rpLoadGrid(); }); rpLoadGrid(); } document.addEventListener('DOMContentLoaded', rpInit);