1593 lines
56 KiB
Plaintext
1593 lines
56 KiB
Plaintext
// 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, '"').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 = `<div class="d-flex"><div class="toast-body">${rpEscapeHtml(message)}</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" onclick="this.closest('.toast').remove()"></button></div>`;
|
|
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 `<div class="col-12">
|
|
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
|
<select class="form-select" id="${id}">
|
|
${field.options.map(o => `<option value="${o}" ${o === val ? 'selected' : ''}>${o}</option>`).join('')}
|
|
</select>
|
|
</div>`;
|
|
}
|
|
if (field.type === 'textarea') {
|
|
return `<div class="col-12">
|
|
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
|
<textarea class="form-control" rows="3" id="${id}" ${field.required ? 'required' : ''}>${rpEscapeHtml(val)}</textarea>
|
|
</div>`;
|
|
}
|
|
const inputVal = field.type === 'date' && val ? String(val).slice(0, 10)
|
|
: field.type === 'datetime-local' && val ? String(val).slice(0, 16)
|
|
: val;
|
|
return `<div class="col-12">
|
|
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
|
<input type="${field.type}" ${field.required ? 'required' : ''} class="form-control" id="${id}" value="${rpEscapeHtml(inputVal)}">
|
|
</div>`;
|
|
}
|
|
|
|
async function rpLoadPatientsForPicker() {
|
|
try {
|
|
rpPatientsCache = await Api.getPatients();
|
|
const select = document.getElementById('rp_patientSelect');
|
|
if (select) {
|
|
select.innerHTML = rpPatientsCache.map(p => `<option value="${p.id}">${rpEscapeHtml(p.fullName)} (${rpEscapeHtml(p.medicalRecordNumber)})</option>`).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 = '<option value="">— none —</option>' +
|
|
list.map(item => {
|
|
const id = item.id ?? item.Id ?? '';
|
|
const label = labelFn(item) || id;
|
|
const isSel = selected && String(selected) === String(id) ? ' selected' : '';
|
|
return `<option value="${rpEscapeHtml(id)}"${isSel}>${rpEscapeHtml(label)}</option>`;
|
|
}).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 = `
|
|
<div class="rp-form-intro">
|
|
<div class="rp-form-intro-icon">${meta.icon}</div>
|
|
<div>
|
|
<div class="rp-form-intro-title">${rpEscapeHtml(meta.title)}</div>
|
|
<div class="rp-form-intro-body">${meta.body}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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/<resource>/<running number>
|
|
// value = <ResourcePrefix>-<patientId> (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 `
|
|
<div class="col-12">
|
|
<div class="rp-risk-score" id="rp_riskScorePreview">
|
|
<span class="rp-score-chip is-low" id="rp_riskScoreChip">0%</span>
|
|
<span class="rp-risk-score-label" id="rp_riskScoreLabel">Low risk</span>
|
|
</div>
|
|
</div>
|
|
${groups.map(g => `
|
|
<div class="col-12">
|
|
<div class="rp-risk-group">${rpEscapeHtml(g.name)}</div>
|
|
${g.items.map(f => {
|
|
const id = `rp_risk_${f.label.replace(/[^a-z0-9]/gi, '_')}`;
|
|
const checked = checkedLabels.includes(f.label) ? 'checked' : '';
|
|
return `<label class="rp-risk-item">
|
|
<input type="checkbox" class="form-check-input rp-risk-check"
|
|
id="${id}" data-label="${rpEscapeHtml(f.label)}"
|
|
data-weight="${f.weight}" ${checked}>
|
|
<span>${rpEscapeHtml(f.label)}</span>
|
|
<span class="rp-risk-weight">+${f.weight}</span>
|
|
</label>`;
|
|
}).join('')}
|
|
</div>`).join('')}
|
|
<div class="col-12">
|
|
<label class="form-label" for="rp_riskFreeText">Additional factors & notes</label>
|
|
<textarea class="form-control" rows="2" id="rp_riskFreeText"
|
|
placeholder="e.g. 3 active condition(s) on chart (+15)">${rpEscapeHtml(freeText)}</textarea>
|
|
<div class="rp-risk-hint">
|
|
Factors the rules engine derived from the chart land here. Any
|
|
<code>(+n)</code> weight in this box is counted in the score above.
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// 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 `
|
|
<div class="rp-card">
|
|
<div class="rp-avatar" style="background:${colour}">${rpEscapeHtml(rpInitials(g.title))}</div>
|
|
<div class="rp-card-name" title="${rpEscapeHtml(g.title)}">${rpEscapeHtml(g.title)}</div>
|
|
${badge ? `<div class="rp-card-badge ${rpBadgeClass(badge)}">${rpEscapeHtml(badge)}</div>` : ''}
|
|
<button class="rp-card-view" style="color:${colour};border-color:${colour}"
|
|
data-group="${rpEscapeHtml(key)}">${label}</button>
|
|
</div>`;
|
|
}).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 = `
|
|
<div class="rp-detail-head">
|
|
<div class="rp-detail-avatar" style="background:${rpAvatarColour(title)}">
|
|
${rpEscapeHtml(rpInitials(title))}
|
|
</div>
|
|
<div>
|
|
<div class="rp-detail-name">${rpEscapeHtml(title)}</div>
|
|
<div class="rp-detail-sub">${rpEscapeHtml(config.title.replace(/s$/, ''))}${
|
|
row.id ? ` · ${rpEscapeHtml(String(row.id))}` : ''}</div>
|
|
</div>
|
|
</div>
|
|
<dl class="rp-detail-grid">
|
|
${entries.map(e => `
|
|
<div class="rp-detail-item">
|
|
<dt>${rpEscapeHtml(e.label)}</dt>
|
|
<dd>${rpEscapeHtml(e.value)}</dd>
|
|
</div>`).join('')}
|
|
</dl>
|
|
${showActions ? `
|
|
<div class="rp-detail-actions">
|
|
<button class="btn btn-sm btn-outline-secondary" data-edit-row="${rpEscapeHtml(String(row.id))}">Edit</button>
|
|
<button class="btn btn-sm btn-outline-danger" data-delete-row="${rpEscapeHtml(String(row.id))}">Delete</button>
|
|
</div>` : ''}`;
|
|
|
|
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 ? '<th>Score</th>' : '') +
|
|
config.columns
|
|
.filter(c => c.key !== 'patientName')
|
|
.map(c => `<th>${rpEscapeHtml(c.label)}</th>`).join('') +
|
|
(showActions ? '<th></th>' : '');
|
|
|
|
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
|
|
? `<td><span class="rp-score-chip is-${level.toLowerCase()}">${risk.score}%</span>
|
|
<div class="rp-score-level">${level}</div></td>`
|
|
: '';
|
|
|
|
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 `<td><ul class="rp-note-list">${
|
|
items.map(i => `<li>${rpEscapeHtml(i)}</li>`).join('')
|
|
}</ul></td>`;
|
|
}
|
|
let v = row[c.key];
|
|
v = c.type === 'date' ? rpFmtDate(v) : rpEscapeHtml(v ?? '—');
|
|
return `<td>${v}</td>`;
|
|
}).join('');
|
|
|
|
const rowCells = scoreCell + cells;
|
|
|
|
const actions = showActions ? `
|
|
<td class="resource-row-actions text-end">
|
|
<button class="btn btn-sm btn-outline-secondary me-1" data-edit-row="${row.id}">Edit</button>
|
|
<button class="btn btn-sm btn-outline-danger" data-delete-row="${row.id}">Delete</button>
|
|
</td>` : '';
|
|
|
|
return `<tr data-row-id="${row.id}">${rowCells}${actions}</tr>`;
|
|
}).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 = `
|
|
<div class="modal fade" id="rp_detailModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="rp_detailTitle">Records</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="rp_detailSingle" class="rp-detail-list d-none"></div>
|
|
<div class="table-responsive" id="rp_detailTableWrap">
|
|
<table class="table table-sm resource-grid-table align-middle mb-0">
|
|
<thead><tr id="rp_detailHead"></tr></thead>
|
|
<tbody id="rp_detailBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
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 => `<th>${rpEscapeHtml(c.label)}</th>`).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); |