Compare commits

..

25 Commits

Author SHA1 Message Date
af93c8fa82 Upload files to "Code " 2026-08-31 14:01:00 +00:00
2aeb78f507 Upload files to ".Net Capstone Project" 2026-08-29 13:52:46 +00:00
ce2f512207 Upload files to ".Net Capstone Project" 2026-08-29 13:47:30 +00:00
9d476e9a9b Upload files to ".Net Capstone Project" 2026-08-29 13:42:29 +00:00
6c0b37042a Upload files to ".Net Capstone Project" 2026-08-29 13:34:43 +00:00
54d93213ed Upload files to ".Net Capstone Project" 2026-08-29 13:26:36 +00:00
3d2db1065f Upload files to ".Net Capstone Project" 2026-08-29 13:21:24 +00:00
ad7dfc309d Upload files to ".Net Capstone Project" 2026-08-29 13:07:46 +00:00
bfdad77519 Upload files to ".Net Capstone Project" 2026-08-29 13:02:58 +00:00
529b00fe82 Upload files to ".Net Capstone Project" 2026-08-29 12:55:37 +00:00
a941f3f3b1 Upload files to ".Net Capstone Project" 2026-08-29 12:50:27 +00:00
6011cc8a44 Upload files to ".Net Capstone Project" 2026-08-29 12:41:45 +00:00
c7fb4c52be Upload files to ".Net Capstone Project" 2026-08-29 12:37:44 +00:00
a7a3c21c9f Upload files to ".Net Capstone Project" 2026-08-29 12:32:31 +00:00
9fb80aaf11 Upload files to ".Net Capstone Project" 2026-08-29 12:22:57 +00:00
938412c349 Upload files to ".Net Capstone Project" 2026-08-29 12:12:43 +00:00
0095700d9b Upload files to ".Net Capstone Project" 2026-08-29 12:04:50 +00:00
5898469243 Upload files to ".Net Capstone Project" 2026-08-29 11:57:34 +00:00
974c4115f7 [UpdatedfileNew29082026.zip](/attachments/c871c5f1-f577-4fa5-8e12-19d3fcc4a521) 2026-08-29 11:48:26 +00:00
1783fbb5e7 Upload files to ".Net Capstone Project" 2026-08-29 11:19:16 +00:00
368ee47823 Upload files to ".Net Capstone Project" 2026-08-29 11:11:51 +00:00
eb0351c2c1 Dashbaord cahnges 2026-08-29 10:30:47 +00:00
51bb1b2409 Dashboard updated 29 2026-08-29 10:10:14 +00:00
e2446d4d90 Application files 2026-08-29 09:36:09 +00:00
09b19a4838 Seed 2026-08-29 07:56:43 +00:00
66 changed files with 3158 additions and 3979 deletions

Binary file not shown.

View File

@ -1,832 +0,0 @@
let currentPatientId = null;
let patientsCache = [];
let patientSearchTerm = '';
function showSpinner(show) {
document.getElementById('spinnerOverlay').style.display = show ? 'flex' : 'none';
}
function showToast(message, variant = 'success') {
const container = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = `toast align-items-center text-bg-${variant} border-0 show mb-2`;
el.innerHTML = `<div class="d-flex">
<div class="toast-body">${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 escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str ?? '';
return div.innerHTML;
}
function fmtDate(dateStr) {
if (!dateStr) return '—';
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
// ---------- Dynamic resource forms ----------
// Field config per FHIR resource type. Drives the generic #addResourceModal
// so patients, conditions, observations, allergies, medications and
// encounters can all be added from the UI and hit the CRUD API — nothing
// here is limited to the three seeded demo patients.
const RESOURCE_FORMS = {
condition: {
title: 'Add Condition',
fields: [
{ key: 'code', label: 'Code (ICD-10, e.g. I10)', type: 'text', required: true },
{ key: 'display', label: 'Display name', type: 'text', required: true },
{ key: 'clinicalStatus', label: 'Clinical status', type: 'select', options: ['active', 'inactive', 'resolved', 'remission'], default: 'active' },
{ key: 'onsetDate', label: 'Onset date', type: 'date', required: true }
],
add: (patientId, payload) => Api.addCondition(patientId, payload)
},
observation: {
title: 'Add Observation',
fields: [
{ key: 'code', label: 'Code (LOINC, e.g. 8480-6)', type: 'text', required: true },
{ key: 'display', label: 'Display name', type: 'text', required: true },
{ key: 'value', label: 'Value', type: 'number', required: true },
{ key: 'unit', label: 'Unit', type: 'text', required: true },
{ key: 'effectiveDate', label: 'Effective date/time', type: 'datetime-local', required: true }
],
add: (patientId, payload) => Api.addObservation(patientId, payload)
},
allergy: {
title: 'Add Allergy / Intolerance',
fields: [
{ key: 'substance', label: 'Substance', type: 'text', required: true },
{ key: 'reaction', label: 'Reaction', type: 'text' },
{ key: 'criticality', label: 'Criticality', type: 'select', options: ['low', 'high', 'unable-to-assess'], default: 'low' }
],
add: (patientId, payload) => Api.addAllergy(patientId, payload)
},
medication: {
title: 'Add Medication',
fields: [
{ key: 'medicationName', label: 'Medication name', type: 'text', required: true },
{ key: 'dosage', label: 'Dosage', type: 'text' },
{ key: 'status', label: 'Status', type: 'select', options: ['active', 'completed', 'cancelled', 'on-hold', 'stopped'], default: 'active' },
{ key: 'authoredOn', label: 'Authored on', type: 'date' }
],
add: (patientId, payload) => Api.addMedication(patientId, payload)
},
encounter: {
title: 'Add Encounter',
fields: [
{ key: 'encounterType', label: 'Encounter type', type: 'text', required: true, placeholder: 'e.g. Outpatient' },
{ key: 'reason', label: 'Reason', type: 'text' },
{ key: 'encounterDate', label: 'Date/time', type: 'datetime-local', required: true },
{ key: 'status', label: 'Status', type: 'select', options: ['planned', 'in-progress', 'finished', 'cancelled'], default: 'in-progress' }
],
add: (patientId, payload) => Api.addEncounter(patientId, payload)
}
};
let activeResourceType = null;
let newPatientModal, addResourceModal;
function openResourceModal(type) {
if (!currentPatientId) {
showToast('Select a patient first.', 'danger');
return;
}
activeResourceType = type;
const config = RESOURCE_FORMS[type];
document.getElementById('addResourceModalTitle').textContent = config.title;
const body = document.getElementById('addResourceModalBody');
body.innerHTML = config.fields.map(f => {
const id = `res_${f.key}`;
if (f.type === 'select') {
return `<div class="col-12">
<label class="form-label small">${escapeHtml(f.label)}</label>
<select class="form-select" id="${id}">
${f.options.map(o => `<option value="${o}" ${o === f.default ? 'selected' : ''}>${o}</option>`).join('')}
</select>
</div>`;
}
return `<div class="col-12">
<label class="form-label small">${escapeHtml(f.label)}</label>
<input type="${f.type}" ${f.required ? 'required' : ''} class="form-control" id="${id}" placeholder="${escapeHtml(f.placeholder || '')}">
</div>`;
}).join('');
addResourceModal.show();
}
// ---------- Dashboard stats + resource browser ----------
// Aggregates every patient's chart + care plans into flat arrays (each item
// tagged with its owning patient) so the top stat cards can show real
// counts, and clicking one can browse that resource across ALL patients —
// not just the one currently selected.
let resourceCache = { conditions: [], observations: [], allergies: [], medications: [], encounters: [], careplans: [] };
let resourceBrowserModal;
async function loadDashboardStats() {
resourceCache = { conditions: [], observations: [], allergies: [], medications: [], encounters: [], careplans: [] };
try {
const [chartResults, carePlanResults] = await Promise.all([
Promise.all(patientsCache.map(p => Api.getChart(p.id).catch(() => null))),
Promise.all(patientsCache.map(p => Api.getCarePlans(p.id).catch(() => [])))
]);
patientsCache.forEach((p, idx) => {
const chart = chartResults[idx];
if (chart) {
chart.conditions.forEach(c => resourceCache.conditions.push({ ...c, patientId: p.id, patientName: p.fullName }));
chart.observations.forEach(o => resourceCache.observations.push({ ...o, patientId: p.id, patientName: p.fullName }));
chart.allergies.forEach(a => resourceCache.allergies.push({ ...a, patientId: p.id, patientName: p.fullName }));
chart.medications.forEach(m => resourceCache.medications.push({ ...m, patientId: p.id, patientName: p.fullName }));
chart.encounters.forEach(e => resourceCache.encounters.push({ ...e, patientId: p.id, patientName: p.fullName }));
}
(carePlanResults[idx] || []).forEach(cp => resourceCache.careplans.push({ ...cp, patientId: p.id, patientName: p.fullName }));
});
} catch {
// Stats are a nice-to-have on top of the core dashboard — don't block
// the rest of the page if aggregation fails for some reason.
}
updateStatCardsForContext();
}
// Returns the patients matching the current search box text (or everyone,
// if the box is empty). Shared by the sidebar list and the stat cards so
// they always agree on "what's currently in view".
function getFilteredPatients() {
const term = patientSearchTerm.trim().toLowerCase();
if (!term) return patientsCache;
return patientsCache.filter(p => {
const fullName = (p.fullName || '').toLowerCase();
const nameParts = fullName.split(' ').filter(Boolean);
// Name matches if the search term is the start of the full name, OR
// the start of any individual name part (first name, last name, etc.)
// — so typing "N" matches "Nisha Meshram" and "Neha Patil", but not
// "Anjali Patil" (the 'n' isn't at the start of either name part).
const nameMatch = fullName.startsWith(term) || nameParts.some(part => part.startsWith(term));
// Strip generic label prefixes ("pt-", "MRN-") before substring
// matching — otherwise a search term can accidentally match the label
// text itself. E.g. "MRN-100235" lowercased contains "mrn", so
// searching "n" would match every patient's MRN regardless of their
// actual number, even ones with nothing else in common with "n".
const idValue = (p.id || '').toLowerCase().replace(/^pt-/, '');
const mrnValue = (p.medicalRecordNumber || '').toLowerCase().replace(/^mrn-/, '');
const idMatch = idValue.includes(term);
const mrnMatch = mrnValue.includes(term);
return nameMatch || idMatch || mrnMatch;
});
}
// Drives the top stat cards. Three states:
// 1. A patient is selected (currentPatientId set) -> counts for just them.
// 2. Nothing selected but the search box has text -> counts for the
// patients currently matching the search.
// 3. Neither -> counts across everyone.
function updateStatCardsForContext() {
const setCount = (id, value) => { const el = document.getElementById(id); if (el) el.textContent = value; };
const scopeLabel = document.getElementById('statsScopeLabel');
let activePatients;
let scopeText = null;
if (currentPatientId) {
const selected = patientsCache.find(p => p.id === currentPatientId);
activePatients = selected ? [selected] : [];
if (selected) scopeText = `Showing data for <strong>${escapeHtml(selected.fullName)}</strong>`;
} else {
activePatients = getFilteredPatients();
if (patientSearchTerm.trim()) {
scopeText = `Showing ${activePatients.length} of ${patientsCache.length} patients matching "${escapeHtml(patientSearchTerm.trim())}"`;
}
}
const activeIds = getActivePatientIds();
setCount('stat_patients', activePatients.length);
setCount('stat_conditions', resourceCache.conditions.filter(c => activeIds.has(c.patientId)).length);
setCount('stat_medications', resourceCache.medications.filter(m => activeIds.has(m.patientId)).length);
setCount('stat_allergies', resourceCache.allergies.filter(a => activeIds.has(a.patientId)).length);
setCount('stat_observations', resourceCache.observations.filter(o => activeIds.has(o.patientId)).length);
setCount('stat_encounters', resourceCache.encounters.filter(e => activeIds.has(e.patientId)).length);
setCount('stat_careplans', resourceCache.careplans.filter(cp => activeIds.has(cp.patientId)).length);
if (scopeLabel) {
if (scopeText) {
scopeLabel.innerHTML = `${scopeText} · <a href="#" id="clearStatsScopeLink">Show all patients</a>`;
scopeLabel.classList.remove('d-none');
} else {
scopeLabel.innerHTML = '';
scopeLabel.classList.add('d-none');
}
}
}
function patientChipHtml(patientId, name) {
return `<span class="patient-chip" data-jump-patient="${patientId}">👤 ${escapeHtml(name)}</span>`;
}
// The single source of truth for "what patient(s) is the page currently
// scoped to" — a selected patient takes priority, otherwise it's whoever
// matches the search box (or everyone, if the box is empty). Used by both
// the stat cards and the "browse all" modal so they always agree.
function getActivePatientIds() {
if (currentPatientId) return new Set([currentPatientId]);
return new Set(getFilteredPatients().map(p => p.id));
}
const RESOURCE_BROWSER_CONFIG = {
patients: {
title: 'Patients',
columns: ['Name', 'Age', 'Gender', 'MRN'],
rows: (activeIds) => patientsCache.filter(p => activeIds.has(p.id)).map(p => [
patientChipHtml(p.id, p.fullName), p.age, escapeHtml(p.gender), escapeHtml(p.medicalRecordNumber)
])
},
conditions: {
title: 'Conditions',
columns: ['Patient', 'Display', 'Code', 'Status', 'Onset'],
rows: (activeIds) => resourceCache.conditions.filter(c => activeIds.has(c.patientId)).map(c => [
patientChipHtml(c.patientId, c.patientName), escapeHtml(c.display), escapeHtml(c.code),
escapeHtml(c.clinicalStatus), fmtDate(c.onsetDate)
])
},
medications: {
title: 'Medications',
columns: ['Patient', 'Medication', 'Dosage', 'Status', 'Authored'],
rows: (activeIds) => resourceCache.medications.filter(m => activeIds.has(m.patientId)).map(m => [
patientChipHtml(m.patientId, m.patientName), escapeHtml(m.medicationName), escapeHtml(m.dosage),
escapeHtml(m.status), fmtDate(m.authoredOn)
])
},
allergies: {
title: 'Allergies',
columns: ['Patient', 'Substance', 'Reaction', 'Criticality'],
rows: (activeIds) => resourceCache.allergies.filter(a => activeIds.has(a.patientId)).map(a => [
patientChipHtml(a.patientId, a.patientName), escapeHtml(a.substance), escapeHtml(a.reaction), escapeHtml(a.criticality)
])
},
observations: {
title: 'Observations',
columns: ['Patient', 'Display', 'Value', 'Date'],
rows: (activeIds) => resourceCache.observations.filter(o => activeIds.has(o.patientId)).map(o => [
patientChipHtml(o.patientId, o.patientName), escapeHtml(o.display), `${o.value} ${escapeHtml(o.unit)}`, fmtDate(o.effectiveDate)
])
},
encounters: {
title: 'Encounters',
columns: ['Patient', 'Type', 'Reason', 'Date', 'Status'],
rows: (activeIds) => resourceCache.encounters.filter(e => activeIds.has(e.patientId)).map(e => [
patientChipHtml(e.patientId, e.patientName), escapeHtml(e.encounterType), escapeHtml(e.reason),
fmtDate(e.encounterDate), escapeHtml(e.status)
])
},
careplans: {
title: 'Care Plans',
columns: ['Patient', 'Title', 'Triggered By', 'Status', 'Created'],
rows: (activeIds) => resourceCache.careplans.filter(cp => activeIds.has(cp.patientId)).map(cp => [
patientChipHtml(cp.patientId, cp.patientName), escapeHtml(cp.title), escapeHtml(cp.triggeredByRule),
escapeHtml(cp.status), fmtDate(cp.createdDate)
])
}
};
function openResourceBrowser(resourceType) {
const config = RESOURCE_BROWSER_CONFIG[resourceType];
if (!config) return;
const activeIds = getActivePatientIds();
const scopeSuffix = currentPatientId
? `${patientsCache.find(p => p.id === currentPatientId)?.fullName || 'selected patient'}`
: (patientSearchTerm.trim() ? ` — matching "${patientSearchTerm.trim()}"` : '');
document.getElementById('resourceBrowserTitle').textContent = config.title + scopeSuffix;
document.getElementById('resourceBrowserHead').innerHTML = config.columns.map(c => `<th>${c}</th>`).join('');
const rows = config.rows(activeIds);
const body = document.getElementById('resourceBrowserBody');
const emptyState = document.getElementById('resourceBrowserEmpty');
if (rows.length === 0) {
body.innerHTML = '';
emptyState.textContent = 'No data found.';
emptyState.classList.remove('d-none');
} else {
emptyState.classList.add('d-none');
body.innerHTML = rows.map(cells => `<tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr>`).join('');
}
resourceBrowserModal.show();
}
async function submitResourceForm(e) {
e.preventDefault();
const config = RESOURCE_FORMS[activeResourceType];
if (!config) return;
const payload = {};
for (const f of config.fields) {
const el = document.getElementById(`res_${f.key}`);
let val = el.value;
if (f.type === 'number') val = val === '' ? null : parseFloat(val);
payload[f.key] = val === '' ? null : val;
}
try {
showSpinner(true);
await config.add(currentPatientId, payload);
addResourceModal.hide();
showToast(`${config.title.replace('Add ', '')} added.`);
await loadChart(currentPatientId);
loadDashboardStats();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
async function deleteResource(type, id) {
if (!currentPatientId || !confirm('Delete this item?')) return;
const deleteFns = {
condition: Api.deleteCondition,
observation: Api.deleteObservation,
allergy: Api.deleteAllergy,
medication: Api.deleteMedication,
encounter: Api.deleteEncounter
};
try {
showSpinner(true);
await deleteFns[type](currentPatientId, id);
showToast('Deleted.');
await loadChart(currentPatientId);
loadDashboardStats();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
function setupDynamicDataControls() {
newPatientModal = new bootstrap.Modal(document.getElementById('newPatientModal'));
addResourceModal = new bootstrap.Modal(document.getElementById('addResourceModal'));
resourceBrowserModal = new bootstrap.Modal(document.getElementById('resourceBrowserModal'));
document.getElementById('newPatientBtn').addEventListener('click', () => newPatientModal.show());
// Live search: filters the cached patient list by name, patient ID, or
// MRN as the user types — no server round-trip needed.
document.getElementById('patientSearchInput')?.addEventListener('input', (e) => {
patientSearchTerm = e.target.value;
renderPatientList();
updateStatCardsForContext();
});
document.getElementById('newPatientForm').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
firstName: document.getElementById('np_firstName').value,
lastName: document.getElementById('np_lastName').value,
dateOfBirth: document.getElementById('np_dob').value,
gender: document.getElementById('np_gender').value,
medicalRecordNumber: document.getElementById('np_mrn').value || null
};
try {
showSpinner(true);
const created = await Api.createPatient(payload);
newPatientModal.hide();
document.getElementById('newPatientForm').reset();
showToast('Patient created.');
await loadPatientList();
loadDashboardStats();
selectPatient(created.patient.id);
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
});
document.getElementById('addResourceForm').addEventListener('submit', submitResourceForm);
// Stat cards -> open the "browse this resource across all patients" modal.
document.getElementById('statsRow').addEventListener('click', (e) => {
const card = e.target.closest('.stat-card');
if (card) openResourceBrowser(card.dataset.resource);
});
// "Show all patients" link inside the stats scope label — clears both
// the search box and the selected patient, reverting the stat cards to
// totals across everyone.
document.addEventListener('click', (e) => {
if (e.target.id !== 'clearStatsScopeLink') return;
e.preventDefault();
patientSearchTerm = '';
const input = document.getElementById('patientSearchInput');
if (input) input.value = '';
currentPatientId = null;
sessionStorage.removeItem('cip_patientId');
document.getElementById('patientContent')?.classList.add('d-none');
document.getElementById('noPatientState')?.classList.remove('d-none');
renderPatientList();
updateStatCardsForContext();
});
// Inside the resource browser: clicking a patient chip jumps straight to
// that patient's Chart tab.
document.getElementById('resourceBrowserBody').addEventListener('click', (e) => {
const chip = e.target.closest('[data-jump-patient]');
if (!chip) return;
resourceBrowserModal.hide();
selectPatient(chip.dataset.jumpPatient);
document.querySelectorAll('#dashboardTabs .nav-link').forEach(b => b.classList.remove('active'));
document.querySelector('#dashboardTabs .nav-link[data-tab="chart"]')?.classList.add('active');
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('d-none'));
document.getElementById('tab-chart')?.classList.remove('d-none');
});
// Event delegation: "+ Add" buttons in the chart tab, and per-item delete
// buttons rendered inside the chart lists (see loadChart()).
document.getElementById('tab-chart').addEventListener('click', (e) => {
const addBtn = e.target.closest('[data-add]');
if (addBtn) { openResourceModal(addBtn.dataset.add); return; }
const delBtn = e.target.closest('[data-delete-type]');
if (delBtn) { deleteResource(delBtn.dataset.deleteType, parseInt(delBtn.dataset.deleteId, 10)); }
});
}
// ---------- Init ----------
document.addEventListener('DOMContentLoaded', async () => {
const token = sessionStorage.getItem('cip_token');
if (!token) {
window.location.href = 'login.html';
return;
}
document.getElementById('clinicianLabel').textContent =
`👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
document.getElementById('logoutBtn').addEventListener('click', () => {
sessionStorage.clear();
window.location.href = 'index.html';
});
setupTabs();
setupRiskForm();
setupDynamicDataControls();
document.getElementById('evaluateCdsBtn').addEventListener('click', evaluateCds);
document.getElementById('refreshCarePlansBtn').addEventListener('click', () => loadCarePlans(currentPatientId));
await loadPatientList();
loadDashboardStats();
const launchPatientId = sessionStorage.getItem('cip_patientId');
if (launchPatientId) {
selectPatient(launchPatientId);
}
});
function setupTabs() {
document.querySelectorAll('#dashboardTabs .nav-link').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#dashboardTabs .nav-link').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('d-none'));
document.getElementById(`tab-${btn.dataset.tab}`).classList.remove('d-none');
});
});
}
// ---------- Patients ----------
async function loadPatientList() {
try {
showSpinner(true);
patientsCache = await Api.getPatients();
renderPatientList();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
function renderPatientList() {
const container = document.getElementById('patientList');
const emptyState = document.getElementById('patientSearchEmpty');
const emptyTerm = document.getElementById('patientSearchEmptyTerm');
const term = patientSearchTerm.trim();
const filtered = getFilteredPatients();
if (filtered.length === 0 && term) {
container.innerHTML = '';
container.classList.add('d-none');
if (emptyTerm) emptyTerm.textContent = patientSearchTerm.trim();
emptyState?.classList.remove('d-none');
return;
}
container.classList.remove('d-none');
emptyState?.classList.add('d-none');
container.innerHTML = filtered.map(p => `
<div class="card-ci patient-tile p-2 mb-2 d-flex justify-content-between align-items-start ${p.id === currentPatientId ? 'active' : ''}" data-id="${p.id}">
<div>
<div class="fw-semibold">${escapeHtml(p.fullName)}</div>
<div class="small text-muted-ci">${p.age} yrs · ${escapeHtml(p.gender)} · ${escapeHtml(p.medicalRecordNumber)}</div>
<div class="small text-muted-ci">ID: ${escapeHtml(p.id)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-patient="${p.id}" title="Delete patient">×</button>
</div>
`).join('');
container.querySelectorAll('.patient-tile').forEach(tile => {
tile.addEventListener('click', (e) => {
if (e.target.closest('[data-delete-patient]')) return;
selectPatient(tile.dataset.id);
});
});
container.querySelectorAll('[data-delete-patient]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = btn.dataset.deletePatient;
if (!confirm('Delete this patient and all their chart data?')) return;
try {
showSpinner(true);
await Api.deletePatient(id);
showToast('Patient deleted.');
if (currentPatientId === id) {
currentPatientId = null;
sessionStorage.removeItem('cip_patientId');
document.getElementById('patientContent').classList.add('d-none');
document.getElementById('noPatientState').classList.remove('d-none');
}
await loadPatientList();
loadDashboardStats();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
});
});
}
async function selectPatient(id) {
currentPatientId = id;
sessionStorage.setItem('cip_patientId', id);
renderPatientList();
updateStatCardsForContext();
document.getElementById('noPatientState').classList.add('d-none');
document.getElementById('patientContent').classList.remove('d-none');
resetRiskTab();
resetCdsTab();
await loadChart(id);
await loadCarePlans(id);
}
// ---------- Chart ----------
async function loadChart(id) {
try {
showSpinner(true);
const chart = await Api.getChart(id);
document.getElementById('patientName').textContent = chart.fullName;
document.getElementById('patientMeta').textContent =
`${chart.age} yrs · ${chart.gender} · ${chart.medicalRecordNumber} · Patient ID: ${chart.patientId}`;
document.getElementById('conditionsList').innerHTML = chart.conditions.length
? chart.conditions.map(c => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(c.display)}</div>
<div class="small text-muted-ci">${escapeHtml(c.code)} · ${escapeHtml(c.clinicalStatus)} · onset ${fmtDate(c.onsetDate)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="condition" data-delete-id="${c.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No active conditions on file.</div>';
document.getElementById('allergiesList').innerHTML = chart.allergies.length
? chart.allergies.map(a => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold text-danger">${escapeHtml(a.substance)}</div>
<div class="small text-muted-ci">Reaction: ${escapeHtml(a.reaction)} · Criticality: ${escapeHtml(a.criticality)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="allergy" data-delete-id="${a.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No known allergies (NKA).</div>';
document.getElementById('medicationsList').innerHTML = chart.medications.length
? chart.medications.map(m => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(m.medicationName)}</div>
<div class="small text-muted-ci">${escapeHtml(m.dosage)} · ${escapeHtml(m.status)} · authored ${fmtDate(m.authoredOn)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="medication" data-delete-id="${m.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No active medications.</div>';
document.getElementById('observationsList').innerHTML = chart.observations.length
? chart.observations.map(o => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(o.display)}: ${o.value} ${escapeHtml(o.unit)}</div>
<div class="small text-muted-ci">${fmtDate(o.effectiveDate)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="observation" data-delete-id="${o.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No recent observations.</div>';
document.getElementById('encountersList').innerHTML = chart.encounters.length
? chart.encounters.map(e => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(e.encounterType)} ${escapeHtml(e.reason)}</div>
<div class="small text-muted-ci">${fmtDate(e.encounterDate)} · ${escapeHtml(e.status)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="encounter" data-delete-id="${e.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No encounters on file.</div>';
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
// ---------- Risk ----------
function resetRiskTab() {
document.getElementById('riskForm').reset();
document.getElementById('q_pain_val').textContent = '0';
document.getElementById('riskResultEmpty').classList.remove('d-none');
document.getElementById('riskResultBody').classList.add('d-none');
}
function setupRiskForm() {
const painInput = document.getElementById('q_pain');
painInput.addEventListener('input', () => {
document.getElementById('q_pain_val').textContent = painInput.value;
});
document.getElementById('riskForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPatientId) return;
const questionnaire = {
smoker: document.getElementById('q_smoker').checked,
familyHistoryHeartDisease: document.getElementById('q_family').checked,
priorHospitalization: document.getElementById('q_hosp').checked,
bmi: document.getElementById('q_bmi').value ? parseFloat(document.getElementById('q_bmi').value) : null,
painScore: parseInt(document.getElementById('q_pain').value || '0', 10)
};
try {
showSpinner(true);
const result = await Api.assessRisk(currentPatientId, questionnaire);
renderRiskResult(result);
showToast('Risk assessment complete.');
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
});
}
function renderRiskResult(result) {
document.getElementById('riskResultEmpty').classList.add('d-none');
document.getElementById('riskResultBody').classList.remove('d-none');
const gauge = document.getElementById('scoreGauge');
gauge.textContent = result.score;
gauge.style.background = result.riskLevel === 'High'
? 'radial-gradient(circle, #e74c3c, #c0392b)'
: 'radial-gradient(circle, #27ae60, #1e8449)';
const badge = document.getElementById('riskLevelBadge');
badge.textContent = `${result.riskLevel} Risk`;
badge.className = `risk-badge ${result.riskLevel}`;
document.getElementById('riskFactorsList').innerHTML = result.contributingFactors
.map(f => `<li>${escapeHtml(f)}</li>`).join('');
}
// ---------- CDS ----------
function resetCdsTab() {
document.getElementById('cdsAlertsContainer').innerHTML =
'<div class="text-muted-ci text-center py-5">Click "Evaluate Encounter" to screen for drug-allergy conflicts and guideline gaps.</div>';
document.getElementById('alertCountBadge').classList.add('d-none');
}
async function evaluateCds() {
if (!currentPatientId) return;
try {
showSpinner(true);
const result = await Api.evaluateCds(currentPatientId);
renderCdsAlerts(result);
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
function renderCdsAlerts(result) {
const container = document.getElementById('cdsAlertsContainer');
const badge = document.getElementById('alertCountBadge');
if (!result.hasAlerts) {
container.innerHTML = '<div class="text-center py-5"><div class="fs-1">✅</div><div class="text-muted-ci">Nothing triggered — no CDS alerts for this encounter.</div></div>';
badge.classList.add('d-none');
return;
}
badge.textContent = result.alerts.length;
badge.classList.remove('d-none');
container.innerHTML = result.alerts.map(a => `
<div class="alert-card ${a.severity}" data-alert-id="${a.id}">
<div class="d-flex justify-content-between align-items-start">
<span class="severity-tag ${a.severity}">${escapeHtml(a.severity)}</span>
<span class="small text-muted-ci">${escapeHtml(a.ruleId)}</span>
</div>
<div class="fw-semibold mt-2">${escapeHtml(a.summary)}</div>
<div class="small text-muted-ci mt-1">${escapeHtml(a.recommendation)}</div>
<button class="btn btn-ci-primary btn-sm mt-3 create-careplan-btn" data-alert-id="${a.id}">
Create Care Plan &amp; Service Request
</button>
</div>
`).join('');
container.querySelectorAll('.create-careplan-btn').forEach(btn => {
btn.addEventListener('click', () => createCarePlanFromAlert(parseInt(btn.dataset.alertId, 10)));
});
}
async function createCarePlanFromAlert(alertId) {
try {
showSpinner(true);
await Api.createCarePlan({
patientId: currentPatientId,
alertId,
createServiceRequest: true,
priority: 'urgent'
});
showToast('Care Plan and Service Request created and written back to the FHIR store.');
document.querySelector(`.alert-card[data-alert-id="${alertId}"]`)?.remove();
const badge = document.getElementById('alertCountBadge');
const remaining = document.querySelectorAll('.alert-card').length;
if (remaining === 0) {
resetCdsTab();
document.getElementById('cdsAlertsContainer').innerHTML =
'<div class="text-center py-5"><div class="fs-1">✅</div><div class="text-muted-ci">All alerts for this encounter have been actioned.</div></div>';
} else {
badge.textContent = remaining;
}
await loadCarePlans(currentPatientId);
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
// ---------- Care Plans ----------
async function loadCarePlans(patientId) {
if (!patientId) return;
try {
const carePlans = await Api.getCarePlans(patientId);
const tbody = document.getElementById('carePlansTableBody');
tbody.innerHTML = carePlans.length
? carePlans.map(cp => `
<tr>
<td>${escapeHtml(cp.title)}</td>
<td><span class="small text-muted-ci">${escapeHtml(cp.triggeredByRule)}</span></td>
<td><span class="badge bg-success-subtle text-success">${escapeHtml(cp.status)}</span></td>
<td class="small text-muted-ci">${fmtDate(cp.createdDate)}</td>
</tr>`).join('')
: '<tr><td colspan="4" class="text-muted-ci text-center py-4">No care plans yet.</td></tr>';
} catch (err) {
showToast(err.message, 'danger');
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,282 +0,0 @@
using ClinicalInsightsPro.API.Data;
using ClinicalInsightsPro.API.DTOs;
using ClinicalInsightsPro.API.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace ClinicalInsightsPro.API.Services;
public class MedplumFhirService : IMedplumFhirService
{
private readonly ApplicationDbContext _db;
private readonly ILogger<MedplumFhirService> _logger;
public MedplumFhirService(
ApplicationDbContext db,
ILogger<MedplumFhirService> logger)
{
_db = db;
_logger = logger;
}
public Task<List<JsonElement>> GetMedplumResourcesAsync(string resourceType, string? patientId = null)
{
// Offline/demo mode has normalized tables, not the raw Medplum store.
// The unified Medplum browser is intentionally live-only so the UI
// always shows actual FHIR JSON when the feature is enabled.
throw new InvalidOperationException(
"The Medplum resource browser requires Fhir:UseLiveMedplum=true. " +
"Set MEDPLUM_USE_LIVE=true and configure MEDPLUM_BASE_URL, " +
"MEDPLUM_TOKEN_URL, MEDPLUM_CLIENT_ID and MEDPLUM_CLIENT_SECRET.");
}
public async Task<List<PatientSummaryDto>> GetPatientListAsync()
{
var patients = await _db.Patient
.AsNoTracking()
.OrderBy(x => x.GivenName)
.ThenBy(x => x.FamilyName)
.ToListAsync();
return patients.Select(p => new PatientSummaryDto
{
Id = p.Id,
FullName = $"{p.GivenName} {p.FamilyName}",
Age = CalculateAge(p.BirthDate),
Gender = p.Gender,
MedicalRecordNumber = p.MedicalRecordNumber
}).ToList();
}
public async Task<PatientChartDto> GetPatientChartAsync(string patientId)
{
var patient = await _db.Patient
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == patientId);
if (patient == null)
{
throw new KeyNotFoundException(
$"Patient '{patientId}' was not found.");
}
var conditions = await _db.Condition
.AsNoTracking()
.Where(x => x.PatientId == patientId)
.OrderByDescending(x => x.RecordedDate)
.ToListAsync();
var observations = await _db.Observation
.AsNoTracking()
.Where(x => x.PatientId == patientId)
.OrderByDescending(x => x.EffectiveDateTime)
.ToListAsync();
var allergies = await _db.AllergyIntolerance
.AsNoTracking()
.Where(x => x.PatientId == patientId)
.ToListAsync();
var medications = await _db.MedicationRequest
.AsNoTracking()
.Where(x => x.PatientId == patientId)
.ToListAsync();
var encounters = await _db.Encounter
.AsNoTracking()
.Where(x => x.PatientId == patientId)
.OrderByDescending(x => x.PeriodStart)
.ToListAsync();
_logger.LogInformation(
"Retrieved chart for patient {PatientId}",
patientId);
return new PatientChartDto
{
PatientId = patient.Id,
FullName = $"{patient.GivenName} {patient.FamilyName}",
Age = CalculateAge(patient.BirthDate),
MedicalRecordNumber = patient.MedicalRecordNumber,
IdentifierSystem = patient.IdentifierSystem,
IdentifierValue = patient.IdentifierValue,
NameUse = patient.NameUse,
Prefix = patient.Prefix,
GivenName = patient.GivenName,
FamilyName = patient.FamilyName,
TelecomSystem = patient.TelecomSystem,
TelecomUse = patient.TelecomUse,
TelecomValue = patient.TelecomValue,
Gender = patient.Gender,
BloodGroup = patient.BloodGroup,
Email = patient.Email,
BirthDate = patient.BirthDate,
AddressUse = patient.AddressUse,
AddressType = patient.AddressType,
AddressLine = patient.AddressLine,
City = patient.City,
State = patient.State,
PostalCode = patient.PostalCode,
MaritalStatusSystem = patient.MaritalStatusSystem,
MaritalStatusCode = patient.MaritalStatusCode,
MaritalStatusDisplay = patient.MaritalStatusDisplay,
ContactNameUse = patient.ContactNameUse,
ContactPrefix = patient.ContactPrefix,
ContactTelecomSystem = patient.ContactTelecomSystem,
ContactTelecomUse = patient.ContactTelecomUse,
ContactTelecomValue = patient.ContactTelecomValue,
ContactAddressUse = patient.ContactAddressUse,
ContactAddressType = patient.ContactAddressType,
ContactAddressLine = patient.ContactAddressLine,
ContactCity = patient.ContactCity,
ContactState = patient.ContactState,
ContactPostalCode = patient.ContactPostalCode,
ContactGender = patient.ContactGender,
ContactOrganizationId = patient.ContactOrganizationId,
ContactPeriodStart = patient.ContactPeriodStart,
ContactPeriodEnd = patient.ContactPeriodEnd,
ManagingOrganizationId = patient.ManagingOrganizationId,
PhotoContentType = patient.PhotoContentType,
PhotoUrl = patient.PhotoUrl,
PhotoTitle = patient.PhotoTitle,
GeneralPractitionerId = patient.GeneralPractitionerId,
Conditions = conditions.Select(c => new ChartConditionDto
{
Id = c.Id,
Code = c.ConditionCode,
Display = c.ConditionDisplay,
ClinicalStatus = c.ClinicalStatusDisplay,
VerificationStatus = c.VerificationStatusDisplay,
Severity = c.SeverityDisplay,
OnsetDate = c.OnsetDateTime,
AbatementDate = c.AbatementDateTime,
RecordedDate = c.RecordedDate
}).ToList(),
Allergies = allergies.Select(a => new ChartAllergyDto
{
Id = a.Id,
Type = a.Type,
ClinicalStatus = a.ClinicalStatus,
VerificationStatus = a.VerificationStatus,
Criticality = a.Criticality,
RecorderPractitionerId = a.RecorderPractitionerId
}).ToList(),
Medications = medications.Select(m => new ChartMedicationDto
{
Id = m.Id,
MedicationCode = m.MedicationCode,
MedicationDisplay = m.MedicationDisplay,
Status = m.Status,
Intent = m.Intent,
Priority = m.Priority
}).ToList(),
Observations = observations.Select(o => new ChartObservationDto
{
Id = o.Id,
Code = o.ObservationCode,
Display = o.ObservationDisplay,
Status = o.Status,
CategoryDisplay = o.CategoryDisplay,
InterpretationCode = o.InterpretationCode,
InterpretationDisplay = o.InterpretationDisplay,
EffectiveDate = o.EffectiveDateTime,
NoteText = o.NoteText
}).ToList(),
Encounters = encounters.Select(e => new ChartEncounterDto
{
Id = e.Id,
Status = e.Status,
ClassCode = e.ClassCode,
ClassDisplay = e.ClassDisplay,
TypeDisplay = e.TypeDisplay,
ServiceTypeDisplay = e.ServiceTypeDisplay,
PeriodStart = e.PeriodStart,
PeriodEnd = e.PeriodEnd
}).ToList()
};
}
private static int CalculateAge(DateTime birthDate)
{
var today = DateTime.UtcNow;
var age = today.Year - birthDate.Year;
if (birthDate.Date > today.AddYears(-age))
{
age--;
}
return age;
}
// ---- Two-way sync additions ----
// This class is the OFFLINE/local implementation (used when
// Fhir:UseLiveMedplum=false) — it reads/writes only the local database
// and never talks to a real Medplum server, so there is nothing to push
// or pull here. These methods exist only so this class still satisfies
// IMedplumFhirService; they throw/no-op with a clear message instead of
// silently pretending to sync. Set MEDPLUM_USE_LIVE=true in .env to
// switch the app over to LiveMedplumFhirService, which implements the
// real sync against Medplum's FHIR API.
public Task<string> CreatePatientInMedplumAsync(Models.Patient patient)
{
_logger.LogWarning(
"CreatePatientInMedplumAsync called while running in offline/local mode " +
"(Fhir:UseLiveMedplum=false) — patient {Id} was saved locally only, not pushed to Medplum. " +
"Set MEDPLUM_USE_LIVE=true in .env to enable Medplum sync.", patient.Id);
return Task.FromResult(string.Empty);
}
public Task UpdatePatientInMedplumAsync(Models.Patient patient)
{
_logger.LogWarning(
"UpdatePatientInMedplumAsync called while running in offline/local mode " +
"(Fhir:UseLiveMedplum=false) — patient {Id} was updated locally only, not pushed to Medplum.", patient.Id);
return Task.CompletedTask;
}
public Task DeletePatientInMedplumAsync(string medplumId)
{
_logger.LogWarning(
"DeletePatientInMedplumAsync called while running in offline/local mode " +
"(Fhir:UseLiveMedplum=false) — nothing was deleted in Medplum.");
return Task.CompletedTask;
}
public Task<List<Models.Patient>> PullAllPatientsFromMedplumAsync()
{
_logger.LogWarning(
"PullAllPatientsFromMedplumAsync called while running in offline/local mode " +
"(Fhir:UseLiveMedplum=false) — returning an empty list. Set MEDPLUM_USE_LIVE=true in .env " +
"to pull real data from Medplum.");
return Task.FromResult(new List<Models.Patient>());
}
}

View File

@ -0,0 +1,193 @@
using ClinicalInsightsPro.API.Models;
namespace ClinicalInsightsPro.API.DTOs;
/// <summary>
/// Everything the dashboard's Chart tab needs in ONE payload.
///
/// The demographic fields at the top are the patient header; the five lists
/// below are the chart panels. dashboard.js reads chart.conditions.length,
/// chart.allergies.length, etc. — so these lists must ALWAYS be non-null,
/// even when empty. That is why each one is initialised to an empty list
/// instead of being left null.
/// </summary>
public class PatientChartDto
{
// ---- Patient header (shown above the chart panels) ----
public string PatientId { get; set; } = string.Empty;
public string FullName { get; set; } = string.Empty;
public int Age { get; set; }
public string MedicalRecordNumber { get; set; } = string.Empty;
public string IdentifierSystem { get; set; } = string.Empty;
public string IdentifierValue { get; set; } = string.Empty;
public string NameUse { get; set; } = string.Empty;
public string? Prefix { get; set; }
public string GivenName { get; set; } = string.Empty;
public string FamilyName { get; set; } = string.Empty;
public string TelecomSystem { get; set; } = "phone";
public string TelecomUse { get; set; } = "work";
public string TelecomValue { get; set; } = string.Empty;
public string Gender { get; set; } = string.Empty;
// Stored on Patient (not a FHIR field) so the dashboard can show them.
public string? BloodGroup { get; set; }
public string? Email { get; set; }
public DateTime BirthDate { get; set; }
public string AddressUse { get; set; } = "home";
public string AddressType { get; set; } = "both";
public string? AddressLine { get; set; }
public string City { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
public string? MaritalStatusSystem { get; set; }
public string? MaritalStatusCode { get; set; }
public string? MaritalStatusDisplay { get; set; }
public string? ContactNameUse { get; set; }
public string? ContactPrefix { get; set; }
public string? ContactTelecomSystem { get; set; }
public string? ContactTelecomUse { get; set; }
public string? ContactTelecomValue { get; set; }
public string? ContactAddressUse { get; set; }
public string? ContactAddressType { get; set; }
public string? ContactAddressLine { get; set; }
public string? ContactCity { get; set; }
public string? ContactState { get; set; }
public string? ContactPostalCode { get; set; }
public string? ContactGender { get; set; }
public string? ContactOrganizationId { get; set; }
public DateTime? ContactPeriodStart { get; set; }
public DateTime? ContactPeriodEnd { get; set; }
public string? ManagingOrganizationId { get; set; }
public string? PhotoContentType { get; set; }
public string? PhotoUrl { get; set; }
public string? PhotoTitle { get; set; }
public string? GeneralPractitionerId { get; set; }
// ---- Chart panels ----
public List<ChartConditionDto> Conditions { get; set; } = new();
public List<ChartAllergyDto> Allergies { get; set; } = new();
public List<ChartMedicationDto> Medications { get; set; } = new();
public List<ChartObservationDto> Observations { get; set; } = new();
public List<ChartEncounterDto> Encounters { get; set; } = new();
}
// Each chart row DTO mirrors the columns that actually exist on the matching
// entity — no invented "substance"/"dosage"/"value" fields, so what the UI
// shows is exactly what is stored in PostgreSQL.
public class ChartConditionDto
{
public string Id { get; set; } = string.Empty;
public string Code { get; set; } = string.Empty;
public string Display { get; set; } = string.Empty;
public string ClinicalStatus { get; set; } = string.Empty;
public string VerificationStatus { get; set; } = string.Empty;
public string Severity { get; set; } = string.Empty;
public DateTime? OnsetDate { get; set; }
public DateTime? AbatementDate { get; set; }
public DateTime? RecordedDate { get; set; }
}
public class ChartAllergyDto
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string ClinicalStatus { get; set; } = string.Empty;
public string VerificationStatus { get; set; } = string.Empty;
public string? Criticality { get; set; }
public string? RecorderPractitionerId { get; set; }
}
public class ChartMedicationDto
{
public string Id { get; set; } = string.Empty;
public string MedicationCode { get; set; } = string.Empty;
public string? MedicationDisplay { get; set; }
public string Status { get; set; } = string.Empty;
public string Intent { get; set; } = string.Empty;
public string? Priority { get; set; }
}
public class ChartObservationDto
{
public string Id { get; set; } = string.Empty;
public string Code { get; set; } = string.Empty;
public string? Display { get; set; }
public string Status { get; set; } = string.Empty;
public string? CategoryDisplay { get; set; }
public string? InterpretationCode { get; set; }
public string? InterpretationDisplay { get; set; }
public DateTimeOffset? EffectiveDate { get; set; }
public string? NoteText { get; set; }
}
public class ChartEncounterDto
{
public string Id { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string ClassCode { get; set; } = string.Empty;
public string ClassDisplay { get; set; } = string.Empty;
public string? TypeDisplay { get; set; }
public string? ServiceTypeDisplay { get; set; }
public DateTime PeriodStart { get; set; }
public DateTime PeriodEnd { get; set; }
}
public class PatientSummaryDto
{
public string Id { get; set; } = string.Empty;
public string FullName { get; set; } = string.Empty;
public int Age { get; set; }
public string Gender { get; set; } = string.Empty;
public string MedicalRecordNumber { get; set; } = string.Empty;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,87 @@
// Single source of truth for the left navigation on EVERY page.
//
// Why this file self-initialises:
// The old version only drew the menu when a page explicitly called
// renderSidebar('key'). Only dashboard.js and resource-page.js did that, so
// any page with its own custom script (or its own hardcoded <aside> markup)
// ended up showing a stale, shorter menu — or none at all. Now the script
// runs itself on DOMContentLoaded and OVERWRITES whatever is in the sidebar
// container, so simply including this file is enough to get the full list.
//
// Adding a nav item? Add it to SIDEBAR_ITEMS below and it appears everywhere.
const SIDEBAR_ITEMS = [
{ key: 'dashboard', href: 'dashboard.html', icon: '🏠', label: 'Dashboard' },
{ key: 'patients', href: 'patients.html', icon: '🧑\u200d⚕', label: 'Patients' },
{ key: 'conditions', href: 'conditions.html', icon: '🩺', label: 'Conditions' },
{ key: 'observations', href: 'observations.html', icon: '📈', label: 'Observations' },
{ key: 'allergies', href: 'allergies.html', icon: '⚠️', label: 'Allergies' },
{ key: 'medications', href: 'medications.html', icon: '💊', label: 'Medications' },
{ key: 'encounters', href: 'encounters.html', icon: '📅', label: 'Encounters' },
{ key: 'organizations', href: 'organizations.html', icon: '🏥', label: 'Organizations' },
{ key: 'locations', href: 'locations.html', icon: '📍', label: 'Locations' },
{ key: 'practitioners', href: 'practitioners.html', icon: '👨‍⚕️', label: 'Practitioners' },
{ key: 'practitionerroles', href: 'practitionerroles.html', icon: '🩹', label: 'Practitioner Roles' },
{ key: 'servicerequests', href: 'servicerequests.html', icon: '📄', label: 'Service Requests' },
{ key: 'riskassessments', href: 'riskassessments.html', icon: '📊', label: 'Risk Assessment' },
{ key: 'cdsalerts', href: 'cdsalerts.html', icon: '🔔', label: 'CDS Alerts' },
{ key: 'careplans', href: 'careplans.html', icon: '📋', label: 'Care Plans' },
{ key: 'cdsrules', href: 'cdsrules.html', icon: '⚙️', label: 'CDS Rules' },
{ key: 'users', href: 'users.html', icon: '👤', label: 'Users' }
];
// Pages that deliberately have no sidebar (public / auth screens).
const SIDEBAR_EXCLUDED_PAGES = ['index.html', 'login.html', 'register.html', 'callback.html'];
// Works out which item to highlight from the URL, so a page doesn't have to
// pass its key in. An explicit key still wins when one is supplied.
function sidebarKeyFromUrl() {
const file = (window.location.pathname.split('/').pop() || 'dashboard.html').toLowerCase();
const match = SIDEBAR_ITEMS.find(i => i.href.toLowerCase() === file);
return match ? match.key : '';
}
function renderSidebar(activeKey) {
// Accept either #appSidebar or any element carrying .app-sidebar, so pages
// that hardcoded their own <aside> still get taken over.
const container =
document.getElementById('appSidebar') ||
document.querySelector('.app-sidebar');
if (!container) return;
const key = activeKey || sidebarKeyFromUrl();
const collapsed = localStorage.getItem('cip_sidebar_collapsed') === '1';
container.innerHTML = `
<button class="sidebar-toggle" id="sidebarToggle" type="button"
title="Collapse / expand menu" aria-label="Toggle navigation"></button>
<nav class="sidebar-nav">
${SIDEBAR_ITEMS.map(i => `
<a href="${i.href}" class="sidebar-link ${i.key === key ? 'active' : ''}" title="${i.label}">
<span class="sidebar-icon">${i.icon}</span><span class="sidebar-text">${i.label}</span>
</a>`).join('')}
</nav>
`;
container.dataset.sidebarRendered = '1';
applySidebarState(collapsed);
document.getElementById('sidebarToggle').addEventListener('click', () => {
const nowCollapsed = !document.body.classList.contains('sidebar-collapsed');
applySidebarState(nowCollapsed);
localStorage.setItem('cip_sidebar_collapsed', nowCollapsed ? '1' : '0');
});
}
function applySidebarState(collapsed) {
document.body.classList.toggle('sidebar-collapsed', collapsed);
}
// Auto-run. If a page's own script calls renderSidebar() later with an
// explicit key, that simply redraws with the same list — harmless.
document.addEventListener('DOMContentLoaded', () => {
const file = (window.location.pathname.split('/').pop() || '').toLowerCase();
if (SIDEBAR_EXCLUDED_PAGES.includes(file)) return;
renderSidebar();
});

Binary file not shown.

View File

@ -1,802 +0,0 @@
let currentPatientId = null;
let patientsCache = [];
let patientSearchTerm = '';
function showSpinner(show) {
document.getElementById('spinnerOverlay').style.display = show ? 'flex' : 'none';
}
function showToast(message, variant = 'success') {
const container = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = `toast align-items-center text-bg-${variant} border-0 show mb-2`;
el.innerHTML = `<div class="d-flex">
<div class="toast-body">${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 escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str ?? '';
return div.innerHTML;
}
function fmtDate(dateStr) {
if (!dateStr) return '—';
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
// ---------- Dynamic resource forms ----------
// Field config per FHIR resource type. Drives the generic #addResourceModal
// so patients, conditions, observations, allergies, medications and
// encounters can all be added from the UI and hit the CRUD API — nothing
// here is limited to the three seeded demo patients.
const RESOURCE_FORMS = {
condition: {
title: 'Add Condition',
fields: [
{ key: 'code', label: 'Code (ICD-10, e.g. I10)', type: 'text', required: true },
{ key: 'display', label: 'Display name', type: 'text', required: true },
{ key: 'clinicalStatus', label: 'Clinical status', type: 'select', options: ['active', 'inactive', 'resolved', 'remission'], default: 'active' },
{ key: 'onsetDate', label: 'Onset date', type: 'date', required: true }
],
add: (patientId, payload) => Api.addCondition(patientId, payload)
},
observation: {
title: 'Add Observation',
fields: [
{ key: 'code', label: 'Code (LOINC, e.g. 8480-6)', type: 'text', required: true },
{ key: 'display', label: 'Display name', type: 'text', required: true },
{ key: 'value', label: 'Value', type: 'number', required: true },
{ key: 'unit', label: 'Unit', type: 'text', required: true },
{ key: 'effectiveDate', label: 'Effective date/time', type: 'datetime-local', required: true }
],
add: (patientId, payload) => Api.addObservation(patientId, payload)
},
allergy: {
title: 'Add Allergy / Intolerance',
fields: [
{ key: 'substance', label: 'Substance', type: 'text', required: true },
{ key: 'reaction', label: 'Reaction', type: 'text' },
{ key: 'criticality', label: 'Criticality', type: 'select', options: ['low', 'high', 'unable-to-assess'], default: 'low' }
],
add: (patientId, payload) => Api.addAllergy(patientId, payload)
},
medication: {
title: 'Add Medication',
fields: [
{ key: 'medicationName', label: 'Medication name', type: 'text', required: true },
{ key: 'dosage', label: 'Dosage', type: 'text' },
{ key: 'status', label: 'Status', type: 'select', options: ['active', 'completed', 'cancelled', 'on-hold', 'stopped'], default: 'active' },
{ key: 'authoredOn', label: 'Authored on', type: 'date' }
],
add: (patientId, payload) => Api.addMedication(patientId, payload)
},
encounter: {
title: 'Add Encounter',
fields: [
{ key: 'encounterType', label: 'Encounter type', type: 'text', required: true, placeholder: 'e.g. Outpatient' },
{ key: 'reason', label: 'Reason', type: 'text' },
{ key: 'encounterDate', label: 'Date/time', type: 'datetime-local', required: true },
{ key: 'status', label: 'Status', type: 'select', options: ['planned', 'in-progress', 'finished', 'cancelled'], default: 'in-progress' }
],
add: (patientId, payload) => Api.addEncounter(patientId, payload)
}
};
let activeResourceType = null;
let newPatientModal, addResourceModal;
function openResourceModal(type) {
if (!currentPatientId) {
showToast('Select a patient first.', 'danger');
return;
}
activeResourceType = type;
const config = RESOURCE_FORMS[type];
document.getElementById('addResourceModalTitle').textContent = config.title;
const body = document.getElementById('addResourceModalBody');
body.innerHTML = config.fields.map(f => {
const id = `res_${f.key}`;
if (f.type === 'select') {
return `<div class="col-12">
<label class="form-label small">${escapeHtml(f.label)}</label>
<select class="form-select" id="${id}">
${f.options.map(o => `<option value="${o}" ${o === f.default ? 'selected' : ''}>${o}</option>`).join('')}
</select>
</div>`;
}
return `<div class="col-12">
<label class="form-label small">${escapeHtml(f.label)}</label>
<input type="${f.type}" ${f.required ? 'required' : ''} class="form-control" id="${id}" placeholder="${escapeHtml(f.placeholder || '')}">
</div>`;
}).join('');
addResourceModal.show();
}
// ---------- Dashboard stats + resource browser ----------
// Aggregates every patient's chart + care plans into flat arrays (each item
// tagged with its owning patient) so the top stat cards can show real
// counts, and clicking one can browse that resource across ALL patients —
// not just the one currently selected.
let resourceCache = { conditions: [], observations: [], allergies: [], medications: [], encounters: [], careplans: [] };
let resourceBrowserModal;
async function loadDashboardStats() {
resourceCache = { conditions: [], observations: [], allergies: [], medications: [], encounters: [], careplans: [] };
try {
const [chartResults, carePlanResults] = await Promise.all([
Promise.all(patientsCache.map(p => Api.getChart(p.id).catch(() => null))),
Promise.all(patientsCache.map(p => Api.getCarePlans(p.id).catch(() => [])))
]);
patientsCache.forEach((p, idx) => {
const chart = chartResults[idx];
if (chart) {
chart.conditions.forEach(c => resourceCache.conditions.push({ ...c, patientId: p.id, patientName: p.fullName }));
chart.observations.forEach(o => resourceCache.observations.push({ ...o, patientId: p.id, patientName: p.fullName }));
chart.allergies.forEach(a => resourceCache.allergies.push({ ...a, patientId: p.id, patientName: p.fullName }));
chart.medications.forEach(m => resourceCache.medications.push({ ...m, patientId: p.id, patientName: p.fullName }));
chart.encounters.forEach(e => resourceCache.encounters.push({ ...e, patientId: p.id, patientName: p.fullName }));
}
(carePlanResults[idx] || []).forEach(cp => resourceCache.careplans.push({ ...cp, patientId: p.id, patientName: p.fullName }));
});
} catch {
// Stats are a nice-to-have on top of the core dashboard — don't block
// the rest of the page if aggregation fails for some reason.
}
updateStatCardsForContext();
}
// Returns the patients matching the current search box text (or everyone,
// if the box is empty). Shared by the sidebar list and the stat cards so
// they always agree on "what's currently in view".
function getFilteredPatients() {
const term = patientSearchTerm.trim().toLowerCase();
if (!term) return patientsCache;
return patientsCache.filter(p =>
(p.fullName || '').toLowerCase().includes(term) ||
(p.id || '').toLowerCase().includes(term) ||
(p.medicalRecordNumber || '').toLowerCase().includes(term));
}
// Drives the top stat cards. Three states:
// 1. A patient is selected (currentPatientId set) -> counts for just them.
// 2. Nothing selected but the search box has text -> counts for the
// patients currently matching the search.
// 3. Neither -> counts across everyone.
function updateStatCardsForContext() {
const setCount = (id, value) => { const el = document.getElementById(id); if (el) el.textContent = value; };
const scopeLabel = document.getElementById('statsScopeLabel');
let activePatients;
let scopeText = null;
if (currentPatientId) {
const selected = patientsCache.find(p => p.id === currentPatientId);
activePatients = selected ? [selected] : [];
if (selected) scopeText = `Showing data for <strong>${escapeHtml(selected.fullName)}</strong>`;
} else {
activePatients = getFilteredPatients();
if (patientSearchTerm.trim()) {
scopeText = `Showing ${activePatients.length} of ${patientsCache.length} patients matching "${escapeHtml(patientSearchTerm.trim())}"`;
}
}
const activeIds = new Set(activePatients.map(p => p.id));
setCount('stat_patients', activePatients.length);
setCount('stat_conditions', resourceCache.conditions.filter(c => activeIds.has(c.patientId)).length);
setCount('stat_medications', resourceCache.medications.filter(m => activeIds.has(m.patientId)).length);
setCount('stat_allergies', resourceCache.allergies.filter(a => activeIds.has(a.patientId)).length);
setCount('stat_observations', resourceCache.observations.filter(o => activeIds.has(o.patientId)).length);
setCount('stat_encounters', resourceCache.encounters.filter(e => activeIds.has(e.patientId)).length);
setCount('stat_careplans', resourceCache.careplans.filter(cp => activeIds.has(cp.patientId)).length);
if (scopeLabel) {
if (scopeText) {
scopeLabel.innerHTML = `${scopeText} · <a href="#" id="clearStatsScopeLink">Show all patients</a>`;
scopeLabel.classList.remove('d-none');
} else {
scopeLabel.innerHTML = '';
scopeLabel.classList.add('d-none');
}
}
}
function patientChipHtml(patientId, name) {
return `<span class="patient-chip" data-jump-patient="${patientId}">👤 ${escapeHtml(name)}</span>`;
}
const RESOURCE_BROWSER_CONFIG = {
patients: {
title: 'All Patients',
columns: ['Name', 'Age', 'Gender', 'MRN'],
rows: () => patientsCache.map(p => [
patientChipHtml(p.id, p.fullName), p.age, escapeHtml(p.gender), escapeHtml(p.medicalRecordNumber)
])
},
conditions: {
title: 'All Conditions',
columns: ['Patient', 'Display', 'Code', 'Status', 'Onset'],
rows: () => resourceCache.conditions.map(c => [
patientChipHtml(c.patientId, c.patientName), escapeHtml(c.display), escapeHtml(c.code),
escapeHtml(c.clinicalStatus), fmtDate(c.onsetDate)
])
},
medications: {
title: 'All Medications',
columns: ['Patient', 'Medication', 'Dosage', 'Status', 'Authored'],
rows: () => resourceCache.medications.map(m => [
patientChipHtml(m.patientId, m.patientName), escapeHtml(m.medicationName), escapeHtml(m.dosage),
escapeHtml(m.status), fmtDate(m.authoredOn)
])
},
allergies: {
title: 'All Allergies',
columns: ['Patient', 'Substance', 'Reaction', 'Criticality'],
rows: () => resourceCache.allergies.map(a => [
patientChipHtml(a.patientId, a.patientName), escapeHtml(a.substance), escapeHtml(a.reaction), escapeHtml(a.criticality)
])
},
observations: {
title: 'All Observations',
columns: ['Patient', 'Display', 'Value', 'Date'],
rows: () => resourceCache.observations.map(o => [
patientChipHtml(o.patientId, o.patientName), escapeHtml(o.display), `${o.value} ${escapeHtml(o.unit)}`, fmtDate(o.effectiveDate)
])
},
encounters: {
title: 'All Encounters',
columns: ['Patient', 'Type', 'Reason', 'Date', 'Status'],
rows: () => resourceCache.encounters.map(e => [
patientChipHtml(e.patientId, e.patientName), escapeHtml(e.encounterType), escapeHtml(e.reason),
fmtDate(e.encounterDate), escapeHtml(e.status)
])
},
careplans: {
title: 'All Care Plans',
columns: ['Patient', 'Title', 'Triggered By', 'Status', 'Created'],
rows: () => resourceCache.careplans.map(cp => [
patientChipHtml(cp.patientId, cp.patientName), escapeHtml(cp.title), escapeHtml(cp.triggeredByRule),
escapeHtml(cp.status), fmtDate(cp.createdDate)
])
}
};
function openResourceBrowser(resourceType) {
const config = RESOURCE_BROWSER_CONFIG[resourceType];
if (!config) return;
document.getElementById('resourceBrowserTitle').textContent = config.title;
document.getElementById('resourceBrowserHead').innerHTML = config.columns.map(c => `<th>${c}</th>`).join('');
const rows = config.rows();
const body = document.getElementById('resourceBrowserBody');
const emptyState = document.getElementById('resourceBrowserEmpty');
if (rows.length === 0) {
body.innerHTML = '';
emptyState.classList.remove('d-none');
} else {
emptyState.classList.add('d-none');
body.innerHTML = rows.map(cells => `<tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr>`).join('');
}
resourceBrowserModal.show();
}
async function submitResourceForm(e) {
e.preventDefault();
const config = RESOURCE_FORMS[activeResourceType];
if (!config) return;
const payload = {};
for (const f of config.fields) {
const el = document.getElementById(`res_${f.key}`);
let val = el.value;
if (f.type === 'number') val = val === '' ? null : parseFloat(val);
payload[f.key] = val === '' ? null : val;
}
try {
showSpinner(true);
await config.add(currentPatientId, payload);
addResourceModal.hide();
showToast(`${config.title.replace('Add ', '')} added.`);
await loadChart(currentPatientId);
loadDashboardStats();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
async function deleteResource(type, id) {
if (!currentPatientId || !confirm('Delete this item?')) return;
const deleteFns = {
condition: Api.deleteCondition,
observation: Api.deleteObservation,
allergy: Api.deleteAllergy,
medication: Api.deleteMedication,
encounter: Api.deleteEncounter
};
try {
showSpinner(true);
await deleteFns[type](currentPatientId, id);
showToast('Deleted.');
await loadChart(currentPatientId);
loadDashboardStats();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
function setupDynamicDataControls() {
newPatientModal = new bootstrap.Modal(document.getElementById('newPatientModal'));
addResourceModal = new bootstrap.Modal(document.getElementById('addResourceModal'));
resourceBrowserModal = new bootstrap.Modal(document.getElementById('resourceBrowserModal'));
document.getElementById('newPatientBtn').addEventListener('click', () => newPatientModal.show());
// Live search: filters the cached patient list by name, patient ID, or
// MRN as the user types — no server round-trip needed.
document.getElementById('patientSearchInput')?.addEventListener('input', (e) => {
patientSearchTerm = e.target.value;
renderPatientList();
updateStatCardsForContext();
});
document.getElementById('newPatientForm').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
firstName: document.getElementById('np_firstName').value,
lastName: document.getElementById('np_lastName').value,
dateOfBirth: document.getElementById('np_dob').value,
gender: document.getElementById('np_gender').value,
medicalRecordNumber: document.getElementById('np_mrn').value || null
};
try {
showSpinner(true);
const created = await Api.createPatient(payload);
newPatientModal.hide();
document.getElementById('newPatientForm').reset();
showToast('Patient created.');
await loadPatientList();
loadDashboardStats();
selectPatient(created.patient.id);
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
});
document.getElementById('addResourceForm').addEventListener('submit', submitResourceForm);
// Stat cards -> open the "browse this resource across all patients" modal.
document.getElementById('statsRow').addEventListener('click', (e) => {
const card = e.target.closest('.stat-card');
if (card) openResourceBrowser(card.dataset.resource);
});
// "Show all patients" link inside the stats scope label — clears both
// the search box and the selected patient, reverting the stat cards to
// totals across everyone.
document.addEventListener('click', (e) => {
if (e.target.id !== 'clearStatsScopeLink') return;
e.preventDefault();
patientSearchTerm = '';
const input = document.getElementById('patientSearchInput');
if (input) input.value = '';
currentPatientId = null;
sessionStorage.removeItem('cip_patientId');
document.getElementById('patientContent')?.classList.add('d-none');
document.getElementById('noPatientState')?.classList.remove('d-none');
renderPatientList();
updateStatCardsForContext();
});
// Inside the resource browser: clicking a patient chip jumps straight to
// that patient's Chart tab.
document.getElementById('resourceBrowserBody').addEventListener('click', (e) => {
const chip = e.target.closest('[data-jump-patient]');
if (!chip) return;
resourceBrowserModal.hide();
selectPatient(chip.dataset.jumpPatient);
document.querySelectorAll('#dashboardTabs .nav-link').forEach(b => b.classList.remove('active'));
document.querySelector('#dashboardTabs .nav-link[data-tab="chart"]')?.classList.add('active');
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('d-none'));
document.getElementById('tab-chart')?.classList.remove('d-none');
});
// Event delegation: "+ Add" buttons in the chart tab, and per-item delete
// buttons rendered inside the chart lists (see loadChart()).
document.getElementById('tab-chart').addEventListener('click', (e) => {
const addBtn = e.target.closest('[data-add]');
if (addBtn) { openResourceModal(addBtn.dataset.add); return; }
const delBtn = e.target.closest('[data-delete-type]');
if (delBtn) { deleteResource(delBtn.dataset.deleteType, parseInt(delBtn.dataset.deleteId, 10)); }
});
}
// ---------- Init ----------
document.addEventListener('DOMContentLoaded', async () => {
const token = sessionStorage.getItem('cip_token');
if (!token) {
window.location.href = 'login.html';
return;
}
document.getElementById('clinicianLabel').textContent =
`👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
document.getElementById('logoutBtn').addEventListener('click', () => {
sessionStorage.clear();
window.location.href = 'index.html';
});
setupTabs();
setupRiskForm();
setupDynamicDataControls();
document.getElementById('evaluateCdsBtn').addEventListener('click', evaluateCds);
document.getElementById('refreshCarePlansBtn').addEventListener('click', () => loadCarePlans(currentPatientId));
await loadPatientList();
loadDashboardStats();
const launchPatientId = sessionStorage.getItem('cip_patientId');
if (launchPatientId) {
selectPatient(launchPatientId);
}
});
function setupTabs() {
document.querySelectorAll('#dashboardTabs .nav-link').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#dashboardTabs .nav-link').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('d-none'));
document.getElementById(`tab-${btn.dataset.tab}`).classList.remove('d-none');
});
});
}
// ---------- Patients ----------
async function loadPatientList() {
try {
showSpinner(true);
patientsCache = await Api.getPatients();
renderPatientList();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
function renderPatientList() {
const container = document.getElementById('patientList');
const emptyState = document.getElementById('patientSearchEmpty');
const emptyTerm = document.getElementById('patientSearchEmptyTerm');
const term = patientSearchTerm.trim();
const filtered = getFilteredPatients();
if (filtered.length === 0 && term) {
container.innerHTML = '';
container.classList.add('d-none');
if (emptyTerm) emptyTerm.textContent = patientSearchTerm.trim();
emptyState?.classList.remove('d-none');
return;
}
container.classList.remove('d-none');
emptyState?.classList.add('d-none');
container.innerHTML = filtered.map(p => `
<div class="card-ci patient-tile p-2 mb-2 d-flex justify-content-between align-items-start ${p.id === currentPatientId ? 'active' : ''}" data-id="${p.id}">
<div>
<div class="fw-semibold">${escapeHtml(p.fullName)}</div>
<div class="small text-muted-ci">${p.age} yrs · ${escapeHtml(p.gender)} · ${escapeHtml(p.medicalRecordNumber)}</div>
<div class="small text-muted-ci">ID: ${escapeHtml(p.id)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-patient="${p.id}" title="Delete patient">×</button>
</div>
`).join('');
container.querySelectorAll('.patient-tile').forEach(tile => {
tile.addEventListener('click', (e) => {
if (e.target.closest('[data-delete-patient]')) return;
selectPatient(tile.dataset.id);
});
});
container.querySelectorAll('[data-delete-patient]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = btn.dataset.deletePatient;
if (!confirm('Delete this patient and all their chart data?')) return;
try {
showSpinner(true);
await Api.deletePatient(id);
showToast('Patient deleted.');
if (currentPatientId === id) {
currentPatientId = null;
sessionStorage.removeItem('cip_patientId');
document.getElementById('patientContent').classList.add('d-none');
document.getElementById('noPatientState').classList.remove('d-none');
}
await loadPatientList();
loadDashboardStats();
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
});
});
}
async function selectPatient(id) {
currentPatientId = id;
sessionStorage.setItem('cip_patientId', id);
renderPatientList();
updateStatCardsForContext();
document.getElementById('noPatientState').classList.add('d-none');
document.getElementById('patientContent').classList.remove('d-none');
resetRiskTab();
resetCdsTab();
await loadChart(id);
await loadCarePlans(id);
}
// ---------- Chart ----------
async function loadChart(id) {
try {
showSpinner(true);
const chart = await Api.getChart(id);
document.getElementById('patientName').textContent = chart.fullName;
document.getElementById('patientMeta').textContent =
`${chart.age} yrs · ${chart.gender} · ${chart.medicalRecordNumber} · Patient ID: ${chart.patientId}`;
document.getElementById('conditionsList').innerHTML = chart.conditions.length
? chart.conditions.map(c => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(c.display)}</div>
<div class="small text-muted-ci">${escapeHtml(c.code)} · ${escapeHtml(c.clinicalStatus)} · onset ${fmtDate(c.onsetDate)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="condition" data-delete-id="${c.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No active conditions on file.</div>';
document.getElementById('allergiesList').innerHTML = chart.allergies.length
? chart.allergies.map(a => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold text-danger">${escapeHtml(a.substance)}</div>
<div class="small text-muted-ci">Reaction: ${escapeHtml(a.reaction)} · Criticality: ${escapeHtml(a.criticality)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="allergy" data-delete-id="${a.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No known allergies (NKA).</div>';
document.getElementById('medicationsList').innerHTML = chart.medications.length
? chart.medications.map(m => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(m.medicationName)}</div>
<div class="small text-muted-ci">${escapeHtml(m.dosage)} · ${escapeHtml(m.status)} · authored ${fmtDate(m.authoredOn)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="medication" data-delete-id="${m.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No active medications.</div>';
document.getElementById('observationsList').innerHTML = chart.observations.length
? chart.observations.map(o => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(o.display)}: ${o.value} ${escapeHtml(o.unit)}</div>
<div class="small text-muted-ci">${fmtDate(o.effectiveDate)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="observation" data-delete-id="${o.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No recent observations.</div>';
document.getElementById('encountersList').innerHTML = chart.encounters.length
? chart.encounters.map(e => `
<div class="chart-list-item d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${escapeHtml(e.encounterType)} ${escapeHtml(e.reason)}</div>
<div class="small text-muted-ci">${fmtDate(e.encounterDate)} · ${escapeHtml(e.status)}</div>
</div>
<button class="btn btn-sm btn-outline-danger" data-delete-type="encounter" data-delete-id="${e.id}">×</button>
</div>`).join('')
: '<div class="text-muted-ci">No encounters on file.</div>';
document.getElementById('validationNotes').innerHTML = chart.validationNotes.length
? chart.validationNotes.map(n => `<div class="validation-note">✓ ${escapeHtml(n)}</div>`).join('')
: '<div class="text-muted-ci">No normalization actions were required.</div>';
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
// ---------- Risk ----------
function resetRiskTab() {
document.getElementById('riskForm').reset();
document.getElementById('q_pain_val').textContent = '0';
document.getElementById('riskResultEmpty').classList.remove('d-none');
document.getElementById('riskResultBody').classList.add('d-none');
}
function setupRiskForm() {
const painInput = document.getElementById('q_pain');
painInput.addEventListener('input', () => {
document.getElementById('q_pain_val').textContent = painInput.value;
});
document.getElementById('riskForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPatientId) return;
const questionnaire = {
smoker: document.getElementById('q_smoker').checked,
familyHistoryHeartDisease: document.getElementById('q_family').checked,
priorHospitalization: document.getElementById('q_hosp').checked,
bmi: document.getElementById('q_bmi').value ? parseFloat(document.getElementById('q_bmi').value) : null,
painScore: parseInt(document.getElementById('q_pain').value || '0', 10)
};
try {
showSpinner(true);
const result = await Api.assessRisk(currentPatientId, questionnaire);
renderRiskResult(result);
showToast('Risk assessment complete.');
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
});
}
function renderRiskResult(result) {
document.getElementById('riskResultEmpty').classList.add('d-none');
document.getElementById('riskResultBody').classList.remove('d-none');
const gauge = document.getElementById('scoreGauge');
gauge.textContent = result.score;
gauge.style.background = result.riskLevel === 'High'
? 'radial-gradient(circle, #e74c3c, #c0392b)'
: 'radial-gradient(circle, #27ae60, #1e8449)';
const badge = document.getElementById('riskLevelBadge');
badge.textContent = `${result.riskLevel} Risk`;
badge.className = `risk-badge ${result.riskLevel}`;
document.getElementById('riskFactorsList').innerHTML = result.contributingFactors
.map(f => `<li>${escapeHtml(f)}</li>`).join('');
}
// ---------- CDS ----------
function resetCdsTab() {
document.getElementById('cdsAlertsContainer').innerHTML =
'<div class="text-muted-ci text-center py-5">Click "Evaluate Encounter" to screen for drug-allergy conflicts and guideline gaps.</div>';
document.getElementById('alertCountBadge').classList.add('d-none');
}
async function evaluateCds() {
if (!currentPatientId) return;
try {
showSpinner(true);
const result = await Api.evaluateCds(currentPatientId);
renderCdsAlerts(result);
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
function renderCdsAlerts(result) {
const container = document.getElementById('cdsAlertsContainer');
const badge = document.getElementById('alertCountBadge');
if (!result.hasAlerts) {
container.innerHTML = '<div class="text-center py-5"><div class="fs-1">✅</div><div class="text-muted-ci">Nothing triggered — no CDS alerts for this encounter.</div></div>';
badge.classList.add('d-none');
return;
}
badge.textContent = result.alerts.length;
badge.classList.remove('d-none');
container.innerHTML = result.alerts.map(a => `
<div class="alert-card ${a.severity}" data-alert-id="${a.id}">
<div class="d-flex justify-content-between align-items-start">
<span class="severity-tag ${a.severity}">${escapeHtml(a.severity)}</span>
<span class="small text-muted-ci">${escapeHtml(a.ruleId)}</span>
</div>
<div class="fw-semibold mt-2">${escapeHtml(a.summary)}</div>
<div class="small text-muted-ci mt-1">${escapeHtml(a.recommendation)}</div>
<button class="btn btn-ci-primary btn-sm mt-3 create-careplan-btn" data-alert-id="${a.id}">
Create Care Plan &amp; Service Request
</button>
</div>
`).join('');
container.querySelectorAll('.create-careplan-btn').forEach(btn => {
btn.addEventListener('click', () => createCarePlanFromAlert(parseInt(btn.dataset.alertId, 10)));
});
}
async function createCarePlanFromAlert(alertId) {
try {
showSpinner(true);
await Api.createCarePlan({
patientId: currentPatientId,
alertId,
createServiceRequest: true,
priority: 'urgent'
});
showToast('Care Plan and Service Request created and written back to the FHIR store.');
document.querySelector(`.alert-card[data-alert-id="${alertId}"]`)?.remove();
const badge = document.getElementById('alertCountBadge');
const remaining = document.querySelectorAll('.alert-card').length;
if (remaining === 0) {
resetCdsTab();
document.getElementById('cdsAlertsContainer').innerHTML =
'<div class="text-center py-5"><div class="fs-1">✅</div><div class="text-muted-ci">All alerts for this encounter have been actioned.</div></div>';
} else {
badge.textContent = remaining;
}
await loadCarePlans(currentPatientId);
} catch (err) {
showToast(err.message, 'danger');
} finally {
showSpinner(false);
}
}
// ---------- Care Plans ----------
async function loadCarePlans(patientId) {
if (!patientId) return;
try {
const carePlans = await Api.getCarePlans(patientId);
const tbody = document.getElementById('carePlansTableBody');
tbody.innerHTML = carePlans.length
? carePlans.map(cp => `
<tr>
<td>${escapeHtml(cp.title)}</td>
<td><span class="small text-muted-ci">${escapeHtml(cp.triggeredByRule)}</span></td>
<td><span class="badge bg-success-subtle text-success">${escapeHtml(cp.status)}</span></td>
<td class="small text-muted-ci">${fmtDate(cp.createdDate)}</td>
</tr>`).join('')
: '<tr><td colspan="4" class="text-muted-ci text-center py-4">No care plans yet.</td></tr>';
} catch (err) {
showToast(err.message, 'danger');
}
}

View File

@ -1,407 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard — Clinical Insight Pro</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css?v=20260828a">
</head>
<body>
<div class="spinner-overlay" id="spinnerOverlay">
<div class="spinner-border text-success" role="status" style="width:3rem;height:3rem;"></div>
</div>
<nav class="navbar navbar-expand-lg navbar-ci">
<div class="container-fluid px-4">
<a class="navbar-brand" href="index.html">🩺 Clinical Insight Pro</a>
<div class="ms-auto d-flex align-items-center gap-3">
<span class="navbar-text" id="clinicianLabel">Clinician</span>
<button class="btn btn-outline-light btn-sm" id="logoutBtn">Sign out</button>
</div>
</div>
</nav>
<div class="app-shell">
<aside class="app-sidebar" id="appSidebar"></aside>
<main class="app-main">
<div class="container-fluid px-4 my-4">
<!-- Dashboard summary — click any card to browse that resource across every patient -->
<div class="stats-row" id="statsRow">
<div class="stat-card stat-patients" data-resource="patients">
<div class="stat-icon">🧑‍⚕️</div>
<div class="stat-value" id="stat_patients"></div>
<div class="stat-label">Patients</div>
<div class="stat-arrow"></div>
</div>
<div class="stat-card stat-conditions" data-resource="conditions">
<div class="stat-icon">🩺</div>
<div class="stat-value" id="stat_conditions"></div>
<div class="stat-label">Conditions</div>
<div class="stat-arrow"></div>
</div>
<div class="stat-card stat-medications" data-resource="medications">
<div class="stat-icon">💊</div>
<div class="stat-value" id="stat_medications"></div>
<div class="stat-label">Medications</div>
<div class="stat-arrow"></div>
</div>
<div class="stat-card stat-allergies" data-resource="allergies">
<div class="stat-icon">⚠️</div>
<div class="stat-value" id="stat_allergies"></div>
<div class="stat-label">Allergies</div>
<div class="stat-arrow"></div>
</div>
<div class="stat-card stat-observations" data-resource="observations">
<div class="stat-icon">📈</div>
<div class="stat-value" id="stat_observations"></div>
<div class="stat-label">Observations</div>
<div class="stat-arrow"></div>
</div>
<div class="stat-card stat-encounters" data-resource="encounters">
<div class="stat-icon">📅</div>
<div class="stat-value" id="stat_encounters"></div>
<div class="stat-label">Encounters</div>
<div class="stat-arrow"></div>
</div>
<div class="stat-card stat-careplans" data-resource="careplans">
<div class="stat-icon">📋</div>
<div class="stat-value" id="stat_careplans"></div>
<div class="stat-label">Care Plans</div>
<div class="stat-arrow"></div>
</div>
</div>
<!-- Shows what the stat cards above are currently scoped to: a selected
patient, a search match, or (hidden) totals across everyone. -->
<div id="statsScopeLabel" class="stats-scope-label d-none mb-3"></div>
<div class="row g-4">
<!-- Patient selector -->
<div class="col-lg-3">
<div class="card-ci p-3 mb-4">
<h6 class="mb-3 d-flex justify-content-between align-items-center">
Patients
<button class="btn btn-ci-primary btn-sm" id="newPatientBtn" title="Add a new patient">+ New</button>
</h6>
<input type="text" class="form-control form-control-sm mb-2" id="patientSearchInput"
placeholder="Search by name or patient ID…" autocomplete="off">
<div id="patientList" class="patient-list-scroll">Loading…</div>
<div id="patientSearchEmpty" class="text-muted-ci small text-center py-3 d-none">No patients match "<span id="patientSearchEmptyTerm"></span>".</div>
</div>
</div>
<!-- Main content -->
<div class="col-lg-9">
<div id="noPatientState" class="card-ci p-4">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-4">
<h5 class="mb-0">CDS Alert Overview — all patients</h5>
<div class="d-flex gap-2">
<select class="form-select form-select-sm" id="overallCdsMonth" style="width:auto"></select>
<select class="form-select form-select-sm" id="overallCdsYear" style="width:auto"></select>
</div>
</div>
<div class="d-flex align-items-center gap-4 flex-wrap">
<div id="overallCdsPie" style="width:180px;height:180px;border-radius:50%;flex-shrink:0"></div>
<div>
<div class="fs-5 fw-semibold mb-2" id="overallPatientCount">— patients</div>
<div id="overallCdsLegend" class="d-flex flex-column gap-2"></div>
</div>
</div>
<div id="overallCdsEmpty" class="text-muted-ci text-center py-4 d-none">No CDS alerts for this month.</div>
</div>
<div id="patientContent" class="d-none">
<!-- Patient header -->
<div class="pt-hero mb-3">
<div class="pt-avatar-lg" id="patientAvatar"></div>
<div class="flex-grow-1">
<div style="font-size:1.3rem;font-weight:600" id="patientName"></div>
<div style="opacity:0.85;font-size:0.9rem" id="patientMeta"></div>
</div>
<span class="badge-fhir">HL7 FHIR R4 · Medplum</span>
</div>
<div class="pt-info-grid mb-4">
<div class="pt-info-tile"><div class="pt-info-label">Gender</div><div class="pt-info-value" id="patientInfoGender"></div></div>
<div class="pt-info-tile"><div class="pt-info-label">Age</div><div class="pt-info-value" id="patientInfoAge"></div></div>
<div class="pt-info-tile"><div class="pt-info-label">Blood group</div><div class="pt-info-value" id="patientInfoBlood"></div></div>
<div class="pt-info-tile"><div class="pt-info-label">Mobile</div><div class="pt-info-value" id="patientInfoMobile"></div></div>
<div class="pt-info-tile"><div class="pt-info-label">Date of birth</div><div class="pt-info-value" id="patientInfoDob"></div></div>
<div class="pt-info-tile"><div class="pt-info-label">MRN</div><div class="pt-info-value" id="patientInfoMrn"></div></div>
</div>
<ul class="nav nav-pills mb-3" id="dashboardTabs">
<li class="nav-item"><button class="nav-link active" data-tab="chart">Chart</button></li>
<li class="nav-item"><button class="nav-link" data-tab="risk">Risk Assessment</button></li>
<li class="nav-item"><button class="nav-link" data-tab="cds">CDS Alerts <span id="alertCountBadge" class="badge bg-danger d-none"></span></button></li>
<li class="nav-item"><button class="nav-link" data-tab="careplans">Care Plans</button></li>
</ul>
<!-- CHART TAB -->
<div class="tab-pane" id="tab-chart">
<div class="row g-3">
<div class="col-md-6">
<div class="card-ci p-4 mb-4">
<h6 class="d-flex justify-content-between align-items-center">Active Conditions
<button class="btn btn-outline-secondary btn-sm" data-add="condition">+ Add</button>
</h6>
<div id="conditionsList" class="mt-2 chart-list-scroll"></div>
</div>
<div class="card-ci p-4">
<h6 class="d-flex justify-content-between align-items-center">Allergies &amp; Intolerances
<button class="btn btn-outline-secondary btn-sm" data-add="allergy">+ Add</button>
</h6>
<div id="allergiesList" class="mt-2 chart-list-scroll"></div>
</div>
</div>
<div class="col-md-6">
<div class="card-ci p-4 mb-4">
<h6 class="d-flex justify-content-between align-items-center">Active Medications
<button class="btn btn-outline-secondary btn-sm" data-add="medication">+ Add</button>
</h6>
<div id="medicationsList" class="mt-2 chart-list-scroll"></div>
</div>
<div class="card-ci p-4 mb-4">
<h6 class="d-flex justify-content-between align-items-center">Recent Observations
<button class="btn btn-outline-secondary btn-sm" data-add="observation">+ Add</button>
</h6>
<div id="observationsList" class="mt-2 chart-list-scroll"></div>
</div>
<div class="card-ci p-4">
<h6 class="d-flex justify-content-between align-items-center">Encounters
<button class="btn btn-outline-secondary btn-sm" data-add="encounter">+ Add</button>
</h6>
<div id="encountersList" class="mt-2 chart-list-scroll"></div>
</div>
</div>
</div>
</div>
<!-- RISK TAB -->
<div class="tab-pane d-none" id="tab-risk">
<div class="card-ci p-4 mb-4">
<h6 class="mb-3">Assessment History</h6>
<div id="riskHistoryEmpty" class="text-muted-ci text-center py-3">No risk assessments on file yet — run the questionnaire below to create the first one.</div>
<div id="riskSummaryWrap" class="d-none d-flex align-items-center gap-4 mb-4 flex-wrap">
<div id="riskSummaryPie" style="width:100px;height:100px;border-radius:50%;flex-shrink:0"></div>
<div id="riskSummaryLegend" class="d-flex flex-column gap-1"></div>
</div>
<div id="riskHistoryBody"></div>
</div>
<div class="row g-4">
<div class="col-md-5">
<div class="card-ci p-4">
<h6 class="mb-3">Risk Questionnaire</h6>
<form id="riskForm">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="q_smoker">
<label class="form-check-label" for="q_smoker">Current smoker</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="q_family">
<label class="form-check-label" for="q_family">Family history of heart disease</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="q_hosp">
<label class="form-check-label" for="q_hosp">Hospitalized within the last 12 months</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="q_newCondition">
<label class="form-check-label" for="q_newCondition">New chronic condition diagnosed in the past 6 months</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="q_highHr">
<label class="form-check-label" for="q_highHr">Resting heart rate over 100 bpm</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="q_severeAllergy">
<label class="form-check-label" for="q_severeAllergy">History of severe/anaphylactic allergic reaction</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="q_missedMeds">
<label class="form-check-label" for="q_missedMeds">Missed medication doses recently</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="q_erVisit">
<label class="form-check-label" for="q_erVisit">ER or urgent care visit in the past 3 months</label>
</div>
<div class="mb-3">
<label class="form-label small">BMI</label>
<input type="number" step="0.1" class="form-control" id="q_bmi" placeholder="e.g. 28.4">
</div>
<div class="mb-3">
<label class="form-label small">Pain score (010)</label>
<input type="range" min="0" max="10" class="form-range" id="q_pain">
<div class="text-muted-ci small text-center" id="q_pain_val">0</div>
</div>
<div id="dynamicRiskQuestions"></div>
<button type="submit" class="btn btn-ci-primary w-100 mt-2">Run Risk Scoring</button>
</form>
</div>
</div>
<div class="col-md-7">
<div class="card-ci p-4 h-100" id="riskResultCard">
<h6 class="mb-3">Result</h6>
<div id="riskResultEmpty" class="text-muted-ci text-center py-5">Complete the questionnaire and run scoring to see results.</div>
<div id="riskResultBody" class="d-none">
<div class="text-center mb-3">
<div class="score-gauge" id="scoreGauge"></div>
<div class="mt-2"><span class="risk-badge" id="riskLevelBadge"></span></div>
</div>
<h6 class="mt-4">Contributing Factors</h6>
<ul id="riskFactorsList" class="small"></ul>
</div>
</div>
</div>
</div>
</div>
<!-- CDS TAB -->
<div class="tab-pane d-none" id="tab-cds">
<div class="card-ci p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">CDS Rules Engine</h6>
<button class="btn btn-ci-primary btn-sm" id="evaluateCdsBtn">Evaluate Encounter</button>
</div>
<div id="cdsAlertsContainer">
<div class="text-muted-ci text-center py-5">Click "Evaluate Encounter" to screen for drug-allergy conflicts and guideline gaps.</div>
</div>
</div>
</div>
<!-- CARE PLANS TAB -->
<div class="tab-pane d-none" id="tab-careplans">
<div class="card-ci p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Care Plans &amp; Service Requests</h6>
<button class="btn btn-outline-secondary btn-sm" id="refreshCarePlansBtn">Refresh</button>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Title</th>
<th>Triggered by</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody id="carePlansTableBody">
<tr><td colspan="4" class="text-muted-ci text-center py-4">No care plans yet.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<footer class="container-fluid text-center footer-ci mb-4">
Clinical Insight Pro — SMART on FHIR · HL7 FHIR R4 · Medplum · .NET 8 Web API
</footer>
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toastContainer"></div>
<!-- New Patient modal -->
<div class="modal fade" id="newPatientModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Patient</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="newPatientForm">
<div class="modal-body">
<div class="row g-2">
<div class="col-6"><label class="form-label small">First name</label><input required class="form-control" id="np_firstName"></div>
<div class="col-6"><label class="form-label small">Last name</label><input required class="form-control" id="np_lastName"></div>
<div class="col-6"><label class="form-label small">Date of birth</label><input required type="date" class="form-control" id="np_dob"></div>
<div class="col-6">
<label class="form-label small">Gender</label>
<select class="form-select" id="np_gender">
<option value="female">female</option>
<option value="male">male</option>
<option value="other">other</option>
<option value="unknown">unknown</option>
</select>
</div>
<div class="col-12"><label class="form-label small">Medical record number (optional)</label><input class="form-control" id="np_mrn"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-ci-primary">Create Patient</button>
</div>
</form>
</div>
</div>
</div>
<!-- Generic "add chart resource" modal — its fields are built dynamically
per resource type (condition/observation/allergy/medication/encounter)
by dashboard.js so one modal covers all five FHIR resource forms. -->
<div class="modal fade" id="addResourceModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addResourceModalTitle">Add</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addResourceForm">
<div class="modal-body row g-2" id="addResourceModalBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-ci-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Resource browser — shows one resource type across every patient. Opened
by clicking a stat card. Clicking a patient chip inside it jumps to
that patient's chart. -->
<div class="modal fade" id="resourceBrowserModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="resourceBrowserTitle">Browse</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="resourceBrowserLoading" class="text-center text-muted-ci py-4 d-none">Loading…</div>
<div class="table-responsive">
<table class="table table-sm resource-browser-table">
<thead>
<tr id="resourceBrowserHead"></tr>
</thead>
<tbody id="resourceBrowserBody"></tbody>
</table>
</div>
<div id="resourceBrowserEmpty" class="text-muted-ci text-center py-4 d-none">No records found.</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="js/config.js?v=20260828a"></script>
<script src="js/sidebar.js?v=20260828a"></script>
<script src="js/api.js?v=20260828a"></script>
<script src="js/dashboard.js?v=20260828a"></script>
</body>
</html>

Binary file not shown.

View File

@ -0,0 +1,504 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Patients — Clinical Insight Pro</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
<style>
/* The Add/Edit Patient form is long — scroll inside the dialog. */
#pt_modal .modal-content { max-height: 90vh; }
#pt_modal .modal-body { max-height: 70vh; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #1a1a1a #e3e9e9; }
#pt_modal .modal-body::-webkit-scrollbar { width: 12px; }
#pt_modal .modal-body::-webkit-scrollbar-track { background: #e3e9e9; border-radius: 6px; }
#pt_modal .modal-body::-webkit-scrollbar-thumb { background: #1a1a1a; border-radius: 6px; border: 2px solid #e3e9e9; }
/* Patient cards — inlined so the layout never depends on css/styles.css
being fresh (the avatars previously rendered as full-width bars because
.rp-avatar had no size). */
.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; 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; background: #eef3f3; color: #5f7373;
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.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; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-ci">
<div class="container-fluid px-4">
<a class="navbar-brand" href="dashboard.html">🩺 Clinical Insight Pro</a>
<div class="ms-auto d-flex align-items-center gap-3">
<span class="navbar-text" id="clinicianLabel">Clinician</span>
<button class="btn btn-outline-light btn-sm" id="logoutBtn">Sign out</button>
</div>
</div>
</nav>
<div class="app-shell">
<aside class="app-sidebar" id="appSidebar"></aside>
<main class="app-main">
<div class="resource-page-header">
<h4>🧑‍⚕️ Patients</h4>
<span class="text-muted-ci small" id="pt_countLabel"></span>
</div>
<div class="card-ci p-3 mb-3 d-flex flex-row align-items-center gap-3 flex-wrap">
<input type="text" class="form-control resource-search-bar" id="pt_searchInput" placeholder="Search by name or patient ID…" autocomplete="off">
<button class="btn btn-ci-primary ms-auto" id="pt_addBtn">+ Add Patient</button>
</div>
<div class="card-ci p-3">
<div class="rp-card-grid" id="pt_cardGrid"></div>
<div class="table-responsive d-none">
<table class="table table-sm resource-grid-table align-middle mb-0">
<thead>
<tr><th>Name</th><th>Age</th><th>Gender</th><th>MRN</th><th>Patient ID</th><th></th></tr>
</thead>
<tbody id="pt_gridBody"></tbody>
</table>
</div>
<div id="pt_gridEmpty" class="text-muted-ci text-center py-4 d-none">No data found.</div>
</div>
</main>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toastContainer"></div>
<div class="modal fade" id="pt_modal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="pt_modalTitle">
Add Patient
</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal">
</button>
</div>
<form id="pt_form">
<div class="modal-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">
Medical Record Number
</label>
<input type="text"
class="form-control"
id="pt_mrn"
placeholder="Leave blank — continues MRN-IN-100006, 100007, …">
</div>
<div class="col-md-6">
<label class="form-label">Blood Group</label>
<select class="form-select" id="pt_bloodGroup">
<option value=""></option>
<option>A+</option><option>A-</option>
<option>B+</option><option>B-</option>
<option>AB+</option><option>AB-</option>
<option>O+</option><option>O-</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email"
class="form-control"
id="pt_email"
placeholder="name@example.com">
</div>
<div class="col-md-6">
<label class="form-label">
Given Name
</label>
<input type="text"
class="form-control"
id="pt_givenName"
required>
</div>
<div class="col-md-6">
<label class="form-label">
Family Name
</label>
<input type="text"
class="form-control"
id="pt_familyName"
required>
</div>
<div class="col-md-6">
<label class="form-label">
Birth Date
</label>
<input type="date"
class="form-control"
id="pt_birthDate">
</div>
<div class="col-md-6">
<label class="form-label">
Gender
</label>
<select class="form-select"
id="pt_gender">
<option value="female">Female</option>
<option value="male">Male</option>
<option value="other">Other</option>
<option value="unknown">Unknown</option>
</select>
</div>
<div class="col-12">
<label class="form-label">
Phone Number
</label>
<input type="text"
class="form-control"
id="pt_phone">
</div>
<div class="col-md-6">
<label class="form-label">
City
</label>
<input type="text"
class="form-control"
id="pt_city">
</div>
<div class="col-md-6">
<label class="form-label">
State
</label>
<input type="text"
class="form-control"
id="pt_state">
</div>
<div class="col-12">
<label class="form-label">
Postal Code
</label>
<input type="text"
class="form-control"
id="pt_postalCode">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button"
class="btn btn-outline-secondary"
data-bs-dismiss="modal">
Cancel
</button>
<button type="submit"
class="btn btn-ci-primary">
Save
</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="js/config.js"></script>
<script src="js/sidebar.js"></script>
<script src="js/api.js"></script>
<script>
let ptCache = [];
let ptSearchTerm = '';
let ptModal, ptEditingId = null;
function ptEscapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function ptShowToast(message, variant = 'success') {
const container = document.getElementById('toastContainer');
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">${ptEscapeHtml(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 ptFiltered() {
const term = ptSearchTerm.trim().toLowerCase();
if (!term) return ptCache;
return ptCache.filter(p => {
const fullName = (p.fullName || '').toLowerCase();
const parts = fullName.split(' ').filter(Boolean);
const nameMatch = fullName.startsWith(term) || parts.some(x => x.startsWith(term));
const idTerm = term.replace(/^pt-/, '');
const mrnTerm = term.replace(/^mrn-/, '');
const idMatch = (p.id || '').toLowerCase().replace(/^pt-/, '').includes(idTerm);
const mrnMatch = (p.medicalRecordNumber || '').toLowerCase().replace(/^mrn-/, '').includes(mrnTerm);
return nameMatch || idMatch || mrnMatch;
});
}
async function ptLoadGrid() {
try {
ptCache = await Api.getPatients();
} catch (err) {
ptShowToast(err.message, 'danger');
return;
}
const rows = ptFiltered();
document.getElementById('pt_countLabel').textContent = `${rows.length} of ${ptCache.length} patient${ptCache.length === 1 ? '' : 's'}`;
const body = document.getElementById('pt_gridBody');
const empty = document.getElementById('pt_gridEmpty');
if (rows.length === 0) {
body.innerHTML = '';
empty.classList.remove('d-none');
return;
}
empty.classList.add('d-none');
// Avatar card per patient, matching the other resource pages.
const grid = document.getElementById('pt_cardGrid');
grid.innerHTML = rows.map(p => {
const colour = ptAvatarColour(p.fullName);
return `
<div class="rp-card">
<div class="rp-avatar" style="background:${colour}">${ptEscapeHtml(ptInitials(p.fullName))}</div>
<div class="rp-card-name" title="${ptEscapeHtml(p.fullName)}">${ptEscapeHtml(p.fullName)}</div>
<div class="rp-card-badge">${p.age} YRS · ${ptEscapeHtml(p.gender).toUpperCase()}</div>
<div class="rp-card-sub">${ptEscapeHtml(p.medicalRecordNumber)}</div>
<div class="rp-card-sub">${ptEscapeHtml(p.id)}</div>
<a class="rp-card-view" style="color:${colour};border-color:${colour}"
href="dashboard.html?patient=${encodeURIComponent(p.id)}">View chart</a>
<div class="rp-card-actions">
<button class="btn btn-sm btn-outline-secondary" data-edit="${p.id}">Edit</button>
<button class="btn btn-sm btn-outline-danger" data-delete="${p.id}">Delete</button>
</div>
</div>`;
}).join('');
grid._ptRows = rows;
body.innerHTML = '';
}
const PT_AVATAR_COLOURS = ['#2e86ab','#1e8449','#d68910','#7d3c98','#c0392b','#117864','#a04000','#5499c7','#b7950b','#34495e'];
function ptInitials(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('') || '?';
}
function ptAvatarColour(name) {
let hash = 0;
for (const ch of (name || '')) hash = (hash * 31 + ch.charCodeAt(0)) % 100000;
return PT_AVATAR_COLOURS[hash % PT_AVATAR_COLOURS.length];
}
function ptOpenAdd() {
ptEditingId = null;
document.getElementById('pt_modalTitle').textContent = 'Add Patient';
document.getElementById('pt_form').reset();
ptModal.show();
}
async function ptOpenEdit(row) {
try {
const p = await Api.getPatientById(row.id);
ptEditingId = p.id;
document.getElementById('pt_modalTitle').textContent =
`Edit - ${p.givenName || ''} ${p.familyName || ''}`;
document.getElementById('pt_mrn').value =
p.medicalRecordNumber || '';
document.getElementById('pt_givenName').value =
p.givenName || '';
document.getElementById('pt_familyName').value =
p.familyName || '';
document.getElementById('pt_birthDate').value =
p.birthDate
? new Date(p.birthDate).toISOString().split('T')[0]
: '';
document.getElementById('pt_gender').value =
p.gender || 'unknown';
document.getElementById('pt_phone').value =
p.telecomValue || '';
document.getElementById('pt_city').value =
p.city || '';
document.getElementById('pt_state').value =
p.state || '';
document.getElementById('pt_bloodGroup').value =
p.bloodGroup || '';
document.getElementById('pt_email').value =
p.email || '';
document.getElementById('pt_postalCode').value =
p.postalCode || '';
ptModal.show();
}
catch (err) {
ptShowToast(err.message, 'danger');
}
}
async function ptSubmit(e) {
e.preventDefault();
const payload = {
medicalRecordNumber:
document.getElementById('pt_mrn').value,
givenName:
document.getElementById('pt_givenName').value,
familyName:
document.getElementById('pt_familyName').value,
birthDate:
document.getElementById('pt_birthDate').value,
gender:
document.getElementById('pt_gender').value,
telecomValue:
document.getElementById('pt_phone').value,
city:
document.getElementById('pt_city').value,
state:
document.getElementById('pt_state').value,
postalCode:
document.getElementById('pt_postalCode').value,
bloodGroup:
document.getElementById('pt_bloodGroup').value || null,
email:
document.getElementById('pt_email').value || null
};
try {
if (ptEditingId) {
if (!payload.birthDate) {
ptShowToast('Date of birth is required to save changes.', 'danger');
return;
}
await Api.updatePatient(ptEditingId, payload);
ptShowToast('Patient updated.');
} else {
await Api.createPatient(payload);
ptShowToast('Patient created.');
}
ptModal.hide();
await ptLoadGrid();
} catch (err) {
ptShowToast(err.message, 'danger');
}
}
async function ptDelete(p) {
if (!confirm(`Delete patient ${p.fullName} and all their chart data?`)) return;
try {
await Api.deletePatient(p.id);
ptShowToast('Patient deleted.');
await ptLoadGrid();
} catch (err) {
ptShowToast(err.message, 'danger');
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!sessionStorage.getItem('cip_token')) { window.location.href = 'login.html'; return; }
renderSidebar('patients');
document.getElementById('clinicianLabel').textContent = `👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
document.getElementById('logoutBtn').addEventListener('click', () => { sessionStorage.clear(); window.location.href = 'login.html'; });
ptModal = new bootstrap.Modal(document.getElementById('pt_modal'));
document.getElementById('pt_addBtn').addEventListener('click', ptOpenAdd);
document.getElementById('pt_form').addEventListener('submit', ptSubmit);
document.getElementById('pt_searchInput').addEventListener('input', (e) => {
ptSearchTerm = e.target.value;
ptLoadGrid();
});
document.getElementById('pt_cardGrid').addEventListener('click', (e) => {
const editBtn = e.target.closest('[data-edit]');
const delBtn = e.target.closest('[data-delete]');
// Rows live on the card grid now; the old table body is left empty by
// the card renderer, so reading _ptRows from it always gave [].
const rows = document.getElementById('pt_cardGrid')._ptRows || [];
if (editBtn) {
const p = rows.find(r => r.id === editBtn.dataset.edit);
if (p) ptOpenEdit(p);
} else if (delBtn) {
const p = rows.find(r => r.id === delBtn.dataset.delete);
if (p) ptDelete(p);
}
});
ptLoadGrid();
});
</script>
</body>
</html>

View File

@ -1,201 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Register — Clinical Insight Pro</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
<style>
/* ------------------------------------------------------------------
Auth background. Put your picture at Frontend/images/bg1.jpg — the
gradient underneath is a fallback so the page still looks right if
the file is missing or still loading.
------------------------------------------------------------------ */
body.auth-page {
min-height: 100vh;
background-image:
linear-gradient(rgba(8, 46, 46, .58), rgba(8, 46, 46, .68)),
url('images/bg1.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
background-color: #0a4f4f;
}
/* The navbar is a direct child of <body>, so the centring happens on the
wrapper — otherwise the top bar would get centred too. */
body.auth-page > .auth-wrap {
min-height: calc(100vh - 62px); /* viewport minus the navbar */
display: flex; align-items: center; justify-content: center;
padding: 1.25rem 1rem;
max-width: 100%;
}
/* ------------------------------------------------------------------
The card is wider and two-column so all eight fields sit above the
fold — the old single 560px column pushed the button (and the
sign-in link under it) off screen.
------------------------------------------------------------------ */
.register-card {
width: 100%;
max-width: 780px;
padding: 1.5rem 1.75rem 1.6rem;
background: #fff;
border: none;
border-radius: 16px;
box-shadow: 0 18px 48px rgba(4, 30, 30, .32);
}
/* Title left, sign-in link right — visible the moment the page loads. */
.register-head {
margin-bottom: 1rem;
padding-bottom: .85rem; border-bottom: 1px solid #eef3f3;
}
.register-head h4 { margin: .4rem 0 0; font-weight: 700; font-size: 1.35rem; }
.register-head p { margin: .15rem 0 0; font-size: .84rem; color: #5f7373; }
/* Sign-in link inside the panel, under the button. */
.register-signin {
text-align: center; margin: .35rem 0 0;
font-size: .88rem; color: #5f7373;
}
.register-signin a { font-weight: 600; text-decoration: none; color: #0f6e6e; }
.register-signin a:hover { text-decoration: underline; }
/* Compact fields so the whole form fits. */
.register-card .form-label {
font-size: .72rem; text-transform: uppercase; letter-spacing: .5px;
font-weight: 600; color: #5f7373; margin-bottom: .2rem;
}
.register-card .form-control,
.register-card .form-select {
padding: .45rem .7rem; border-radius: 9px;
border-color: #dfe7e7; font-size: .92rem;
}
.register-card .form-control:focus,
.register-card .form-select:focus {
border-color: #0f6e6e; box-shadow: 0 0 0 .16rem rgba(15, 110, 110, .15);
}
.register-card .form-text { font-size: .74rem; margin-top: .1rem; }
.register-card .alert { font-size: .88rem; padding: .5rem .75rem; }
.register-card .btn-ci-primary { padding: .6rem; border-radius: 9px; font-size: .98rem; }
.status-row { display: flex; align-items: center; gap: 1.25rem; padding-top: .45rem; }
/* Short laptop screens: tighten further rather than forcing a page scroll. */
@media (max-height: 720px) {
.register-card { padding: 1.1rem 1.35rem 1.2rem; }
.register-head { margin-bottom: .7rem; padding-bottom: .6rem; }
.register-head h4 { font-size: 1.15rem; }
.register-head p { display: none; }
.register-card .form-control,
.register-card .form-select { padding: .35rem .65rem; }
}
</style>
</head>
<body class="auth-page">
<nav class="navbar navbar-expand-lg navbar-ci">
<div class="container">
<a class="navbar-brand" href="index.html">🩺 Clinical Insight Pro</a>
<span class="navbar-text ms-auto">Register</span>
</div>
</nav>
<div class="container auth-wrap">
<div class="card-ci register-card">
<!-- The sign-in link lives in the header rather than under the button,
so it never falls below the fold. -->
<div class="register-head">
<div>
<span class="badge-fhir">New User</span>
<h4>Create your account</h4>
<p>Saved to the Users table in PostgreSQL.</p>
</div>
</div>
<div id="registerError" class="alert alert-danger d-none mb-2"></div>
<div id="registerSuccess" class="alert alert-success d-none mb-2"></div>
<form id="registerForm">
<div class="row g-2 g-md-3">
<!-- Row 1 — name -->
<div class="col-md-6">
<label class="form-label" for="reg_firstName">First name</label>
<input type="text" class="form-control" id="reg_firstName" placeholder="Jane" required>
</div>
<div class="col-md-6">
<label class="form-label" for="reg_lastName">Last name</label>
<input type="text" class="form-control" id="reg_lastName" placeholder="Doe" required>
</div>
<!-- Row 2 — contact -->
<div class="col-md-6">
<label class="form-label" for="reg_email">Email</label>
<input type="email" class="form-control" id="reg_email" placeholder="jane.doe@example.com" required>
</div>
<div class="col-md-6">
<label class="form-label" for="reg_mobile">Phone number</label>
<input type="tel" class="form-control" id="reg_mobile" placeholder="9876543210" required>
</div>
<!-- Row 3 — gender and status -->
<div class="col-md-6">
<label class="form-label" for="reg_gender">Gender</label>
<select class="form-select" id="reg_gender" required>
<option value="" disabled selected>Select gender</option>
<option value="female">Female</option>
<option value="male">Male</option>
<option value="other">Other</option>
<option value="unknown">Prefer not to say</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label d-block">Status</label>
<div class="status-row">
<div class="form-check mb-0">
<input class="form-check-input" type="radio" name="reg_status" id="reg_active" value="active" checked>
<label class="form-check-label" for="reg_active">Active</label>
</div>
<div class="form-check mb-0">
<input class="form-check-input" type="radio" name="reg_status" id="reg_inactive" value="inactive">
<label class="form-check-label" for="reg_inactive">Inactive</label>
</div>
</div>
</div>
<!-- Row 4 — credentials -->
<div class="col-md-6">
<label class="form-label" for="reg_username">Username</label>
<input type="text" class="form-control" id="reg_username" placeholder="jane.doe" required>
</div>
<div class="col-md-6">
<label class="form-label" for="reg_password">Password</label>
<input type="password" class="form-control" id="reg_password" placeholder="••••••••" minlength="6" required>
<div class="form-text">At least 6 characters.</div>
</div>
<div class="col-12 mt-3">
<button type="submit" class="btn btn-ci-primary w-100 fw-semibold" id="registerBtn">Register</button>
</div>
<!-- Sign-in link sits inside the panel, right under the button. The
form is only four rows now, so it's on screen without scrolling. -->
<div class="col-12">
<p class="register-signin">
Already registered?
<a href="login.html">Sign in &rarr;</a>
</p>
</div>
</div>
</form>
</div>
</div>
<script src="js/config.js"></script>
<script src="js/register.js"></script>
</body>
</html>

View File

@ -68,30 +68,47 @@ async function rpLoadPatientsForPicker() {
} 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);
async function rpLoadPractitionerRoleDropdowns() {
const fill = (fieldKey, list, labelFn, selected) => {
const el = document.getElementById(`rp_field_${fieldKey}`);
if (!el || el.tagName !== 'SELECT') return;
const practitioners = await Api.getPractitioners();
const organizations = await Api.getOrganizations();
const locations = await Api.getLocations();
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('');
const practitionerSelect =
document.getElementById('rp_field_practitionerId');
if (selected) el.value = String(selected);
};
const organizationSelect =
document.getElementById('rp_field_organizationId');
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);
const locationSelect =
document.getElementById('rp_field_locationId');
if (practitionerSelect) {
practitionerSelect.innerHTML =
practitioners.map(p =>
`<option value="${p.id}">
${p.givenName} ${p.familyName}
</option>`
).join('');
}
if (organizationSelect) {
organizationSelect.innerHTML =
organizations.map(o =>
`<option value="${o.id}">
${o.name}
</option>`
).join('');
}
if (locationSelect) {
locationSelect.innerHTML =
locations.map(l =>
`<option value="${l.id}">
${l.name}
</option>`
).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.
@ -99,7 +116,7 @@ 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.'
body: 'Record the contributing factors as one note, separating each with a semicolon and ending with its weight — e.g. <code>Current smoker (+10); Prior hospitalization (+12)</code>. The percentage and risk level are calculated from those weights.'
},
servicerequests: { icon: '📄', title: 'Service request', body: 'The identifier is generated automatically from the resource name and patient id.' },
careplans: { icon: '📋', title: 'Care plan', body: 'Period covers the whole plan; the activity schedule covers the next step.' },
@ -135,98 +152,28 @@ async function rpOpenAddModal() {
// 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));
.classList.toggle('d-none', !!config.standalone);
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();
.map(f => rpFieldInputHtml(f, null)).join('');
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();
}
async function rpOpenEditModal(row) {
function rpOpenEditModal(row) {
rpEditingRowId = row.id;
rpEditingPatientId = rpRowPatientId(row);
rpEditingPatientId = row.patientId;
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$/, '')} — ${row.patientName}`;
document.getElementById('rp_modalTitle').textContent =
`Edit ${config.title.replace(/s$/, '')}${label ? `${label}` : ''}`;
`Edit ${config.title.replace(/s$/, '')}`;
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'); }
}
.map(f => rpFieldInputHtml(f, row[f.key])).join('');
rpAddModal.show();
}
@ -257,12 +204,6 @@ async function rpSubmitModal(e) {
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') {
@ -412,7 +353,7 @@ if (config.key === 'careplans') {
// 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 pid = (!config.standalone && picker) ? picker.value : null;
const ids = rpBuildIdentifiers(config, pid);
if (config.fields.some(f => f.key === 'identifierSystem')) {
@ -451,19 +392,26 @@ if (config.key === 'careplans') {
patientId;
}
const standaloneResources = [
'organizations',
'locations',
'practitioners',
'practitionerroles',
'cdsrules',
'users'
];
if (
!rpIsStandalone(config) &&
!RP_STANDALONE_KEYS.includes(rpNormKey(config.key)) &&
!config.standalone &&
!standaloneResources.includes(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);
console.log("FINAL PAYLOAD", payload);
await config.add(patientId, payload);
rpShowToast(`${config.title} added.`);
}
@ -477,18 +425,11 @@ if (
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;
if (!confirm(`Delete this ${config.title.replace(/s$/, '').toLowerCase()} for ${row.patientName}?`)) return;
try {
//await config.remove(row.patientId, row.id);
await config.remove(
rpRowPatientId(row),
row.patientId || null,
row.id
);
rpShowToast('Deleted.');
@ -605,41 +546,6 @@ const RP_CARD_STYLES = `
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; }
@ -674,37 +580,6 @@ const RP_CARD_STYLES = `
.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;
@ -770,36 +645,20 @@ function rpAvatarColour(name) {
// 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) {
function rpCardBadge(config, rows) {
if (config.cardBadge) return config.cardBadge(rows);
// Risk assessments show the patient's highest score, not just a status.
if (rpNormKey(config.key) === 'riskassessment') {
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 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];
const value = rows[0]?.[col.key];
return value === null || value === undefined || value === '' ? '' : String(value);
}
@ -830,143 +689,6 @@ const RP_IDENTIFIER_PREFIX = {
// 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 &amp; 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
@ -1015,205 +737,22 @@ function rpBuildIdentifiers(config, patientId) {
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
};
if (config.standalone) {
// One card per record; the title is the first column's value.
const titleKey = config.columns[0].key;
rows.forEach(r => {
groups[r.id] = { title: String(r[titleKey] ?? '—'), rows: [r] };
});
} else {
list.forEach(r => {
const key = rpRowPatientId(r) || 'unassigned';
if (!groups[key]) groups[key] = { title: r.patientName || key, rows: [], single: false };
rows.forEach(r => {
const key = r.patientId || 'unassigned';
if (!groups[key]) groups[key] = { title: r.patientName || key, rows: [] };
groups[key].rows.push(r);
});
}
@ -1228,24 +767,14 @@ async function rpLoadGrid() {
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 || []);
const rows = await config.listAll(rpSearchTerm.trim());
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}`
countLabel.textContent = config.standalone
? `${rows.length} record${rows.length === 1 ? '' : 's'}`
: `${rows.length} record${rows.length === 1 ? '' : 's'} · ${keys.length} patient${keys.length === 1 ? '' : 's'}`;
}
@ -1258,20 +787,16 @@ async function rpLoadGrid() {
grid.innerHTML = keys.map(key => {
const g = rpGroups[key];
const badge = rpCardBadge(config, g.rows, g.title);
const badge = rpCardBadge(config, g.rows);
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>
data-group="${rpEscapeHtml(key)}">View (${g.rows.length})</button>
</div>`;
}).join('');
} catch (err) {
@ -1280,129 +805,17 @@ async function rpLoadGrid() {
}
// ---------- 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';
const isRisk = config.key === 'riskassessments';
document.getElementById('rp_detailHead').innerHTML =
(isRisk ? '<th>Score</th>' : '') +
@ -1485,8 +898,7 @@ function rpBuildCardShell() {
<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">
<div class="table-responsive">
<table class="table table-sm resource-grid-table align-middle mb-0">
<thead><tr id="rp_detailHead"></tr></thead>
<tbody id="rp_detailBody"></tbody>
@ -1498,18 +910,6 @@ function rpBuildCardShell() {
</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() {
@ -1558,25 +958,20 @@ function rpInit() {
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();
if (!config.standalone) 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) => {
// Edit / Delete now live inside the detail modal.
document.getElementById('rp_detailBody').addEventListener('click', (e) => {
const editBtn = e.target.closest('[data-edit-row]');
const delBtn = e.target.closest('[data-delete-row]');
if (!editBtn && !delBtn) return;
const rows = rpDetailRows.length
? rpDetailRows
: (document.getElementById('rp_detailBody')?._rpRows || []);
const tbody = document.getElementById('rp_detailBody');
const rows = tbody._rpRows || [];
if (editBtn) {
const row = rows.find(r => String(r.id) === editBtn.dataset.editRow) || rows[0];
const row = rows.find(r => String(r.id) === editBtn.dataset.editRow);
if (row) { rpDetailModal.hide(); rpOpenEditModal(row); }
} else if (delBtn) {
const row = rows.find(r => String(r.id) === delBtn.dataset.deleteRow) || rows[0];
const row = rows.find(r => String(r.id) === delBtn.dataset.deleteRow);
if (row) { rpDetailModal.hide(); rpDeleteRow(row); }
}
});

View File

@ -152,17 +152,14 @@ async function rpOpenAddModal() {
// 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));
.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 === 'riskassessments') rpWireRiskForm();
if (config.key === 'practitionerroles') {
await rpLoadPractitionerRoleDropdowns();
@ -176,9 +173,6 @@ 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';
@ -215,8 +209,6 @@ function rpOpenEditModal(row) {
`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;
@ -414,7 +406,7 @@ if (config.key === 'careplans') {
// 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 pid = (!config.standalone && picker) ? picker.value : null;
const ids = rpBuildIdentifiers(config, pid);
if (config.fields.some(f => f.key === 'identifierSystem')) {
@ -463,7 +455,7 @@ if (config.key === 'careplans') {
];
if (
!rpIsStandalone(config) &&
!config.standalone &&
!standaloneResources.includes(config.key) &&
!patientId
)
@ -666,11 +658,6 @@ const RP_CARD_STYLES = `
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;
@ -806,16 +793,10 @@ const RP_RISK_FACTORS = [
// 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);
const select = document.getElementById('rp_patientSelect');
if (!select) return '';
const patient = rpPatientsCache.find(p => p.id === select.value);
return (patient?.gender || '').toLowerCase();
}
@ -853,13 +834,9 @@ function rpRiskFactorsHtml(checkedLabels = [], freeText = '') {
}).join('')}
</div>`).join('')}
<div class="col-12">
<label class="form-label" for="rp_riskFreeText">Additional factors &amp; notes</label>
<label class="form-label" for="rp_riskFreeText">Additional note (optional)</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>
placeholder="Anything not covered by the boxes above">${rpEscapeHtml(freeText)}</textarea>
</div>`;
}
@ -871,17 +848,7 @@ function rpUpdateRiskPreview() {
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);
score = Math.min(score, 100);
const level = rpRiskLevel(score);
chip.textContent = `${score}%`;
@ -966,58 +933,17 @@ function rpBuildIdentifiers(config, patientId) {
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.
const RP_STANDALONE_KEYS = [
'organizations',
'locations',
'practitioners',
'practitionerroles',
'cdsrules',
'users'
];
function rpIsStandalone(config, rows) {
if (config.standalone === true) return true;
if (RP_STANDALONE_KEYS.includes(config.key)) return true;
// Data-driven fallback: no row has a patientId => nothing to group by.
const sample = Array.isArray(rows) ? rows : rpAllRows;
if (Array.isArray(sample) && sample.length > 0) {
return !sample.some(r => r && r.patientId);
}
return false;
}
// 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 = {
practitioners: r => [r.prefix, r.givenName, r.familyName].filter(Boolean).join(' '),
practitionerroles: r => r.practitionerName || r.roleDisplay || r.id,
organizations: r => r.name,
locations: r => r.name,
cdsrules: r => r.ruleId,
users: r => [r.firstName, r.lastName].filter(Boolean).join(' ') || r.username
};
let rpGroups = {};
function rpBuildGroups(rows) {
const config = window.RESOURCE_PAGE_CONFIG;
const groups = {};
if (rpIsStandalone(config, rows)) {
// One card per record.
const titleFn = RP_CARD_TITLE[config.key]
|| (r => String(r[config.columns[0].key] ?? '—'));
rows.forEach((r, i) => {
// Fall back to the index if the API didn't send an id — otherwise every
// record would collide on groups[undefined] and you'd see one card.
const key = (r.id ?? r.Id ?? `row-${i}`).toString();
groups[key] = { title: titleFn(r) || '—', rows: [r] };
if (config.standalone) {
// One card per record; the title is the first column's value.
const titleKey = config.columns[0].key;
rows.forEach(r => {
groups[r.id] = { title: String(r[titleKey] ?? '—'), rows: [r] };
});
} else {
rows.forEach(r => {
@ -1043,7 +969,7 @@ async function rpLoadGrid() {
const keys = Object.keys(rpGroups);
if (countLabel) {
countLabel.textContent = rpIsStandalone(config)
countLabel.textContent = config.standalone
? `${rows.length} record${rows.length === 1 ? '' : 's'}`
: `${rows.length} record${rows.length === 1 ? '' : 's'} · ${keys.length} patient${keys.length === 1 ? '' : 's'}`;
}
@ -1228,7 +1154,7 @@ function rpInit() {
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();
if (!config.standalone) rpLoadPatientsForPicker();
// Edit / Delete now live inside the detail modal.
document.getElementById('rp_detailBody').addEventListener('click', (e) => {

View File

@ -152,7 +152,7 @@ async function rpOpenAddModal() {
// 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));
.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'))
@ -414,7 +414,7 @@ if (config.key === 'careplans') {
// 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 pid = (!config.standalone && picker) ? picker.value : null;
const ids = rpBuildIdentifiers(config, pid);
if (config.fields.some(f => f.key === 'identifierSystem')) {
@ -463,7 +463,7 @@ if (config.key === 'careplans') {
];
if (
!rpIsStandalone(config) &&
!config.standalone &&
!standaloneResources.includes(config.key) &&
!patientId
)
@ -966,47 +966,17 @@ function rpBuildIdentifiers(config, patientId) {
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.
const RP_STANDALONE_KEYS = [
'organizations',
'locations',
'practitioners',
'practitionerroles',
'cdsrules',
'users'
];
function rpIsStandalone(config) {
return config.standalone === true || RP_STANDALONE_KEYS.includes(config.key);
}
// 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 = {
practitioners: r => [r.prefix, r.givenName, r.familyName].filter(Boolean).join(' '),
practitionerroles: r => r.practitionerName || r.roleDisplay || r.id,
organizations: r => r.name,
locations: r => r.name,
cdsrules: r => r.ruleId,
users: r => [r.firstName, r.lastName].filter(Boolean).join(' ') || r.username
};
let rpGroups = {};
function rpBuildGroups(rows) {
const config = window.RESOURCE_PAGE_CONFIG;
const groups = {};
if (rpIsStandalone(config)) {
// One card per record.
const titleFn = RP_CARD_TITLE[config.key]
|| (r => String(r[config.columns[0].key] ?? '—'));
if (config.standalone) {
// One card per record; the title is the first column's value.
const titleKey = config.columns[0].key;
rows.forEach(r => {
groups[r.id] = { title: titleFn(r) || '—', rows: [r] };
groups[r.id] = { title: String(r[titleKey] ?? '—'), rows: [r] };
});
} else {
rows.forEach(r => {
@ -1032,7 +1002,7 @@ async function rpLoadGrid() {
const keys = Object.keys(rpGroups);
if (countLabel) {
countLabel.textContent = rpIsStandalone(config)
countLabel.textContent = config.standalone
? `${rows.length} record${rows.length === 1 ? '' : 's'}`
: `${rows.length} record${rows.length === 1 ? '' : 's'} · ${keys.length} patient${keys.length === 1 ? '' : 's'}`;
}
@ -1217,7 +1187,7 @@ function rpInit() {
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();
if (!config.standalone) rpLoadPatientsForPicker();
// Edit / Delete now live inside the detail modal.
document.getElementById('rp_detailBody').addEventListener('click', (e) => {

View File

@ -0,0 +1,126 @@
// Single source of truth for the left navigation on EVERY page.
//
// Two problems this file solves:
//
// 1. Pages that never call renderSidebar() — it self-initialises on
// DOMContentLoaded, so just including the script is enough.
//
// 2. Pages with their own custom script that renders a DIFFERENT (shorter)
// menu — a MutationObserver watches the container and re-asserts the full
// list whenever something replaces it. This is why CDS Alerts and
// Observations were showing only 10 items while the Dashboard showed all
// of them: those pages build their own nav after this script has run.
//
// Adding a nav item? Add it to SIDEBAR_ITEMS and it appears everywhere.
const SIDEBAR_ITEMS = [
{ key: 'dashboard', href: 'dashboard.html', icon: '🏠', label: 'Dashboard' },
{ key: 'patients', href: 'patients.html', icon: '🧑\u200d⚕', label: 'Patients' },
{ key: 'conditions', href: 'conditions.html', icon: '🩺', label: 'Conditions' },
{ key: 'observations', href: 'observations.html', icon: '📈', label: 'Observations' },
{ key: 'allergies', href: 'allergies.html', icon: '⚠️', label: 'Allergies' },
{ key: 'medications', href: 'medications.html', icon: '💊', label: 'Medications' },
{ key: 'encounters', href: 'encounters.html', icon: '📅', label: 'Encounters' },
{ key: 'organizations', href: 'organizations.html', icon: '🏥', label: 'Organizations' },
{ key: 'locations', href: 'locations.html', icon: '📍', label: 'Locations' },
{ key: 'practitioners', href: 'practitioners.html', icon: '👨‍⚕️', label: 'Practitioners' },
{ key: 'practitionerroles', href: 'practitionerroles.html', icon: '🩹', label: 'Practitioner Roles' },
{ key: 'servicerequests', href: 'servicerequests.html', icon: '📄', label: 'Service Requests' },
{ key: 'riskassessments', href: 'riskassessments.html', icon: '📊', label: 'Risk Assessment' },
{ key: 'cdsalerts', href: 'cdsalerts.html', icon: '🔔', label: 'CDS Alerts' },
{ key: 'careplans', href: 'careplans.html', icon: '📋', label: 'Care Plans' },
{ key: 'cdsrules', href: 'cdsrules.html', icon: '⚙️', label: 'CDS Rules' },
{ key: 'users', href: 'users.html', icon: '👤', label: 'Users' }
];
// Pages that deliberately have no sidebar (public / auth screens).
const SIDEBAR_EXCLUDED_PAGES = ['index.html', 'login.html', 'register.html', 'callback.html'];
// Set while this script is writing to the container, so the observer below
// doesn't react to its own changes and loop forever.
let sidebarWriting = false;
function sidebarContainer() {
return document.getElementById('appSidebar') || document.querySelector('.app-sidebar');
}
// Works out which item to highlight from the URL, so a page doesn't have to
// pass its key in. An explicit key still wins when one is supplied.
function sidebarKeyFromUrl() {
const file = (window.location.pathname.split('/').pop() || 'dashboard.html').toLowerCase();
const match = SIDEBAR_ITEMS.find(i => i.href.toLowerCase() === file);
return match ? match.key : '';
}
function renderSidebar(activeKey) {
const container = sidebarContainer();
if (!container) return;
const key = activeKey || sidebarKeyFromUrl();
const collapsed = localStorage.getItem('cip_sidebar_collapsed') === '1';
sidebarWriting = true;
container.innerHTML = `
<button class="sidebar-toggle" id="sidebarToggle" type="button"
title="Collapse / expand menu" aria-label="Toggle navigation"></button>
<nav class="sidebar-nav">
${SIDEBAR_ITEMS.map(i => `
<a href="${i.href}" class="sidebar-link ${i.key === key ? 'active' : ''}" title="${i.label}">
<span class="sidebar-icon">${i.icon}</span><span class="sidebar-text">${i.label}</span>
</a>`).join('')}
</nav>
`;
applySidebarState(collapsed);
document.getElementById('sidebarToggle').addEventListener('click', () => {
const nowCollapsed = !document.body.classList.contains('sidebar-collapsed');
applySidebarState(nowCollapsed);
localStorage.setItem('cip_sidebar_collapsed', nowCollapsed ? '1' : '0');
});
// Release on the next tick so the observer ignores this whole write.
setTimeout(() => { sidebarWriting = false; }, 0);
}
function applySidebarState(collapsed) {
document.body.classList.toggle('sidebar-collapsed', collapsed);
}
// Re-render if the menu ever ends up with the wrong number of links — which
// is what happens when a page's own script overwrites the container after
// this one has run.
function guardSidebar() {
const container = sidebarContainer();
if (!container) return;
new MutationObserver(() => {
if (sidebarWriting) return;
const links = container.querySelectorAll('.sidebar-link').length;
if (links !== SIDEBAR_ITEMS.length) renderSidebar();
}).observe(container, { childList: true, subtree: true });
}
function initSidebar() {
const file = (window.location.pathname.split('/').pop() || '').toLowerCase();
if (SIDEBAR_EXCLUDED_PAGES.includes(file)) return;
renderSidebar();
guardSidebar();
// Belt and braces for pages whose own script runs late (after images and
// stylesheets finish loading).
window.addEventListener('load', () => {
const container = sidebarContainer();
if (container && container.querySelectorAll('.sidebar-link').length !== SIDEBAR_ITEMS.length) {
renderSidebar();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSidebar);
} else {
initSidebar();
}

View File

@ -1,300 +0,0 @@
// Single source of truth for the left navigation on EVERY page.
//
// Two problems this file solves:
//
// 1. Pages that never call renderSidebar() — it self-initialises on
// DOMContentLoaded, so just including the script is enough.
//
// 2. Pages with their own custom script that renders a DIFFERENT (shorter)
// menu — a MutationObserver watches the container and re-asserts the full
// list whenever something replaces it. This is why CDS Alerts and
// Observations were showing only 10 items while the Dashboard showed all
// of them: those pages build their own nav after this script has run.
//
// Adding a nav item? Add it to SIDEBAR_ITEMS and it appears everywhere.
const SIDEBAR_ITEMS = [
{ key: 'dashboard', href: 'dashboard.html', icon: '🏠', label: 'Dashboard' },
{ key: 'patients', href: 'patients.html', icon: '🧑\u200d⚕', label: 'Patients' },
{ key: 'conditions', href: 'conditions.html', icon: '🩺', label: 'Conditions' },
{ key: 'observations', href: 'observations.html', icon: '📈', label: 'Observations' },
{ key: 'allergies', href: 'allergies.html', icon: '⚠️', label: 'Allergies' },
{ key: 'medications', href: 'medications.html', icon: '💊', label: 'Medications' },
{ key: 'encounters', href: 'encounters.html', icon: '📅', label: 'Encounters' },
{ key: 'organizations', href: 'organizations.html', icon: '🏥', label: 'Organizations' },
{ key: 'locations', href: 'locations.html', icon: '📍', label: 'Locations' },
{ key: 'practitioners', href: 'practitioners.html', icon: '👨‍⚕️', label: 'Practitioners' },
{ key: 'practitionerroles', href: 'practitionerroles.html', icon: '🩹', label: 'Practitioner Roles' },
{ key: 'servicerequests', href: 'servicerequests.html', icon: '📄', label: 'Service Requests' },
{ key: 'riskassessments', href: 'riskassessments.html', icon: '📊', label: 'Risk Assessment' },
{ key: 'cdsalerts', href: 'cdsalerts.html', icon: '🔔', label: 'CDS Alerts' },
{ key: 'careplans', href: 'careplans.html', icon: '📋', label: 'Care Plans' },
{ key: 'cdsrules', href: 'cdsrules.html', icon: '⚙️', label: 'CDS Rules' },
{ key: 'users', href: 'users.html', icon: '👤', label: 'Users' }
];
// The collapse + scroll + search styles ship with this script rather than
// css/styles.css. That external file wasn't being picked up on most pages,
// which is why the toggle only ever worked on the dashboard.
const SIDEBAR_STYLES = `
.app-sidebar {
display: flex;
flex-direction: column;
transition: width .16s ease;
/* Without a bounded height the nav grows instead of scrolling, which is
why the scrollbar never appeared. */
max-height: 100vh;
position: sticky;
top: 0;
overflow: hidden;
}
.sidebar-toggle {
background: rgba(255,255,255,.14); border: none; color: #fff;
font-size: 1.1rem; line-height: 1; width: 40px; height: 40px;
border-radius: 10px; margin: 0 .2rem .6rem; cursor: pointer; flex-shrink: 0;
}
.sidebar-toggle:hover { background: rgba(255,255,255,.26); }
/* Hamburger in the top bar, left of the brand. */
.navbar-sidebar-toggle {
background: rgba(255,255,255,.16); border: none; color: #fff;
font-size: 1.15rem; line-height: 1; width: 38px; height: 38px;
border-radius: 9px; margin-right: .75rem; cursor: pointer; flex-shrink: 0;
}
.navbar-sidebar-toggle:hover { background: rgba(255,255,255,.3); }
/* Welcome line at the top of the rail. */
.sidebar-welcome {
padding: 0 .55rem .55rem; flex-shrink: 0;
border-bottom: 1px solid rgba(255,255,255,.12); margin-bottom: .55rem;
}
.sidebar-welcome-hi { display: block; font-size: .72rem; color: rgba(255,255,255,.6); }
.sidebar-welcome-name {
display: block; font-weight: 600; color: #fff; font-size: .95rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* --- Search --- */
.sidebar-search { padding: 0 .35rem .6rem; flex-shrink: 0; }
.sidebar-search input {
width: 100%; border-radius: 8px; border: 1px solid rgba(255,255,255,.2);
background: rgba(255,255,255,.12); color: #fff;
padding: .35rem .6rem; font-size: .85rem;
}
.sidebar-search input::placeholder { color: rgba(255,255,255,.6); }
.sidebar-search input:focus {
outline: none; border-color: rgba(255,255,255,.5);
background: rgba(255,255,255,.18);
}
.sidebar-empty {
color: rgba(255,255,255,.55); font-size: .82rem;
padding: .5rem .6rem;
}
/* --- Scrollable nav so long menus don't get cut off --- */
.sidebar-nav {
flex: 1 1 auto;
overflow-y: auto;
min-height: 0;
padding-right: .15rem;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,.45) rgba(255,255,255,.08);
}
.sidebar-nav::-webkit-scrollbar { width: 10px; }
.sidebar-nav::-webkit-scrollbar-track { background: rgba(0,0,0,.22); border-radius: 5px; }
.sidebar-nav::-webkit-scrollbar-thumb {
background: rgba(255,255,255,.65); border-radius: 5px;
border: 2px solid transparent; background-clip: content-box;
}
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: #fff; background-clip: content-box; }
/* --- Collapsed rail --- */
/* !important because css/styles.css sets .app-sidebar { width: ... } and,
depending on load order, could otherwise win this. */
body.sidebar-collapsed .app-sidebar,
body.sidebar-collapsed #appSidebar {
width: 68px !important;
min-width: 68px !important;
padding-left: .4rem;
padding-right: .4rem;
}
body.sidebar-collapsed .sidebar-text { display: none; }
body.sidebar-collapsed .sidebar-search,
body.sidebar-collapsed .sidebar-welcome { display: none; }
body.sidebar-collapsed .sidebar-link { justify-content: center; padding-left: .4rem; padding-right: .4rem; }
`;
function injectSidebarStyles() {
if (document.getElementById('cip-sidebar-styles')) return;
const style = document.createElement('style');
style.id = 'cip-sidebar-styles';
style.textContent = SIDEBAR_STYLES;
document.head.appendChild(style);
}
// Pages that deliberately have no sidebar (public / auth screens).
const SIDEBAR_EXCLUDED_PAGES = ['index.html', 'login.html', 'register.html', 'callback.html'];
// Set while this script is writing to the container, so the observer below
// doesn't react to its own changes and loop forever.
let sidebarWriting = false;
function sidebarContainer() {
return document.getElementById('appSidebar') || document.querySelector('.app-sidebar');
}
// Works out which item to highlight from the URL, so a page doesn't have to
// pass its key in. An explicit key still wins when one is supplied.
function sidebarKeyFromUrl() {
const file = (window.location.pathname.split('/').pop() || 'dashboard.html').toLowerCase();
const match = SIDEBAR_ITEMS.find(i => i.href.toLowerCase() === file);
return match ? match.key : '';
}
// Puts the hamburger in the top bar, immediately left of the brand — the
// spot people reach for first. Returns false if the page has no navbar, in
// which case the button stays inside the sidebar as before.
function mountNavbarToggle() {
const brand = document.querySelector('.navbar-brand');
if (!brand) return false;
if (!document.getElementById('sidebarToggle')) {
const btn = document.createElement('button');
btn.id = 'sidebarToggle';
btn.type = 'button';
btn.className = 'navbar-sidebar-toggle';
btn.title = 'Collapse / expand menu';
btn.setAttribute('aria-label', 'Toggle navigation');
btn.textContent = '☰';
brand.parentNode.insertBefore(btn, brand);
}
return true;
}
// "Welcome, <name>" using whoever signed in. auth.js stores the display name
// under cip_clinician at sign-in.
function currentUserName() {
try {
return sessionStorage.getItem('cip_clinician')
|| localStorage.getItem('cip_clinician')
|| '';
} catch { return ''; }
}
function renderSidebar(activeKey) {
injectSidebarStyles();
const container = sidebarContainer();
if (!container) return;
const key = activeKey || sidebarKeyFromUrl();
const collapsed = localStorage.getItem('cip_sidebar_collapsed') === '1';
sidebarWriting = true;
const navbarHasToggle = mountNavbarToggle();
container.innerHTML = `
${navbarHasToggle ? '' : `
<button class="sidebar-toggle" id="sidebarToggle" type="button"
title="Collapse / expand menu" aria-label="Toggle navigation"></button>`}
<div class="sidebar-welcome" id="sidebarWelcome"></div>
<div class="sidebar-search">
<input type="search" id="sidebarSearch" placeholder="Search menu…" autocomplete="off">
</div>
<nav class="sidebar-nav" id="sidebarNav">
${SIDEBAR_ITEMS.map(i => `
<a href="${i.href}" class="sidebar-link ${i.key === key ? 'active' : ''}"
data-label="${i.label.toLowerCase()}" title="${i.label}">
<span class="sidebar-icon">${i.icon}</span><span class="sidebar-text">${i.label}</span>
</a>`).join('')}
<div class="sidebar-empty d-none" id="sidebarNoMatch">No matching page.</div>
</nav>
`;
const name = currentUserName();
const welcome = document.getElementById('sidebarWelcome');
if (welcome) {
welcome.innerHTML = name
? `<span class="sidebar-welcome-hi">Welcome,</span>
<span class="sidebar-welcome-name">${name}</span>`
: '';
welcome.classList.toggle('d-none', !name);
}
applySidebarState(collapsed);
// Filter the menu as you type. Expands the rail first if it's collapsed,
// since the search box is hidden in that state.
const search = document.getElementById('sidebarSearch');
if (search) search.oninput = () => {
const term = search.value.trim().toLowerCase();
let shown = 0;
container.querySelectorAll('.sidebar-link').forEach(a => {
const match = !term || a.dataset.label.includes(term);
a.classList.toggle('d-none', !match);
if (match) shown++;
});
document.getElementById('sidebarNoMatch').classList.toggle('d-none', shown > 0);
};
// Assigned, not added. The navbar button is created once and survives
// every re-render, so addEventListener stacked a new handler each time —
// an even number of handlers flipped the state twice per click, which
// looked exactly like a dead button.
const toggleBtn = document.getElementById('sidebarToggle');
if (toggleBtn) {
toggleBtn.onclick = () => {
const nowCollapsed = !document.body.classList.contains('sidebar-collapsed');
applySidebarState(nowCollapsed);
try {
localStorage.setItem('cip_sidebar_collapsed', nowCollapsed ? '1' : '0');
} catch {}
};
}
// Release on the next tick so the observer ignores this whole write.
setTimeout(() => { sidebarWriting = false; }, 0);
}
function applySidebarState(collapsed) {
document.body.classList.toggle('sidebar-collapsed', collapsed);
}
// Re-render if the menu ever ends up with the wrong number of links — which
// is what happens when a page's own script overwrites the container after
// this one has run.
function guardSidebar() {
const container = sidebarContainer();
if (!container) return;
new MutationObserver(() => {
if (sidebarWriting) return;
const links = container.querySelectorAll('.sidebar-link').length;
if (links !== SIDEBAR_ITEMS.length) renderSidebar();
}).observe(container, { childList: true, subtree: true });
}
function initSidebar() {
const file = (window.location.pathname.split('/').pop() || '').toLowerCase();
if (SIDEBAR_EXCLUDED_PAGES.includes(file)) return;
renderSidebar();
guardSidebar();
// Belt and braces for pages whose own script runs late (after images and
// stylesheets finish loading).
window.addEventListener('load', () => {
const container = sidebarContainer();
if (container && container.querySelectorAll('.sidebar-link').length !== SIDEBAR_ITEMS.length) {
renderSidebar();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSidebar);
} else {
initSidebar();
}

View File

@ -1,328 +0,0 @@
:root {
--ci-primary: #0f6e6e;
--ci-primary-dark: #0a4f4f;
--ci-accent: #2e86ab;
--ci-bg: #f4f7f8;
--ci-critical: #c0392b;
--ci-warning: #d68910;
--ci-info: #2e86ab;
--ci-success: #1e8449;
--ci-text: #1f2d2d;
--ci-muted: #5f7373;
--ci-border: #dfe7e7;
}
* { box-sizing: border-box; }
body {
background-color: var(--ci-bg);
color: var(--ci-text);
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.navbar-ci {
background: linear-gradient(135deg, var(--ci-primary-dark), var(--ci-primary));
}
.navbar-ci .navbar-brand {
font-weight: 700;
letter-spacing: 0.3px;
color: #fff !important;
}
.navbar-ci .navbar-text,
.navbar-ci .nav-link {
color: #eaf6f6 !important;
}
.hero-panel {
background: linear-gradient(135deg, var(--ci-primary-dark) 0%, var(--ci-primary) 55%, var(--ci-accent) 100%);
color: #fff;
border-radius: 16px;
padding: 3rem 2.5rem;
}
.hero-panel h1 {
font-weight: 700;
}
.card-ci {
border: 1px solid var(--ci-border);
border-radius: 14px;
background: #fff;
box-shadow: 0 2px 10px rgba(15, 110, 110, 0.06);
}
.card-ci .card-header {
background-color: #fff;
border-bottom: 1px solid var(--ci-border);
font-weight: 600;
border-radius: 14px 14px 0 0 !important;
}
.step-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--ci-primary);
color: #fff;
font-weight: 700;
margin-right: 0.6rem;
flex-shrink: 0;
}
.risk-badge {
font-size: 0.85rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-weight: 600;
}
.risk-badge.High { background-color: #fdecea; color: var(--ci-critical); border: 1px solid #f5c6c0; }
.risk-badge.Normal { background-color: #eafaf1; color: var(--ci-success); border: 1px solid #bfe8cf; }
.alert-card {
border-left: 5px solid var(--ci-info);
border-radius: 8px;
background: #fff;
padding: 1rem 1.1rem;
margin-bottom: 0.85rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
}
.alert-card.critical { border-left-color: var(--ci-critical); }
.alert-card.warning { border-left-color: var(--ci-warning); }
.alert-card.info { border-left-color: var(--ci-info); }
.severity-tag {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 4px;
}
.severity-tag.critical { background-color: #fdecea; color: var(--ci-critical); }
.severity-tag.warning { background-color: #fdf3e3; color: var(--ci-warning); }
.severity-tag.info { background-color: #e8f2fb; color: var(--ci-info); }
.chart-list-item {
border-bottom: 1px dashed var(--ci-border);
padding: 0.5rem 0;
}
.chart-list-item:last-child { border-bottom: none; }
.patient-tile {
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.patient-tile:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 110, 110, 0.15);
}
.patient-tile.active {
border-color: var(--ci-primary);
box-shadow: 0 0 0 2px var(--ci-primary) inset;
}
.score-gauge {
width: 120px;
height: 120px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.8rem;
font-weight: 700;
color: #fff;
margin: 0 auto;
}
.text-muted-ci { color: var(--ci-muted); }
.btn-ci-primary {
background-color: var(--ci-primary);
border-color: var(--ci-primary);
color: #fff;
}
.btn-ci-primary:hover {
background-color: var(--ci-primary-dark);
border-color: var(--ci-primary-dark);
color: #fff;
}
.badge-fhir {
background-color: #e8f2fb;
color: var(--ci-accent);
font-weight: 600;
border-radius: 6px;
padding: 0.25rem 0.5rem;
font-size: 0.7rem;
}
.footer-ci {
color: var(--ci-muted);
font-size: 0.85rem;
}
.validation-note {
font-size: 0.8rem;
color: var(--ci-muted);
padding-left: 1rem;
border-left: 2px solid var(--ci-border);
margin-bottom: 0.35rem;
}
.spinner-overlay {
position: fixed;
inset: 0;
background: rgba(255,255,255,0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
}
/* ---- Dashboard stat cards ---- */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.9rem;
margin-bottom: 1.5rem;
}
.stat-card {
cursor: pointer;
border: none;
border-radius: 14px;
padding: 1.1rem 1.2rem;
color: #fff;
position: relative;
overflow: hidden;
transition: transform 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 3px 10px rgba(0,0,0,0.08);
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 10px 22px rgba(0,0,0,0.16);
}
.stat-card:active { transform: translateY(-1px); }
.stat-card .stat-icon {
font-size: 1.5rem;
opacity: 0.85;
}
.stat-card .stat-value {
font-size: 2rem;
font-weight: 800;
line-height: 1.1;
margin-top: 0.35rem;
}
.stat-card .stat-label {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.9;
margin-top: 0.15rem;
}
.stat-card .stat-arrow {
position: absolute;
right: 0.9rem;
bottom: 0.8rem;
font-size: 1.1rem;
opacity: 0.55;
}
.stat-card.stat-patients { background: linear-gradient(135deg, #0f6e6e, #2e86ab); }
.stat-card.stat-conditions { background: linear-gradient(135deg, #b9770e, #d68910); }
.stat-card.stat-medications{ background: linear-gradient(135deg, #6c3483, #9b59b6); }
.stat-card.stat-allergies { background: linear-gradient(135deg, #922b21, #c0392b); }
.stat-card.stat-observations{ background: linear-gradient(135deg, #148f77, #1abc9c); }
.stat-card.stat-encounters { background: linear-gradient(135deg, #1a5276, #2e86ab); }
.stat-card.stat-careplans { background: linear-gradient(135deg, #196f3d, #27ae60); }
.stat-card.stat-alerts { background: linear-gradient(135deg, #922b21, #e74c3c); }
/* ---- Stats scope label (shown when cards are scoped to a search/patient) ---- */
.stats-scope-label {
background: #eaf6f6;
border: 1px solid #cfe9e8;
color: var(--ci-primary-dark);
border-radius: 10px;
padding: 0.5rem 0.9rem;
font-size: 0.85rem;
display: inline-block;
}
.stats-scope-label a {
color: var(--ci-primary-dark);
font-weight: 600;
text-decoration: underline;
}
/* ---- Scrollable patient list ---- */
.patient-list-scroll {
max-height: 300px;
overflow-y: auto;
padding-right: 4px;
}
/* ---- Scrollable chart resource lists (Conditions, Allergies, Medications,
Observations, Encounters panels on the patient chart) each box scrolls
independently once it has more than ~3 items, instead of the card
growing to fit everything. !important guards against any other rule
(e.g. a card-ci default) silently overriding the height/overflow. ---- */
.chart-list-scroll {
max-height: 190px !important;
overflow-y: auto !important;
padding-right: 6px;
display: block;
}
.chart-list-scroll::-webkit-scrollbar,
.patient-list-scroll::-webkit-scrollbar {
width: 7px;
}
.chart-list-scroll::-webkit-scrollbar-track,
.patient-list-scroll::-webkit-scrollbar-track {
background: transparent;
}
.chart-list-scroll::-webkit-scrollbar-thumb,
.patient-list-scroll::-webkit-scrollbar-thumb {
background: var(--ci-border);
border-radius: 999px;
}
.chart-list-scroll::-webkit-scrollbar-thumb:hover,
.patient-list-scroll::-webkit-scrollbar-thumb:hover {
background: var(--ci-primary);
}
/* ---- Resource browser (all-patients table view) ---- */
.resource-browser-table th {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--ci-muted);
border-bottom: 2px solid var(--ci-border);
}
.resource-browser-table td {
vertical-align: middle;
}
.resource-browser-table tr:hover td {
background-color: #f4faf9;
}
.patient-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: #eaf6f6;
color: var(--ci-primary-dark);
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
}
.patient-chip:hover { background: #d7ecec; }

Binary file not shown.

Binary file not shown.