Compare commits
7 Commits
main
...
Himanshu_N
| Author | SHA1 | Date | |
|---|---|---|---|
| f372b5ec0d | |||
| 89d50a8392 | |||
| 0cf76dc9de | |||
| 6055f0cc4e | |||
| 60deef9071 | |||
| 4fa75217bf | |||
| 5418b0fd6f |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 & Service Request
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
container.querySelectorAll('.create-careplan-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => createCarePlanFromAlert(parseInt(btn.dataset.alertId, 10)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCarePlanFromAlert(alertId) {
|
|
||||||
try {
|
|
||||||
showSpinner(true);
|
|
||||||
await Api.createCarePlan({
|
|
||||||
patientId: currentPatientId,
|
|
||||||
alertId,
|
|
||||||
createServiceRequest: true,
|
|
||||||
priority: 'urgent'
|
|
||||||
});
|
|
||||||
showToast('Care Plan and Service Request created and written back to the FHIR store.');
|
|
||||||
|
|
||||||
document.querySelector(`.alert-card[data-alert-id="${alertId}"]`)?.remove();
|
|
||||||
const badge = document.getElementById('alertCountBadge');
|
|
||||||
const remaining = document.querySelectorAll('.alert-card').length;
|
|
||||||
if (remaining === 0) {
|
|
||||||
resetCdsTab();
|
|
||||||
document.getElementById('cdsAlertsContainer').innerHTML =
|
|
||||||
'<div class="text-center py-5"><div class="fs-1">✅</div><div class="text-muted-ci">All alerts for this encounter have been actioned.</div></div>';
|
|
||||||
} else {
|
|
||||||
badge.textContent = remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadCarePlans(currentPatientId);
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message, 'danger');
|
|
||||||
} finally {
|
|
||||||
showSpinner(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Care Plans ----------
|
|
||||||
async function loadCarePlans(patientId) {
|
|
||||||
if (!patientId) return;
|
|
||||||
try {
|
|
||||||
const carePlans = await Api.getCarePlans(patientId);
|
|
||||||
const tbody = document.getElementById('carePlansTableBody');
|
|
||||||
|
|
||||||
tbody.innerHTML = carePlans.length
|
|
||||||
? carePlans.map(cp => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(cp.title)}</td>
|
|
||||||
<td><span class="small text-muted-ci">${escapeHtml(cp.triggeredByRule)}</span></td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success">${escapeHtml(cp.status)}</span></td>
|
|
||||||
<td class="small text-muted-ci">${fmtDate(cp.createdDate)}</td>
|
|
||||||
</tr>`).join('')
|
|
||||||
: '<tr><td colspan="4" class="text-muted-ci text-center py-4">No care plans yet.</td></tr>';
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.Net Capstone Project/MedplpumDB.sql
Normal file
BIN
.Net Capstone Project/MedplpumDB.sql
Normal file
Binary file not shown.
@ -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>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.Net Capstone Project/clinicalinsightsprodbNew2.sql
Normal file
BIN
.Net Capstone Project/clinicalinsightsprodbNew2.sql
Normal file
Binary file not shown.
@ -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 & 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 & 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 (0–10)</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 & 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.
@ -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 →</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
@ -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();
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
Loading…
Reference in New Issue
Block a user