Compare commits
13 Commits
main
...
rushali_do
| Author | SHA1 | Date | |
|---|---|---|---|
| 0324d207cc | |||
| 888eaa3dac | |||
| 9925126701 | |||
| da525bc8d3 | |||
| d549583de0 | |||
| 2806df61f1 | |||
| c928f1493f | |||
| 8fc19f1868 | |||
| 502918bbe2 | |||
| b352665fda | |||
| 457b801fb1 | |||
| e3ed1134ca | |||
| 7ab8bff37b |
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/ClinicalInsightsPro-fixed.zip
Normal file
BIN
.Net Capstone Project/ClinicalInsightsPro-fixed.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/ClinicalInsightsPro-fixedv1.zip
Normal file
BIN
.Net Capstone Project/ClinicalInsightsPro-fixedv1.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/ClinicalInsightsPro-fixedv2.zip
Normal file
BIN
.Net Capstone Project/ClinicalInsightsPro-fixedv2.zip
Normal file
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.
665
.Net Capstone Project/LiveMedplumFhirService.cs
Normal file
665
.Net Capstone Project/LiveMedplumFhirService.cs
Normal file
@ -0,0 +1,665 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ClinicalInsightsPro.API.DTOs;
|
||||||
|
using ClinicalInsightsPro.API.Models;
|
||||||
|
using ClinicalInsightsPro.API.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace ClinicalInsightsPro.API.Services;
|
||||||
|
|
||||||
|
// Talks to a real Medplum FHIR R4 server over HTTP, using the OAuth2 client
|
||||||
|
// credentials grant (Fhir:MedplumClientId / MedplumClientSecret, both loaded
|
||||||
|
// from .env -- see Program.cs) against Fhir:MedplumTokenUrl, then
|
||||||
|
// reading FHIR resources at Fhir:MedplumBaseUrl. Activated when
|
||||||
|
// Fhir:UseLiveMedplum = true (MEDPLUM_USE_LIVE in .env); otherwise the app
|
||||||
|
// falls back to MedplumFhirService, which reads the local PostgreSQL store
|
||||||
|
// so the app still runs without live Medplum credentials.
|
||||||
|
//
|
||||||
|
// This service is READ-ONLY and never touches ApplicationDbContext -- the
|
||||||
|
// entities built below (Condition, Observation, AllergyIntolerance,
|
||||||
|
// MedicationRequest, Encounter, Patient) are transient objects used only to
|
||||||
|
// (a) run them through IValidationService, the same way the local store's
|
||||||
|
// rows are validated, and (b) shape the PatientChartDto response. So the
|
||||||
|
// [Required]/FK attributes on those model classes (e.g. Condition.RecorderId)
|
||||||
|
// are never enforced here -- nothing is ever SaveChanges'd.
|
||||||
|
public class LiveMedplumFhirService : IMedplumFhirService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly IValidationService _validation;
|
||||||
|
private readonly ILogger<LiveMedplumFhirService> _logger;
|
||||||
|
private readonly string _envFilePath;
|
||||||
|
|
||||||
|
private string? _cachedToken;
|
||||||
|
private DateTime _tokenExpiresAt = DateTime.MinValue;
|
||||||
|
|
||||||
|
public LiveMedplumFhirService(HttpClient http, IConfiguration config, IValidationService validation,
|
||||||
|
ILogger<LiveMedplumFhirService> logger, IHostEnvironment hostEnvironment)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_config = config;
|
||||||
|
_validation = validation;
|
||||||
|
_logger = logger;
|
||||||
|
_envFilePath = Path.Combine(hostEnvironment.ContentRootPath, ".env");
|
||||||
|
|
||||||
|
// Reuse a still-valid token already written to .env (e.g. by a
|
||||||
|
// previous startup within the token's lifetime) instead of hitting
|
||||||
|
// Medplum again immediately.
|
||||||
|
var (savedToken, savedExpiry) = EnvTokenStore.TryLoadMedplumToken(_envFilePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(savedToken) && savedExpiry.HasValue && savedExpiry.Value > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
_cachedToken = savedToken;
|
||||||
|
_tokenExpiresAt = savedExpiry.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called once at application startup (see Program.cs) so a fresh Medplum
|
||||||
|
// access token is already generated and saved to .env by the time
|
||||||
|
// Swagger/the API is reachable -- no manual "call the token endpoint
|
||||||
|
// first" step required.
|
||||||
|
public async Task InitializeTokenAsync() => await EnsureAuthorizedAsync();
|
||||||
|
|
||||||
|
private async Task EnsureAuthorizedAsync()
|
||||||
|
{
|
||||||
|
if (_cachedToken != null && DateTime.UtcNow < _tokenExpiresAt)
|
||||||
|
{
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _cachedToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenUrl = _config["Fhir:MedplumTokenUrl"];
|
||||||
|
var clientId = _config["Fhir:MedplumClientId"];
|
||||||
|
var clientSecret = _config["Fhir:MedplumClientSecret"];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tokenUrl) || string.IsNullOrWhiteSpace(clientId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Live Medplum mode is enabled (MEDPLUM_USE_LIVE=true in .env) but MEDPLUM_TOKEN_URL / " +
|
||||||
|
"MEDPLUM_CLIENT_ID are not set. Set these in .env to connect to Medplum.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "client_credentials",
|
||||||
|
["client_id"] = clientId,
|
||||||
|
["client_secret"] = clientSecret ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
using var response = await _http.PostAsync(tokenUrl, new FormUrlEncodedContent(form));
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
var payload = await JsonSerializer.DeserializeAsync<JsonElement>(stream);
|
||||||
|
|
||||||
|
_cachedToken = payload.GetProperty("access_token").GetString();
|
||||||
|
var expiresIn = payload.TryGetProperty("expires_in", out var exp) ? exp.GetInt32() : 300;
|
||||||
|
_tokenExpiresAt = DateTime.UtcNow.AddSeconds(Math.Max(30, expiresIn - 30));
|
||||||
|
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _cachedToken);
|
||||||
|
|
||||||
|
// Persist so the token survives across scoped-service instances
|
||||||
|
// within this run and is visible in the .env file, per project setup.
|
||||||
|
EnvTokenStore.SaveMedplumToken(_envFilePath, _cachedToken!, _tokenExpiresAt);
|
||||||
|
|
||||||
|
_logger.LogInformation("Obtained Medplum client-credentials access token (expires in {ExpiresIn}s) and saved it to {EnvFile}.", expiresIn, _envFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PatientSummaryDto>> GetPatientListAsync()
|
||||||
|
{
|
||||||
|
await EnsureAuthorizedAsync();
|
||||||
|
var baseUrl = _config["Fhir:MedplumBaseUrl"]!.TrimEnd('/');
|
||||||
|
|
||||||
|
var bundle = await GetBundleAsync($"{baseUrl}/Patient?_count=50");
|
||||||
|
var results = new List<PatientSummaryDto>();
|
||||||
|
|
||||||
|
foreach (var entry in bundle.EnumerateEntries())
|
||||||
|
{
|
||||||
|
var patient = MapPatient(entry);
|
||||||
|
results.Add(new PatientSummaryDto
|
||||||
|
{
|
||||||
|
Id = patient.Id,
|
||||||
|
FullName = $"{patient.GivenName} {patient.FamilyName}",
|
||||||
|
Age = CalculateAge(patient.BirthDate),
|
||||||
|
Gender = patient.Gender,
|
||||||
|
MedicalRecordNumber = patient.MedicalRecordNumber
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PatientChartDto> GetPatientChartAsync(string patientId)
|
||||||
|
{
|
||||||
|
await EnsureAuthorizedAsync();
|
||||||
|
var baseUrl = _config["Fhir:MedplumBaseUrl"]!.TrimEnd('/');
|
||||||
|
|
||||||
|
var patientResponse = await _http.GetAsync($"{baseUrl}/Patient/{patientId}");
|
||||||
|
if (patientResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
throw new KeyNotFoundException($"Patient '{patientId}' was not found in Medplum.");
|
||||||
|
}
|
||||||
|
patientResponse.EnsureSuccessStatusCode();
|
||||||
|
using var patientStream = await patientResponse.Content.ReadAsStreamAsync();
|
||||||
|
var patientJson = await JsonSerializer.DeserializeAsync<JsonElement>(patientStream);
|
||||||
|
var patient = MapPatient(patientJson);
|
||||||
|
|
||||||
|
var conditions = await FetchResourceListAsync($"{baseUrl}/Condition?patient={patientId}", MapCondition, patientId);
|
||||||
|
var observations = await FetchResourceListAsync($"{baseUrl}/Observation?patient={patientId}", MapObservation, patientId);
|
||||||
|
var allergies = await FetchResourceListAsync($"{baseUrl}/AllergyIntolerance?patient={patientId}", MapAllergy, patientId);
|
||||||
|
var medications = await FetchResourceListAsync($"{baseUrl}/MedicationRequest?patient={patientId}", MapMedication, patientId);
|
||||||
|
var encounters = await FetchResourceListAsync($"{baseUrl}/Encounter?patient={patientId}", MapEncounter, patientId);
|
||||||
|
|
||||||
|
var notes = new List<string> { "FHIR bundle retrieved live from Medplum and validated against R4 structure definitions." };
|
||||||
|
foreach (var c in conditions) notes.AddRange(_validation.ValidateAndNormalizeCondition(c));
|
||||||
|
foreach (var o in observations) notes.AddRange(_validation.ValidateAndNormalizeObservation(o));
|
||||||
|
foreach (var a in allergies) notes.AddRange(_validation.ValidateAndNormalizeAllergy(a));
|
||||||
|
foreach (var m in medications) notes.AddRange(_validation.ValidateAndNormalizeMedication(m));
|
||||||
|
foreach (var e in encounters) notes.AddRange(_validation.ValidateAndNormalizeEncounter(e));
|
||||||
|
|
||||||
|
if (!allergies.Any())
|
||||||
|
{
|
||||||
|
notes.Add("No AllergyIntolerance resources found -- treated as 'no known allergies' (NKA).");
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
|
||||||
|
ValidationNotes = notes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<T>> FetchResourceListAsync<T>(string url, Func<JsonElement, string, T> mapper, string patientId)
|
||||||
|
{
|
||||||
|
var bundle = await GetBundleAsync(url);
|
||||||
|
return bundle.EnumerateEntries().Select(e => mapper(e, patientId)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FhirBundle> GetBundleAsync(string url)
|
||||||
|
{
|
||||||
|
var response = await _http.GetAsync(url);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
var json = await JsonSerializer.DeserializeAsync<JsonElement>(stream);
|
||||||
|
return new FhirBundle(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FHIR JSON -> domain model mapping ----
|
||||||
|
// Every mapper below targets the CURRENT Models/*.cs shape (the one
|
||||||
|
// ApplicationDbContext/MedplumFhirService use), not the old
|
||||||
|
// FirstName/LastName/Code/Display-style entities. These objects are only
|
||||||
|
// ever read from, never saved -- see the class-level comment.
|
||||||
|
|
||||||
|
private static Patient MapPatient(JsonElement res)
|
||||||
|
{
|
||||||
|
var name = FirstOrDefault(res, "name");
|
||||||
|
var given = name.ValueKind == JsonValueKind.Object ? GetFirstArrayString(name, "given") : string.Empty;
|
||||||
|
var family = name.ValueKind == JsonValueKind.Object ? GetString(name, "family") : string.Empty;
|
||||||
|
var nameUse = name.ValueKind == JsonValueKind.Object ? GetString(name, "use") : null;
|
||||||
|
var prefix = name.ValueKind == JsonValueKind.Object ? GetFirstArrayString(name, "prefix") : null;
|
||||||
|
|
||||||
|
var identifier = FirstOrDefault(res, "identifier");
|
||||||
|
var identifierSystem = identifier.ValueKind == JsonValueKind.Object ? GetString(identifier, "system") : null;
|
||||||
|
var identifierValue = identifier.ValueKind == JsonValueKind.Object ? GetString(identifier, "value") : null;
|
||||||
|
|
||||||
|
var telecom = FirstOfSystem(res, "telecom", "phone");
|
||||||
|
var email = FirstOfSystem(res, "telecom", "email");
|
||||||
|
|
||||||
|
var address = FirstOrDefault(res, "address");
|
||||||
|
var addressLine = address.ValueKind == JsonValueKind.Object ? GetFirstArrayString(address, "line") : null;
|
||||||
|
|
||||||
|
var maritalStatus = res.TryGetProperty("maritalStatus", out var ms) ? ms : default;
|
||||||
|
var managingOrgId = GetReferenceId(res, "managingOrganization");
|
||||||
|
var generalPractitionerRef = FirstOrDefault(res, "generalPractitioner");
|
||||||
|
var generalPractitionerId = generalPractitionerRef.ValueKind == JsonValueKind.Object
|
||||||
|
? ExtractReferenceId(GetString(generalPractitionerRef, "reference"))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var photo = FirstOrDefault(res, "photo");
|
||||||
|
|
||||||
|
return new Patient
|
||||||
|
{
|
||||||
|
Id = GetString(res, "id") ?? string.Empty,
|
||||||
|
PatientId = GetString(res, "id") ?? string.Empty,
|
||||||
|
ResourceType = "Patient",
|
||||||
|
|
||||||
|
MedicalRecordNumber = identifierValue ?? string.Empty,
|
||||||
|
IdentifierSystem = identifierSystem ?? string.Empty,
|
||||||
|
IdentifierValue = identifierValue ?? string.Empty,
|
||||||
|
|
||||||
|
NameUse = nameUse ?? "official",
|
||||||
|
Prefix = prefix,
|
||||||
|
GivenName = given ?? string.Empty,
|
||||||
|
FamilyName = family ?? string.Empty,
|
||||||
|
|
||||||
|
TelecomSystem = telecom.system ?? "phone",
|
||||||
|
TelecomUse = telecom.use ?? "home",
|
||||||
|
TelecomValue = telecom.value ?? string.Empty,
|
||||||
|
|
||||||
|
Gender = GetString(res, "gender") ?? "unknown",
|
||||||
|
|
||||||
|
Email = email.value,
|
||||||
|
|
||||||
|
BirthDate = GetString(res, "birthDate") is string bd && DateTime.TryParse(bd, out var d) ? d : DateTime.UtcNow,
|
||||||
|
|
||||||
|
AddressUse = address.ValueKind == JsonValueKind.Object ? GetString(address, "use") ?? "home" : "home",
|
||||||
|
AddressType = address.ValueKind == JsonValueKind.Object ? GetString(address, "type") ?? "both" : "both",
|
||||||
|
AddressLine = addressLine,
|
||||||
|
City = address.ValueKind == JsonValueKind.Object ? GetString(address, "city") ?? string.Empty : string.Empty,
|
||||||
|
State = address.ValueKind == JsonValueKind.Object ? GetString(address, "state") ?? string.Empty : string.Empty,
|
||||||
|
PostalCode = address.ValueKind == JsonValueKind.Object ? GetString(address, "postalCode") ?? string.Empty : string.Empty,
|
||||||
|
|
||||||
|
MaritalStatusSystem = maritalStatus.ValueKind == JsonValueKind.Object ? GetFirstCodingField(maritalStatus, "system") : null,
|
||||||
|
MaritalStatusCode = maritalStatus.ValueKind == JsonValueKind.Object ? GetFirstCodingField(maritalStatus, "code") : null,
|
||||||
|
MaritalStatusDisplay = maritalStatus.ValueKind == JsonValueKind.Object ? GetFirstCodingField(maritalStatus, "display") : null,
|
||||||
|
|
||||||
|
ManagingOrganizationId = managingOrgId,
|
||||||
|
GeneralPractitionerId = generalPractitionerId,
|
||||||
|
|
||||||
|
PhotoContentType = photo.ValueKind == JsonValueKind.Object ? GetString(photo, "contentType") : null,
|
||||||
|
PhotoUrl = photo.ValueKind == JsonValueKind.Object ? GetString(photo, "url") : null,
|
||||||
|
PhotoTitle = photo.ValueKind == JsonValueKind.Object ? GetString(photo, "title") : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Condition MapCondition(JsonElement res, string patientId)
|
||||||
|
{
|
||||||
|
var clinicalStatus = res.TryGetProperty("clinicalStatus", out var cs) ? cs : default;
|
||||||
|
var verificationStatus = res.TryGetProperty("verificationStatus", out var vs) ? vs : default;
|
||||||
|
var category = FirstOrDefault(res, "category");
|
||||||
|
var severity = res.TryGetProperty("severity", out var sev) ? sev : default;
|
||||||
|
|
||||||
|
return new Condition
|
||||||
|
{
|
||||||
|
Id = GetString(res, "id") ?? Guid.NewGuid().ToString(),
|
||||||
|
ConditionId = GetString(res, "id") ?? Guid.NewGuid().ToString(),
|
||||||
|
ResourceType = "Condition",
|
||||||
|
|
||||||
|
ClinicalStatusCode = (clinicalStatus.ValueKind == JsonValueKind.Object ? GetFirstCodingField(clinicalStatus, "code") : null) ?? string.Empty,
|
||||||
|
ClinicalStatusDisplay = (clinicalStatus.ValueKind == JsonValueKind.Object ? GetFirstCodingField(clinicalStatus, "display") : null) ?? string.Empty,
|
||||||
|
|
||||||
|
VerificationStatusCode = (verificationStatus.ValueKind == JsonValueKind.Object ? GetFirstCodingField(verificationStatus, "code") : null) ?? string.Empty,
|
||||||
|
VerificationStatusDisplay = (verificationStatus.ValueKind == JsonValueKind.Object ? GetFirstCodingField(verificationStatus, "display") : null) ?? string.Empty,
|
||||||
|
|
||||||
|
CategorySystem = category.ValueKind == JsonValueKind.Object ? GetFirstCodingField(category, "system") : null,
|
||||||
|
CategoryCode = category.ValueKind == JsonValueKind.Object ? GetFirstCodingField(category, "code") : null,
|
||||||
|
CategoryDisplay = category.ValueKind == JsonValueKind.Object ? GetFirstCodingField(category, "display") : null,
|
||||||
|
|
||||||
|
SeverityCode = (severity.ValueKind == JsonValueKind.Object ? GetFirstCodingField(severity, "code") : null) ?? string.Empty,
|
||||||
|
SeverityDisplay = (severity.ValueKind == JsonValueKind.Object ? GetFirstCodingField(severity, "display") : null) ?? string.Empty,
|
||||||
|
|
||||||
|
ConditionCode = GetCodeableConceptCode(res, "code"),
|
||||||
|
ConditionDisplay = GetCodeableConceptDisplay(res, "code"),
|
||||||
|
|
||||||
|
PatientId = patientId,
|
||||||
|
|
||||||
|
OnsetDateTime = GetString(res, "onsetDateTime") is string od && DateTime.TryParse(od, out var onset) ? onset : DateTime.UtcNow,
|
||||||
|
AbatementDateTime = GetString(res, "abatementDateTime") is string ad && DateTime.TryParse(ad, out var abate) ? abate : (DateTime?)null,
|
||||||
|
RecordedDate = GetString(res, "recordedDate") is string rd && DateTime.TryParse(rd, out var recorded) ? recorded : DateTime.UtcNow,
|
||||||
|
|
||||||
|
RecorderId = ExtractReferenceId(GetReferenceString(res, "recorder")) ?? string.Empty,
|
||||||
|
AsserterId = ExtractReferenceId(GetReferenceString(res, "asserter")) ?? string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Observation MapObservation(JsonElement res, string patientId)
|
||||||
|
{
|
||||||
|
var category = FirstOrDefault(res, "category");
|
||||||
|
var interpretation = FirstOrDefault(res, "interpretation");
|
||||||
|
var note = FirstOrDefault(res, "note");
|
||||||
|
|
||||||
|
return new Observation
|
||||||
|
{
|
||||||
|
Id = GetString(res, "id") ?? Guid.NewGuid().ToString(),
|
||||||
|
Status = GetString(res, "status") ?? "final",
|
||||||
|
|
||||||
|
CategoryCode = category.ValueKind == JsonValueKind.Object ? GetFirstCodingField(category, "code") : null,
|
||||||
|
CategoryDisplay = category.ValueKind == JsonValueKind.Object ? GetFirstCodingField(category, "display") : null,
|
||||||
|
|
||||||
|
ObservationCode = GetCodeableConceptCode(res, "code"),
|
||||||
|
ObservationDisplay = GetCodeableConceptDisplay(res, "code"),
|
||||||
|
|
||||||
|
PatientId = patientId,
|
||||||
|
|
||||||
|
PerformerPractitionerId = ExtractReferenceId(GetFirstReferenceString(res, "performer")),
|
||||||
|
|
||||||
|
EffectiveDateTime = GetString(res, "effectiveDateTime") is string ed && DateTimeOffset.TryParse(ed, out var eff) ? eff : (DateTimeOffset?)null,
|
||||||
|
Issued = GetString(res, "issued") is string iss && DateTimeOffset.TryParse(iss, out var issued) ? issued : (DateTimeOffset?)null,
|
||||||
|
|
||||||
|
NoteText = note.ValueKind == JsonValueKind.Object ? GetString(note, "text") : null,
|
||||||
|
NoteAuthorPractitionerId = note.ValueKind == JsonValueKind.Object ? ExtractReferenceId(GetString(note, "authorReference")) : null,
|
||||||
|
NoteTime = note.ValueKind == JsonValueKind.Object && GetString(note, "time") is string nt && DateTimeOffset.TryParse(nt, out var noteTime) ? noteTime : (DateTimeOffset?)null,
|
||||||
|
|
||||||
|
InterpretationCode = interpretation.ValueKind == JsonValueKind.Object ? GetFirstCodingField(interpretation, "code") : null,
|
||||||
|
InterpretationDisplay = interpretation.ValueKind == JsonValueKind.Object ? GetFirstCodingField(interpretation, "display") : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AllergyIntolerance MapAllergy(JsonElement res, string patientId)
|
||||||
|
{
|
||||||
|
var clinicalStatus = res.TryGetProperty("clinicalStatus", out var cs) ? cs : default;
|
||||||
|
var verificationStatus = res.TryGetProperty("verificationStatus", out var vs) ? vs : default;
|
||||||
|
|
||||||
|
return new AllergyIntolerance
|
||||||
|
{
|
||||||
|
Id = GetString(res, "id") ?? Guid.NewGuid().ToString(),
|
||||||
|
ClinicalStatus = (clinicalStatus.ValueKind == JsonValueKind.Object ? GetFirstCodingField(clinicalStatus, "code") : null) ?? "active",
|
||||||
|
VerificationStatus = (verificationStatus.ValueKind == JsonValueKind.Object ? GetFirstCodingField(verificationStatus, "code") : null) ?? "unconfirmed",
|
||||||
|
Type = GetString(res, "type") ?? "allergy",
|
||||||
|
Criticality = GetString(res, "criticality"),
|
||||||
|
|
||||||
|
PatientId = patientId,
|
||||||
|
|
||||||
|
AsserterPatientId = ExtractReferenceId(GetReferenceString(res, "asserter")),
|
||||||
|
RecorderPractitionerId = ExtractReferenceId(GetReferenceString(res, "recorder"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MedicationRequest MapMedication(JsonElement res, string patientId) => new()
|
||||||
|
{
|
||||||
|
Id = GetString(res, "id") ?? Guid.NewGuid().ToString(),
|
||||||
|
Status = GetString(res, "status") ?? "active",
|
||||||
|
Intent = GetString(res, "intent") ?? "order",
|
||||||
|
Priority = GetString(res, "priority"),
|
||||||
|
|
||||||
|
PatientId = patientId,
|
||||||
|
|
||||||
|
MedicationCode = GetCodeableConceptCode(res, "medicationCodeableConcept"),
|
||||||
|
MedicationDisplay = GetCodeableConceptDisplay(res, "medicationCodeableConcept")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Encounter MapEncounter(JsonElement res, string patientId)
|
||||||
|
{
|
||||||
|
var classField = res.TryGetProperty("class", out var cls) ? cls : default;
|
||||||
|
var type = FirstOrDefault(res, "type");
|
||||||
|
var serviceType = res.TryGetProperty("serviceType", out var st) ? st : default;
|
||||||
|
var priority = res.TryGetProperty("priority", out var pr) ? pr : default;
|
||||||
|
var period = res.TryGetProperty("period", out var p) ? p : default;
|
||||||
|
|
||||||
|
return new Encounter
|
||||||
|
{
|
||||||
|
Id = GetString(res, "id") ?? Guid.NewGuid().ToString(),
|
||||||
|
EncounterId = GetString(res, "id") ?? Guid.NewGuid().ToString(),
|
||||||
|
ResourceType = "Encounter",
|
||||||
|
Status = GetString(res, "status") ?? "finished",
|
||||||
|
|
||||||
|
ClassSystem = (classField.ValueKind == JsonValueKind.Object ? GetString(classField, "system") : null) ?? string.Empty,
|
||||||
|
ClassCode = (classField.ValueKind == JsonValueKind.Object ? GetString(classField, "code") : null) ?? string.Empty,
|
||||||
|
ClassDisplay = (classField.ValueKind == JsonValueKind.Object ? GetString(classField, "display") : null) ?? string.Empty,
|
||||||
|
|
||||||
|
TypeSystem = type.ValueKind == JsonValueKind.Object ? GetFirstCodingField(type, "system") : null,
|
||||||
|
TypeCode = type.ValueKind == JsonValueKind.Object ? GetFirstCodingField(type, "code") : null,
|
||||||
|
TypeDisplay = type.ValueKind == JsonValueKind.Object ? GetFirstCodingField(type, "display") : null,
|
||||||
|
|
||||||
|
ServiceTypeSystem = serviceType.ValueKind == JsonValueKind.Object ? GetFirstCodingField(serviceType, "system") : null,
|
||||||
|
ServiceTypeCode = serviceType.ValueKind == JsonValueKind.Object ? GetFirstCodingField(serviceType, "code") : null,
|
||||||
|
ServiceTypeDisplay = serviceType.ValueKind == JsonValueKind.Object ? GetFirstCodingField(serviceType, "display") : null,
|
||||||
|
|
||||||
|
PrioritySystem = priority.ValueKind == JsonValueKind.Object ? GetFirstCodingField(priority, "system") : null,
|
||||||
|
PriorityCode = priority.ValueKind == JsonValueKind.Object ? GetFirstCodingField(priority, "code") : null,
|
||||||
|
PriorityDisplay = priority.ValueKind == JsonValueKind.Object ? GetFirstCodingField(priority, "display") : null,
|
||||||
|
|
||||||
|
PatientId = patientId,
|
||||||
|
|
||||||
|
PeriodStart = period.ValueKind == JsonValueKind.Object && GetString(period, "start") is string ps && DateTime.TryParse(ps, out var start) ? start : DateTime.UtcNow,
|
||||||
|
PeriodEnd = period.ValueKind == JsonValueKind.Object && GetString(period, "end") is string pe && DateTime.TryParse(pe, out var end) ? end : DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- small JSON helpers ----
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement el, string propertyName) =>
|
||||||
|
el.ValueKind == JsonValueKind.Object && el.TryGetProperty(propertyName, out var v) && v.ValueKind == JsonValueKind.String
|
||||||
|
? v.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static JsonElement FirstOrDefault(JsonElement el, string arrayPropertyName) =>
|
||||||
|
el.ValueKind == JsonValueKind.Object && el.TryGetProperty(arrayPropertyName, out var arr) &&
|
||||||
|
arr.ValueKind == JsonValueKind.Array && arr.GetArrayLength() > 0
|
||||||
|
? arr[0]
|
||||||
|
: default;
|
||||||
|
|
||||||
|
private static string? GetFirstArrayString(JsonElement el, string arrayPropertyName)
|
||||||
|
{
|
||||||
|
if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(arrayPropertyName, out var arr) ||
|
||||||
|
arr.ValueKind != JsonValueKind.Array || arr.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return arr[0].ValueKind == JsonValueKind.String ? arr[0].GetString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finds the first entry of a telecom-style array whose "system" matches,
|
||||||
|
// e.g. FirstOfSystem(patient, "telecom", "phone").
|
||||||
|
private static (string? system, string? use, string? value) FirstOfSystem(JsonElement el, string arrayPropertyName, string system)
|
||||||
|
{
|
||||||
|
if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(arrayPropertyName, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return (null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in arr.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (GetString(item, "system") == system)
|
||||||
|
{
|
||||||
|
return (GetString(item, "system"), GetString(item, "use"), GetString(item, "value"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads system/code/display off the first entry of a CodeableConcept's
|
||||||
|
// "coding" array (or a "system"/"code"/"display" set directly on the
|
||||||
|
// element itself, for the maritalStatus / clinicalStatus shape).
|
||||||
|
private static string? GetFirstCodingField(JsonElement codeableConcept, string field)
|
||||||
|
{
|
||||||
|
if (codeableConcept.ValueKind != JsonValueKind.Object) return null;
|
||||||
|
|
||||||
|
if (codeableConcept.TryGetProperty("coding", out var coding) &&
|
||||||
|
coding.ValueKind == JsonValueKind.Array && coding.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
return GetString(coding[0], field);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetString(codeableConcept, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCodeableConceptCode(JsonElement res, string propertyName)
|
||||||
|
{
|
||||||
|
if (!res.TryGetProperty(propertyName, out var cc)) return string.Empty;
|
||||||
|
if (cc.ValueKind == JsonValueKind.String) return cc.GetString() ?? string.Empty;
|
||||||
|
if (cc.TryGetProperty("coding", out var coding) && coding.GetArrayLength() > 0 && coding[0].TryGetProperty("code", out var code))
|
||||||
|
return code.GetString() ?? string.Empty;
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCodeableConceptDisplay(JsonElement res, string propertyName)
|
||||||
|
{
|
||||||
|
if (!res.TryGetProperty(propertyName, out var cc)) return string.Empty;
|
||||||
|
return GetCodeableConceptDisplay(cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCodeableConceptDisplay(JsonElement cc)
|
||||||
|
{
|
||||||
|
if (cc.TryGetProperty("text", out var text)) return text.GetString() ?? string.Empty;
|
||||||
|
if (cc.TryGetProperty("coding", out var coding) && coding.GetArrayLength() > 0 && coding[0].TryGetProperty("display", out var display))
|
||||||
|
return display.GetString() ?? string.Empty;
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// reference-style: { "reference": "Practitioner/123" }
|
||||||
|
private static string? GetReferenceString(JsonElement res, string propertyName) =>
|
||||||
|
res.ValueKind == JsonValueKind.Object && res.TryGetProperty(propertyName, out var refEl) && refEl.ValueKind == JsonValueKind.Object
|
||||||
|
? GetString(refEl, "reference")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// array-of-reference-style: "performer": [ { "reference": "Practitioner/123" } ]
|
||||||
|
private static string? GetFirstReferenceString(JsonElement res, string arrayPropertyName)
|
||||||
|
{
|
||||||
|
var first = FirstOrDefault(res, arrayPropertyName);
|
||||||
|
return first.ValueKind == JsonValueKind.Object ? GetString(first, "reference") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Practitioner/123" -> "123"
|
||||||
|
private static string? ExtractReferenceId(string? reference)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(reference)) return null;
|
||||||
|
var slashIndex = reference.LastIndexOf('/');
|
||||||
|
return slashIndex >= 0 && slashIndex < reference.Length - 1 ? reference[(slashIndex + 1)..] : reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetReferenceId(JsonElement res, string propertyName) =>
|
||||||
|
ExtractReferenceId(GetReferenceString(res, propertyName));
|
||||||
|
|
||||||
|
private static int CalculateAge(DateTime dob)
|
||||||
|
{
|
||||||
|
var today = DateTime.UtcNow;
|
||||||
|
var age = today.Year - dob.Year;
|
||||||
|
if (dob.Date > today.AddYears(-age)) age--;
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small helper over a FHIR searchset Bundle to enumerate entry.resource elements.
|
||||||
|
private readonly struct FhirBundle
|
||||||
|
{
|
||||||
|
private readonly JsonElement _json;
|
||||||
|
public FhirBundle(JsonElement json) { _json = json; }
|
||||||
|
|
||||||
|
public IEnumerable<JsonElement> EnumerateEntries()
|
||||||
|
{
|
||||||
|
if (!_json.TryGetProperty("entry", out var entries) || entries.ValueKind != JsonValueKind.Array)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
foreach (var entry in entries.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (entry.TryGetProperty("resource", out var resource))
|
||||||
|
yield return resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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/V1files.zip
Normal file
BIN
.Net Capstone Project/V1files.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/clinicalinsightpro31.sql
Normal file
BIN
.Net Capstone Project/clinicalinsightpro31.sql
Normal file
Binary file not shown.
BIN
.Net Capstone Project/clinicalinsightsprodb31082026.sql
Normal file
BIN
.Net Capstone Project/clinicalinsightsprodb31082026.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; }
|
|
||||||
BIN
Demo/CDS-Enterprise-Appp (2).zip
Normal file
BIN
Demo/CDS-Enterprise-Appp (2).zip
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user