diff --git a/.Net Capstone Project/resource-pagev2.js b/.Net Capstone Project/resource-pagev2.js
new file mode 100644
index 0000000..e4eb85a
--- /dev/null
+++ b/.Net Capstone Project/resource-pagev2.js
@@ -0,0 +1,1184 @@
+// 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: '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 = `
+ `;
+}
+
+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))
+ .filter(f => !(config.key === 'riskassessments' && f.key === 'noteText'))
+ .map(f => rpFieldInputHtml(f, null)).join('')
+ + (config.key === 'riskassessments' ? rpRiskFactorsHtml() : '');
+
+ if (config.key === 'riskassessments') rpWireRiskForm();
+
+ if (config.key === 'practitionerroles') {
+ await rpLoadPractitionerRoleDropdowns();
+}
+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));
+
+ 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();
+}
+
+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');
+ const split = config.key === 'riskassessments'
+ ? rpSplitRiskNote(row.noteText)
+ : null;
+
+ document.getElementById('rp_fieldsBody').innerHTML = config.fields
+ .filter(f => !RP_AUTO_FIELDS.includes(f.key))
+ .filter(f => !(config.key === 'riskassessments' && f.key === 'noteText'))
+ .map(f => rpFieldInputHtml(f, row[f.key])).join('')
+ + (split ? rpRiskFactorsHtml(split.known, split.other) : '');
+
+ if (config.key === 'riskassessments') rpWireRiskForm();
+
+ 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 (config.key === 'riskassessments') {
+ // 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 = (!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; }
+/* --- 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-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 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.
+function rpSelectedGender() {
+ const select = document.getElementById('rp_patientSelect');
+ if (!select) return '';
+ const patient = rpPatientsCache.find(p => p.id === select.value);
+ 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 `
+
+ ${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('')}
+
+
+
+
`;
+}
+
+// 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); });
+ score = Math.min(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 };
+}
+
+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);