Compare commits

..

2 Commits

Author SHA1 Message Date
4fa75217bf Update Code
Update Code
2026-08-26 15:11:10 +00:00
5418b0fd6f Merge pull request 'New Pull' (#1) from main into himanshu_dotnet_capstone_project
Reviewed-on: #1
2026-08-21 09:32:34 +00:00
44 changed files with 0 additions and 7250 deletions

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.

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>());
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

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

@ -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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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; }