// 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 `
`;
}
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 = `
${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))
.filter(f => !(config.key === 'riskassessments' && f.key === 'noteText'))
.map(f => rpFieldInputHtml(f, null)).join('')
+ (config.key === 'riskassessments' ? rpRiskFactorsHtml() : '');
if (config.key === 'riskassessments') {
rpRiskPatientId = null; // Add mode follows the dropdown
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));
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();
}
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');
if (config.key === 'riskassessments') rpRiskPatientId = row.patientId;
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-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) {
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.
// 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 `