diff --git a/.Net Capstone Project/resource-pagev1.js b/.Net Capstone Project/resource-pagev1.js new file mode 100644 index 0000000..2e39190 --- /dev/null +++ b/.Net Capstone Project/resource-pagev1.js @@ -0,0 +1,988 @@ +// 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 */ } +} + +async function rpLoadPractitionerRoleDropdowns() { + + const practitioners = await Api.getPractitioners(); + const organizations = await Api.getOrganizations(); + const locations = await Api.getLocations(); + + const practitionerSelect = + document.getElementById('rp_field_practitionerId'); + + const organizationSelect = + document.getElementById('rp_field_organizationId'); + + const locationSelect = + document.getElementById('rp_field_locationId'); + + if (practitionerSelect) { + practitionerSelect.innerHTML = + practitioners.map(p => + `` + ).join(''); + } + + if (organizationSelect) { + organizationSelect.innerHTML = + organizations.map(o => + `` + ).join(''); + } + + if (locationSelect) { + locationSelect.innerHTML = + locations.map(l => + `` + ).join(''); + } +} +// 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: 'Record the contributing factors as one note, separating each with a semicolon and ending with its weight โ€” e.g. Current smoker (+10); Prior hospitalization (+12). The percentage and risk level are calculated from those weights.' + }, + 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', !!config.standalone); + document.getElementById('rp_fieldsBody').innerHTML = config.fields + .filter(f => !RP_AUTO_FIELDS.includes(f.key)) + .map(f => rpFieldInputHtml(f, null)).join(''); + if (config.key === 'practitionerroles') { + await rpLoadPractitionerRoleDropdowns(); +} +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_modalTitle').textContent = +`Edit ${config.title.replace(/s$/, '')}`; + rpRenderFormIntro(config, 'edit'); + document.getElementById('rp_patientPickerRow').classList.add('d-none'); + document.getElementById('rp_fieldsBody').innerHTML = config.fields + .filter(f => !RP_AUTO_FIELDS.includes(f.key)) + .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}`); + + 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; + } + + 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 = (!config.standalone && 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; +} + + const standaloneResources = [ + 'organizations', + 'locations', + 'practitioners', + 'practitionerroles', + 'cdsrules', + 'users' +]; + +if ( + !config.standalone && + !standaloneResources.includes(config.key) && + !patientId +) +{ + rpShowToast('Select a patient first.', 'danger'); + return; +} +console.log("FINAL PAYLOAD", payload); + 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); + await config.remove( + row.patientId || null, + 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; +} + +/* --- 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; } +.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) { + if (config.cardBadge) return config.cardBadge(rows); + + // Risk assessments show the patient's highest score, not just a status. + if (config.key === 'riskassessments') { + const best = rows + .map(r => rpParseRiskNote(r.noteText).score) + .reduce((a, b) => Math.max(a, b), 0); + return `${rpRiskLevel(best)} ยท ${best}%`; + } + + const col = config.columns.find(c => c.key !== 'patientName'); + if (!col) return ''; + const value = rows[0]?.[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 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 }; +} + +let rpGroups = {}; + +function rpBuildGroups(rows) { + const config = window.RESOURCE_PAGE_CONFIG; + const groups = {}; + + if (config.standalone) { + // One card per record; the title is the first column's value. + const titleKey = config.columns[0].key; + rows.forEach(r => { + groups[r.id] = { title: String(r[titleKey] ?? 'โ€”'), rows: [r] }; + }); + } else { + rows.forEach(r => { + const key = r.patientId || 'unassigned'; + if (!groups[key]) groups[key] = { title: r.patientName || key, rows: [] }; + 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 rows = await config.listAll(rpSearchTerm.trim()); + rpAllRows = rows; + rpGroups = rpBuildGroups(rows); + const keys = Object.keys(rpGroups); + + if (countLabel) { + countLabel.textContent = config.standalone + ? `${rows.length} record${rows.length === 1 ? '' : 's'}` + : `${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); + const colour = rpAvatarColour(g.title); + + return ` +
+
${rpEscapeHtml(rpInitials(g.title))}
+
${rpEscapeHtml(g.title)}
+ ${badge ? `
${rpEscapeHtml(badge)}
` : ''} + +
`; + }).join(''); + } catch (err) { + rpShowToast(err.message, 'danger'); + } +} + +// ---------- Detail modal ---------- +function rpOpenDetail(groupKey) { + const config = window.RESOURCE_PAGE_CONFIG; + const group = rpGroups[groupKey]; + if (!group) return; + + document.getElementById('rp_detailTitle').textContent = + `${config.title} โ€” ${group.title}`; + + const showActions = !(config.readOnly || config.canEdit === false); + + const isRisk = config.key === 'riskassessments'; + + 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); + } +} + +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 (!config.standalone) rpLoadPatientsForPicker(); + + // Edit / Delete now live inside the detail modal. + document.getElementById('rp_detailBody').addEventListener('click', (e) => { + const editBtn = e.target.closest('[data-edit-row]'); + const delBtn = e.target.closest('[data-delete-row]'); + const tbody = document.getElementById('rp_detailBody'); + const rows = tbody._rpRows || []; + + if (editBtn) { + const row = rows.find(r => String(r.id) === editBtn.dataset.editRow); + if (row) { rpDetailModal.hide(); rpOpenEditModal(row); } + } else if (delBtn) { + const row = rows.find(r => String(r.id) === delBtn.dataset.deleteRow); + if (row) { rpDetailModal.hide(); rpDeleteRow(row); } + } + }); + } + + document.getElementById('rp_searchInput').addEventListener('input', (e) => { + rpSearchTerm = e.target.value; + rpLoadGrid(); + }); + + rpLoadGrid(); +} + +document.addEventListener('DOMContentLoaded', rpInit);