R EA ource page updated
This commit is contained in:
parent
543afd3d6c
commit
b9ba92e484
350
.Net Capstone Project/resource-page updated.js
Normal file
350
.Net Capstone Project/resource-page updated.js
Normal file
@ -0,0 +1,350 @@
|
||||
// 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, 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 === 'checkbox') {
|
||||
const checked = value === true || value === 'true';
|
||||
return `<div class="col-12 form-check mt-1">
|
||||
<input type="checkbox" class="form-check-input" id="${id}" ${checked ? 'checked' : ''}>
|
||||
<label class="form-check-label small" for="${id}">${rpEscapeHtml(field.label)}</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return `<div class="col-12">
|
||||
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
||||
<textarea ${field.required ? 'required' : ''} class="form-control" id="${id}" rows="2">${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 */ }
|
||||
}
|
||||
|
||||
function rpOpenAddModal() {
|
||||
rpEditingRowId = null;
|
||||
rpEditingPatientId = null;
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
document.getElementById('rp_modalTitle').textContent = `Add ${config.title.replace(/s$/, '')}`;
|
||||
document.getElementById('rp_patientPickerRow').classList.remove('d-none');
|
||||
document.getElementById('rp_fieldsBody').innerHTML = config.fields.map(f => rpFieldInputHtml(f, null)).join('');
|
||||
rpAddModal.show();
|
||||
}
|
||||
|
||||
function rpOpenEditModal(row) {
|
||||
rpEditingRowId = row.id;
|
||||
rpEditingPatientId = row.patientId;
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
document.getElementById('rp_modalTitle').textContent = `Edit ${config.title.replace(/s$/, '')} — ${row.patientName}`;
|
||||
document.getElementById('rp_patientPickerRow').classList.add('d-none');
|
||||
document.getElementById('rp_fieldsBody').innerHTML = config.fields.map(f => rpFieldInputHtml(f, row[f.key])).join('');
|
||||
rpAddModal.show();
|
||||
}
|
||||
|
||||
async function rpSubmitModal(e) {
|
||||
e.preventDefault();
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
const payload = {};
|
||||
for (const f of config.fields) {
|
||||
const el = document.getElementById(`rp_field_${f.key}`);
|
||||
let v;
|
||||
if (f.type === 'checkbox') {
|
||||
v = el.checked;
|
||||
} else {
|
||||
v = el.value;
|
||||
if (f.type === 'number') v = v === '' ? null : parseFloat(v);
|
||||
v = v === '' ? null : v;
|
||||
}
|
||||
payload[f.key] = v;
|
||||
}
|
||||
|
||||
try {
|
||||
if (rpEditingRowId) {
|
||||
await config.update(rpEditingPatientId, rpEditingRowId, payload);
|
||||
rpShowToast(`${config.title} updated.`);
|
||||
} else {
|
||||
const patientId = document.getElementById('rp_patientSelect').value;
|
||||
if (!patientId) { rpShowToast('Select a patient first.', 'danger'); return; }
|
||||
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);
|
||||
rpShowToast('Deleted.');
|
||||
await rpLoadGrid();
|
||||
// The row's Delete button lives inside the popup modal — refresh (or
|
||||
// close, if that was their last record) so it doesn't show stale data.
|
||||
const container = document.getElementById('rp_gridBody');
|
||||
const stillHasRows = (container._rpRows || []).some(r => r.patientId === row.patientId);
|
||||
if (stillHasRows) rpOpenViewModal(row.patientId);
|
||||
else if (rpViewModalInstance) rpViewModalInstance.hide();
|
||||
} catch (err) {
|
||||
rpShowToast(err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap "bg-*" classes cycled per tile so patients are visually easy to
|
||||
// tell apart at a glance — not tied to any data meaning, just variety.
|
||||
const RP_TILE_COLORS = ['primary', 'success', 'info', 'warning', 'danger', 'secondary'];
|
||||
|
||||
// The pages ship a <table><thead>...<tbody id="rp_gridBody"> wrapper (from
|
||||
// before this was a tile grid). Swap that whole table out for a plain grid
|
||||
// div, once, so none of the 7 HTML pages that use this engine need editing.
|
||||
function rpEnsureTileContainer() {
|
||||
const existing = document.getElementById('rp_gridBody');
|
||||
if (existing.tagName.toLowerCase() === 'div') return existing;
|
||||
const table = existing.closest('table');
|
||||
const div = document.createElement('div');
|
||||
div.id = 'rp_gridBody';
|
||||
div.className = 'rp-tile-grid';
|
||||
table.replaceWith(div);
|
||||
return div;
|
||||
}
|
||||
|
||||
let rpViewModalInstance = null;
|
||||
|
||||
// The pages don't ship a "view details" modal in their HTML — build it once
|
||||
// and reuse it, same approach as rpEnsureTileContainer above. Scrollable so
|
||||
// patients with a lot of records don't blow out the page.
|
||||
function rpEnsureViewModal() {
|
||||
let el = document.getElementById('rp_viewModal');
|
||||
if (el) return el;
|
||||
el = document.createElement('div');
|
||||
el.className = 'modal fade';
|
||||
el.id = 'rp_viewModal';
|
||||
el.tabIndex = -1;
|
||||
el.innerHTML = `
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="rp_viewModalTitle"></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0" id="rp_viewModalBody"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
rpViewModalInstance = new bootstrap.Modal(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function rpOpenViewModal(patientId) {
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
const container = document.getElementById('rp_gridBody');
|
||||
const rows = (container._rpRows || []).filter(r => r.patientId === patientId);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
rpEnsureViewModal();
|
||||
document.getElementById('rp_viewModalTitle').textContent = rows[0].patientName;
|
||||
|
||||
const detailColumns = config.columns.filter(c => c.key !== 'patientName');
|
||||
const detailRows = rows.map(row => {
|
||||
const cells = detailColumns.map(c => {
|
||||
if (c.render) return `<td>${c.render(row[c.key], row)}</td>`;
|
||||
let v = row[c.key];
|
||||
v = c.type === 'date' ? rpFmtDate(v) : rpEscapeHtml(v ?? '—');
|
||||
return `<td>${v}</td>`;
|
||||
}).join('');
|
||||
const actions = config.readOnly ? '' : `
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" data-edit-row="${row.id}" data-edit-patient="${row.patientId}">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" data-delete-row="${row.id}" data-delete-patient="${row.patientId}">Delete</button>
|
||||
</td>`;
|
||||
return `<tr data-row-id="${row.id}" data-row-patient="${row.patientId}">${cells}${actions}</tr>`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('rp_viewModalBody').innerHTML = `
|
||||
<div style="max-height:60vh;overflow-y:auto">
|
||||
<table class="table table-sm resource-grid-table align-middle mb-0">
|
||||
<thead><tr>${detailColumns.map(c => `<th>${rpEscapeHtml(c.label)}</th>`).join('')}${config.readOnly ? '' : '<th></th>'}</tr></thead>
|
||||
<tbody>${detailRows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
rpViewModalInstance.show();
|
||||
}
|
||||
|
||||
async function rpLoadGrid() {
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
const container = rpEnsureTileContainer();
|
||||
const empty = document.getElementById('rp_gridEmpty');
|
||||
const countLabel = document.getElementById('rp_countLabel');
|
||||
|
||||
try {
|
||||
const rows = await config.listAll(rpSearchTerm.trim());
|
||||
const patientCount = new Set(rows.map(r => r.patientId)).size;
|
||||
if (countLabel) countLabel.textContent = `${rows.length} record${rows.length === 1 ? '' : 's'} · ${patientCount} patient${patientCount === 1 ? '' : 's'}`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
container.innerHTML = '';
|
||||
empty.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('d-none');
|
||||
|
||||
const groups = new Map();
|
||||
rows.forEach(r => {
|
||||
if (!groups.has(r.patientId)) groups.set(r.patientId, { patientId: r.patientId, patientName: r.patientName, items: [] });
|
||||
groups.get(r.patientId).items.push(r);
|
||||
});
|
||||
|
||||
container.innerHTML = [...groups.values()].map((g, i) => {
|
||||
const color = RP_TILE_COLORS[i % RP_TILE_COLORS.length];
|
||||
const initials = g.patientName.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase();
|
||||
|
||||
return `
|
||||
<div class="card shadow-sm border-0" style="cursor:pointer" data-view-patient="${g.patientId}">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="rounded-circle bg-${color} text-white d-inline-flex align-items-center justify-content-center mb-2" style="width:44px;height:44px;font-size:14px" aria-hidden="true">${rpEscapeHtml(initials)}</div>
|
||||
<div class="fw-semibold small">${rpEscapeHtml(g.patientName)}</div>
|
||||
<span class="badge bg-${color}-subtle text-${color}-emphasis rounded-pill mt-1">${g.items.length} record${g.items.length === 1 ? '' : 's'}</span>
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-${color}" data-view-patient="${g.patientId}">View</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Stash rows for the View/Edit/Delete handlers (avoids a second fetch per click).
|
||||
container._rpRows = rows;
|
||||
|
||||
// Lets a page compute something from the freshly-loaded rows (e.g. an
|
||||
// "X% actioned" stat) without the generic engine needing to know about
|
||||
// page-specific fields like "Actioned".
|
||||
if (typeof config.afterLoad === 'function') config.afterLoad(rows);
|
||||
} catch (err) {
|
||||
rpShowToast(err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// "View" opens a scrollable popup with that patient's records, instead of
|
||||
// expanding inline (which used to push the rest of the grid down). Works
|
||||
// on every page, including read-only ones (Risk Assessment, Care Plans).
|
||||
rpEnsureTileContainer().addEventListener('click', (e) => {
|
||||
const viewEl = e.target.closest('[data-view-patient]');
|
||||
if (!viewEl) return;
|
||||
rpOpenViewModal(viewEl.dataset.viewPatient);
|
||||
});
|
||||
|
||||
if (!config.readOnly) {
|
||||
rpAddModal = new bootstrap.Modal(document.getElementById('rp_modal'));
|
||||
document.getElementById('rp_addBtn').addEventListener('click', rpOpenAddModal);
|
||||
document.getElementById('rp_form').addEventListener('submit', rpSubmitModal);
|
||||
rpLoadPatientsForPicker();
|
||||
|
||||
rpEnsureViewModal().addEventListener('click', (e) => {
|
||||
const editBtn = e.target.closest('[data-edit-row]');
|
||||
const delBtn = e.target.closest('[data-delete-row]');
|
||||
const tbody = document.getElementById('rp_gridBody');
|
||||
if (editBtn) {
|
||||
const row = (tbody._rpRows || []).find(r => String(r.id) === editBtn.dataset.editRow && r.patientId === editBtn.dataset.editPatient);
|
||||
if (row) { rpViewModalInstance.hide(); rpOpenEditModal(row); }
|
||||
} else if (delBtn) {
|
||||
const row = (tbody._rpRows || []).find(r => String(r.id) === delBtn.dataset.deleteRow && r.patientId === delBtn.dataset.deletePatient);
|
||||
if (row) rpDeleteRow(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('rp_searchInput').addEventListener('input', (e) => {
|
||||
rpSearchTerm = e.target.value;
|
||||
rpLoadGrid();
|
||||
});
|
||||
|
||||
rpLoadGrid();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', rpInit);
|
||||
Loading…
Reference in New Issue
Block a user