Compare commits
44 Commits
nisha_dotn
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e1452f689 | |||
| 0775ecf1e4 | |||
| dc713e2d0a | |||
| 7e52df0952 | |||
| 19e47f6403 | |||
| 9a35f9e52d | |||
| 2274719221 | |||
| f7b99018d7 | |||
| 8294c3d256 | |||
| 548bfcba00 | |||
| 595244066f | |||
| 125b51347c | |||
| 47176b4349 | |||
| a44b71c83e | |||
| 19b96e82cb | |||
| c0c1423346 | |||
| 4e5ae647c2 | |||
| 6c82c0c96b | |||
| d263bfc450 | |||
| 3d9c43e18c | |||
| 92e9e00a22 | |||
| 5c37184063 | |||
| f20fa61d58 | |||
| e8440ed643 | |||
| c676defb49 | |||
| 62d1d698d5 | |||
| a3d7ac839a | |||
| 24605e9ae5 | |||
| 7828f4233c | |||
| da3247b659 | |||
| a88544b8f1 | |||
| 8d3361cb19 | |||
| 973fcdc2bf | |||
| 0580a88beb | |||
| 607bec47f4 | |||
| 4c713baa96 | |||
| f617440f18 | |||
| acb0b2f5bd | |||
| 162fef31f1 | |||
| 0b8868b278 | |||
| 177a684d41 | |||
| 1ae29db38a | |||
| b32a324349 | |||
| ce414de029 |
Binary file not shown.
BIN
.Net Capstone Project/ApplicationDbContext_DateTimeFix.zip
Normal file
BIN
.Net Capstone Project/ApplicationDbContext_DateTimeFix.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/AuthBackground.zip
Normal file
BIN
.Net Capstone Project/AuthBackground.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/AutoToken_AllRequiredFiles.zip
Normal file
BIN
.Net Capstone Project/AutoToken_AllRequiredFiles.zip
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.Net Capstone Project/CapstoneNisha26082026.zip
Normal file
BIN
.Net Capstone Project/CapstoneNisha26082026.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/CapstoneNisha26082026_updated.zip
Normal file
BIN
.Net Capstone Project/CapstoneNisha26082026_updated.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/CapstoneRushali26082026.zip
Normal file
BIN
.Net Capstone Project/CapstoneRushali26082026.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Capstoneprojectgit.zip
Normal file
BIN
.Net Capstone Project/Capstoneprojectgit.zip
Normal file
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.zip
Normal file
BIN
.Net Capstone Project/ClinicalInsightsPro.zip
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.Net Capstone Project/Dashboard 28082026.zip
Normal file
BIN
.Net Capstone Project/Dashboard 28082026.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Dashboard empty colour.zip
Normal file
BIN
.Net Capstone Project/Dashboard empty colour.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Dashboard file 28 08 2026.zip
Normal file
BIN
.Net Capstone Project/Dashboard file 28 08 2026.zip
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
832
.Net Capstone Project/Dashboard27.js
Normal file
832
.Net Capstone Project/Dashboard27.js
Normal file
@ -0,0 +1,832 @@
|
|||||||
|
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.
BIN
.Net Capstone Project/DashboardRedesign_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/DashboardRedesign_ChangedFiles.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/DashboardStats_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/DashboardStats_ChangedFiles.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Files27.zip
Normal file
BIN
.Net Capstone Project/Files27.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Fix500Errors_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/Fix500Errors_ChangedFiles.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/InnerExceptionDetail_ChangedFile.zip
Normal file
BIN
.Net Capstone Project/InnerExceptionDetail_ChangedFile.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/JsonErrorDetail_ChangedFile.zip
Normal file
BIN
.Net Capstone Project/JsonErrorDetail_ChangedFile.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/LoginRegister_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/LoginRegister_ChangedFiles.zip
Normal file
Binary file not shown.
282
.Net Capstone Project/MedplumFhirService.cs
Normal file
282
.Net Capstone Project/MedplumFhirService.cs
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
.Net Capstone Project/Nishacapstone.zip
Normal file
BIN
.Net Capstone Project/Nishacapstone.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/NpgsqlLegacyTimestamp_Fix.zip
Normal file
BIN
.Net Capstone Project/NpgsqlLegacyTimestamp_Fix.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/PatientSearchScroll_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/PatientSearchScroll_ChangedFiles.zip
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.Net Capstone Project/RegisterFullFields_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/RegisterFullFields_ChangedFiles.zip
Normal file
Binary file not shown.
1593
.Net Capstone Project/Resourcepage
Normal file
1593
.Net Capstone Project/Resourcepage
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,147 +0,0 @@
|
|||||||
using ClinicalInsightsPro.API.Data;
|
|
||||||
using ClinicalInsightsPro.API.DTOs;
|
|
||||||
using ClinicalInsightsPro.API.Models;
|
|
||||||
using ClinicalInsightsPro.API.Services.Interfaces;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ClinicalInsightsPro.API.Services;
|
|
||||||
|
|
||||||
// Questionnaire-based rule engine that scores an encounter as High or Normal
|
|
||||||
// risk, combining chart data (conditions, vitals) with clinician-entered
|
|
||||||
// questionnaire responses. Rule weights are intentionally simple and modular
|
|
||||||
// so they can be extended, or eventually replaced by an ML model, per scope.
|
|
||||||
public class RiskScoringService : IRiskScoringService
|
|
||||||
{
|
|
||||||
private readonly ApplicationDbContext _db;
|
|
||||||
private const int HighRiskThreshold = 50;
|
|
||||||
|
|
||||||
public RiskScoringService(ApplicationDbContext db)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RiskAssessmentResultDto> AssessAsync(string patientId, QuestionnaireDto questionnaire)
|
|
||||||
{
|
|
||||||
var patient = await _db.Patients
|
|
||||||
.Include(p => p.Conditions)
|
|
||||||
.Include(p => p.Observations)
|
|
||||||
.Include(p => p.Allergies)
|
|
||||||
.Include(p => p.Medications)
|
|
||||||
.Include(p => p.Encounters)
|
|
||||||
.FirstOrDefaultAsync(p => p.Id == patientId);
|
|
||||||
|
|
||||||
if (patient == null)
|
|
||||||
throw new KeyNotFoundException($"Patient '{patientId}' was not found.");
|
|
||||||
|
|
||||||
int score = 0;
|
|
||||||
var factors = new List<string>();
|
|
||||||
|
|
||||||
foreach (var c in patient.Conditions.Where(c => c.ClinicalStatus == "active"))
|
|
||||||
{
|
|
||||||
switch (c.Code)
|
|
||||||
{
|
|
||||||
case "E11.9":
|
|
||||||
score += 20; factors.Add("Active Type 2 diabetes (+20)"); break;
|
|
||||||
case "I10":
|
|
||||||
score += 15; factors.Add("Essential hypertension (+15)"); break;
|
|
||||||
case "J45.909":
|
|
||||||
score += 10; factors.Add("Asthma (+10)"); break;
|
|
||||||
default:
|
|
||||||
score += 5; factors.Add($"Active condition: {c.Display} (+5)"); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var latestSystolic = patient.Observations
|
|
||||||
.Where(o => o.Code == "8480-6")
|
|
||||||
.OrderByDescending(o => o.EffectiveDate)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
if (latestSystolic != null && latestSystolic.Value >= 160)
|
|
||||||
{
|
|
||||||
score += 20; factors.Add($"Systolic BP {latestSystolic.Value} mmHg — hypertensive urgency range (+20)");
|
|
||||||
}
|
|
||||||
else if (latestSystolic != null && latestSystolic.Value >= 140)
|
|
||||||
{
|
|
||||||
score += 10; factors.Add($"Systolic BP {latestSystolic.Value} mmHg — stage 2 hypertension (+10)");
|
|
||||||
}
|
|
||||||
|
|
||||||
var latestGlucose = patient.Observations
|
|
||||||
.Where(o => o.Code == "2339-0")
|
|
||||||
.OrderByDescending(o => o.EffectiveDate)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
if (latestGlucose != null && latestGlucose.Value >= 200)
|
|
||||||
{
|
|
||||||
score += 15; factors.Add($"Glucose {latestGlucose.Value} mg/dL — poorly controlled (+15)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allergies — a known high-criticality allergy raises the risk of a
|
|
||||||
// future adverse drug event, independent of what's happening today.
|
|
||||||
var highCriticalityAllergies = patient.Allergies.Count(a => a.Criticality == "high");
|
|
||||||
if (highCriticalityAllergies > 0)
|
|
||||||
{
|
|
||||||
var allergyPoints = Math.Min(highCriticalityAllergies * 10, 20);
|
|
||||||
score += allergyPoints;
|
|
||||||
factors.Add($"{highCriticalityAllergies} high-criticality allerg{(highCriticalityAllergies == 1 ? "y" : "ies")} on file (+{allergyPoints})");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Medications — polypharmacy (commonly defined as 5+ concurrent
|
|
||||||
// medications) is itself an established risk factor for interactions
|
|
||||||
// and adverse events, regardless of what the medications are for.
|
|
||||||
var activeMedicationCount = patient.Medications.Count(m => m.Status == "active");
|
|
||||||
if (activeMedicationCount >= 5)
|
|
||||||
{
|
|
||||||
score += 12; factors.Add($"Polypharmacy — {activeMedicationCount} active medications (+12)");
|
|
||||||
}
|
|
||||||
else if (activeMedicationCount >= 3)
|
|
||||||
{
|
|
||||||
score += 6; factors.Add($"{activeMedicationCount} active medications (+6)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encounters — frequent recent encounters (visits, admissions, ED
|
|
||||||
// visits) in the last 12 months suggest an unstable or worsening
|
|
||||||
// condition even if no single encounter looks alarming on its own.
|
|
||||||
var recentEncounterCount = patient.Encounters.Count(e => e.EncounterDate >= DateTime.UtcNow.AddMonths(-12));
|
|
||||||
if (recentEncounterCount >= 3)
|
|
||||||
{
|
|
||||||
score += 12; factors.Add($"{recentEncounterCount} encounters within the last 12 months (+12)");
|
|
||||||
}
|
|
||||||
else if (recentEncounterCount == 2)
|
|
||||||
{
|
|
||||||
score += 6; factors.Add("2 encounters within the last 12 months (+6)");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionnaire.Smoker) { score += 10; factors.Add("Current smoker (+10)"); }
|
|
||||||
if (questionnaire.FamilyHistoryHeartDisease) { score += 8; factors.Add("Family history of heart disease (+8)"); }
|
|
||||||
if (questionnaire.PriorHospitalization) { score += 12; factors.Add("Prior hospitalization within 12 months (+12)"); }
|
|
||||||
if (questionnaire.Bmi is >= 30) { score += 8; factors.Add($"BMI {questionnaire.Bmi:F1} — obese range (+8)"); }
|
|
||||||
if (questionnaire.PainScore >= 7) { score += 5; factors.Add($"Reported pain score {questionnaire.PainScore}/10 (+5)"); }
|
|
||||||
|
|
||||||
score = Math.Min(score, 100);
|
|
||||||
var level = score >= HighRiskThreshold ? "High" : "Normal";
|
|
||||||
|
|
||||||
if (!factors.Any())
|
|
||||||
factors.Add("No significant risk factors identified from chart or questionnaire.");
|
|
||||||
|
|
||||||
var assessment = new RiskAssessment
|
|
||||||
{
|
|
||||||
PatientId = patientId,
|
|
||||||
Score = score,
|
|
||||||
RiskLevel = level,
|
|
||||||
Rationale = string.Join("; ", factors),
|
|
||||||
AssessedDate = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
_db.RiskAssessments.Add(assessment);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return new RiskAssessmentResultDto
|
|
||||||
{
|
|
||||||
PatientId = patientId,
|
|
||||||
Score = score,
|
|
||||||
RiskLevel = level,
|
|
||||||
ContributingFactors = factors,
|
|
||||||
AssessedDate = assessment.AssessedDate
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
.Net Capstone Project/ScopedStats_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/ScopedStats_ChangedFiles.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/SidebarAndBackground.zip
Normal file
BIN
.Net Capstone Project/SidebarAndBackground.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/SignIn_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/SignIn_ChangedFiles.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/SwaggerAutoAuth_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/SwaggerAutoAuth_ChangedFiles.zip
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.Net Capstone Project/Updated files.zip
Normal file
BIN
.Net Capstone Project/Updated files.zip
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.Net Capstone Project/Updatev1.zip
Normal file
BIN
.Net Capstone Project/Updatev1.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/UsernameSignIn_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/UsernameSignIn_ChangedFiles.zip
Normal file
Binary file not shown.
@ -1,367 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>CDS Alerts — 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>
|
|
||||||
.cds-tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
|
||||||
.cds-badge {
|
|
||||||
display: inline-block; padding: 0.28rem 0.7rem; border-radius: 999px;
|
|
||||||
font-size: 0.76rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px;
|
|
||||||
}
|
|
||||||
.cds-badge.critical, .cds-badge.high { background: #fdecea; color: #c0392b; }
|
|
||||||
.cds-badge.warning, .cds-badge.moderate { background: #fff6dd; color: #a9750c; }
|
|
||||||
.cds-badge.info, .cds-badge.low { background: #e8f2fb; color: #2e6fa8; }
|
|
||||||
|
|
||||||
.cds-priority-wrap { display: flex; align-items: center; gap: 0.5rem; min-width: 110px; }
|
|
||||||
.cds-priority-bar { flex: 1; height: 7px; border-radius: 999px; background: #eef1f1; overflow: hidden; }
|
|
||||||
.cds-priority-fill { height: 100%; border-radius: 999px; }
|
|
||||||
.cds-priority-fill.critical { background: #d64545; }
|
|
||||||
.cds-priority-fill.warning { background: #e0a83d; }
|
|
||||||
.cds-priority-fill.info { background: #4a90c4; }
|
|
||||||
.cds-priority-pct { font-size: 0.78rem; font-weight: 700; color: var(--ci-muted); width: 34px; text-align: right; }
|
|
||||||
|
|
||||||
.cds-overall-wrap { display: flex; align-items: center; gap: 10px; margin: 0.25rem 0 1.1rem; }
|
|
||||||
.cds-overall-bar { flex: 1; height: 9px; border-radius: 999px; background: #eef1f1; overflow: hidden; }
|
|
||||||
.cds-overall-fill { height: 100%; border-radius: 999px; }
|
|
||||||
.cds-overall-fill.high { background: #d64545; }
|
|
||||||
.cds-overall-fill.moderate { background: #e0a83d; }
|
|
||||||
.cds-overall-fill.low { background: #27ae60; }
|
|
||||||
.cds-overall-num { font-size: 1.15rem; font-weight: 700; min-width: 56px; text-align: right; }
|
|
||||||
|
|
||||||
.cds-actioned-pill { font-size: 0.72rem; font-weight: 600; padding: 0.15rem 0.55rem; border-radius: 999px; background: #eafaf1; color: #1e8449; }
|
|
||||||
.cds-actioned-pill.pending { background: #f4f4f4; color: #7a7a7a; }
|
|
||||||
.cds-alert-card { border: 1px solid #e6e9e8; border-radius: 12px; padding: 1rem 1.1rem; margin-bottom: 0.85rem; background: #fff; }
|
|
||||||
.cds-row-critical { border-left: 3px solid #d64545; }
|
|
||||||
.cds-row-warning { border-left: 3px solid #e0a83d; }
|
|
||||||
.cds-row-info { border-left: 3px solid #4a90c4; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-ci">
|
|
||||||
<div class="container-fluid px-4">
|
|
||||||
<a class="navbar-brand" href="dashboard.html">🩺 Clinical Insight Pro</a>
|
|
||||||
<div class="ms-auto d-flex align-items-center gap-3">
|
|
||||||
<span class="navbar-text" id="clinicianLabel">Clinician</span>
|
|
||||||
<button class="btn btn-outline-light btn-sm" id="logoutBtn">Sign out</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="app-shell">
|
|
||||||
<aside class="app-sidebar" id="appSidebar"></aside>
|
|
||||||
<main class="app-main">
|
|
||||||
<div class="resource-page-header">
|
|
||||||
<h4>🔔 CDS Alerts</h4>
|
|
||||||
<span class="text-muted-ci small" id="cds_countLabel"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-ci p-3 mb-3 d-flex flex-row align-items-center gap-3 flex-wrap">
|
|
||||||
<input type="text" class="form-control resource-search-bar" id="cds_searchInput" placeholder="Search by patient name or patient ID…" autocomplete="off">
|
|
||||||
<a href="cdsalert-add.html" class="btn btn-ci-primary ms-auto">+ Add Alert</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="cds_body" class="cds-tile-grid"></div>
|
|
||||||
<div id="cds_empty" class="text-muted-ci text-center py-5 d-none">No CDS alerts on file.</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toastContainer"></div>
|
|
||||||
|
|
||||||
<div class="modal fade" id="cds_editModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="cds_editModalTitle">Edit Alert</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<form id="cds_form">
|
|
||||||
<div class="modal-body row g-2">
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label small">Severity</label>
|
|
||||||
<select class="form-select" id="cds_severity">
|
|
||||||
<option value="info">Info</option>
|
|
||||||
<option value="warning" selected>Warning</option>
|
|
||||||
<option value="critical">Critical</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label small">Summary</label>
|
|
||||||
<textarea class="form-control" id="cds_summary" rows="2" required></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label small">Recommendation</label>
|
|
||||||
<textarea class="form-control" id="cds_recommendation" rows="2" required></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 form-check mt-2">
|
|
||||||
<input type="checkbox" class="form-check-input" id="cds_actioned">
|
|
||||||
<label class="form-check-label small" for="cds_actioned">Already actioned</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-ci-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="js/config.js"></script>
|
|
||||||
<script src="js/sidebar.js"></script>
|
|
||||||
<script src="js/api.js"></script>
|
|
||||||
<script>
|
|
||||||
const CDS_TILE_COLORS = ['primary', 'success', 'info', 'warning', 'danger', 'secondary'];
|
|
||||||
|
|
||||||
const CDS_PRIORITY = { critical: 100, warning: 60, info: 30 };
|
|
||||||
|
|
||||||
const CDS_RULE_WEIGHTS = { critical: 35, warning: 20, info: 8 };
|
|
||||||
|
|
||||||
let cdsSearchTerm = '';
|
|
||||||
let cdsEditModal, cdsEditingId = null, cdsEditingPatientId = null;
|
|
||||||
let cdsViewModalInstance = null;
|
|
||||||
|
|
||||||
function cdsEscapeHtml(s) {
|
|
||||||
if (s === null || s === undefined) return '';
|
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdsFmtDate(value) {
|
|
||||||
if (!value) return '—';
|
|
||||||
const d = new Date(value);
|
|
||||||
if (isNaN(d)) return '—';
|
|
||||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdsShowToast(message, variant = 'success') {
|
|
||||||
const container = document.getElementById('toastContainer');
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = `toast align-items-center text-bg-${variant === 'danger' ? 'danger' : 'success'} border-0 show mb-2`;
|
|
||||||
el.innerHTML = `<div class="d-flex"><div class="toast-body">${cdsEscapeHtml(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 cdsComputeOverallRisk(alerts) {
|
|
||||||
let score = 0;
|
|
||||||
alerts.forEach(a => { score += CDS_RULE_WEIGHTS[(a.severity || '').toLowerCase()] || 0; });
|
|
||||||
score = Math.min(score, 100);
|
|
||||||
const level = score >= 70 ? 'high' : score >= 40 ? 'moderate' : 'low';
|
|
||||||
return { score, level };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdsEnsureViewModal() {
|
|
||||||
let el = document.getElementById('cds_viewModal');
|
|
||||||
if (el) return el;
|
|
||||||
el = document.createElement('div');
|
|
||||||
el.className = 'modal fade';
|
|
||||||
el.id = 'cds_viewModal';
|
|
||||||
el.tabIndex = -1;
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="cds_viewModalTitle"></h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="cds_viewModalBody"></div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
document.body.appendChild(el);
|
|
||||||
cdsViewModalInstance = new bootstrap.Modal(el);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdsOpenViewModal(patientId, allRows) {
|
|
||||||
const items = allRows.filter(r => r.patientId === patientId)
|
|
||||||
.sort((a, b) => (CDS_PRIORITY[(b.severity||'').toLowerCase()]||0) - (CDS_PRIORITY[(a.severity||'').toLowerCase()]||0));
|
|
||||||
if (items.length === 0) return;
|
|
||||||
|
|
||||||
cdsEnsureViewModal();
|
|
||||||
document.getElementById('cds_viewModalTitle').textContent = items[0].patientName;
|
|
||||||
|
|
||||||
const overall = cdsComputeOverallRisk(items);
|
|
||||||
const critCount = items.filter(a => (a.severity||'').toLowerCase() === 'critical').length;
|
|
||||||
const warnCount = items.filter(a => (a.severity||'').toLowerCase() === 'warning').length;
|
|
||||||
const infoCount = items.filter(a => (a.severity||'').toLowerCase() === 'info').length;
|
|
||||||
|
|
||||||
const summaryHtml = `
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
||||||
<span class="cds-badge ${overall.level}">${overall.level} CDS risk</span>
|
|
||||||
<span class="small text-muted-ci">${critCount} critical (+${CDS_RULE_WEIGHTS.critical} each) · ${warnCount} warning (+${CDS_RULE_WEIGHTS.warning} each) · ${infoCount} info (+${CDS_RULE_WEIGHTS.info} each)</span>
|
|
||||||
</div>
|
|
||||||
<div class="cds-overall-wrap">
|
|
||||||
<div class="cds-overall-bar"><div class="cds-overall-fill ${overall.level}" style="width:${overall.score}%"></div></div>
|
|
||||||
<span class="cds-overall-num">${overall.score}%</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const cardsHtml = items.map(r => {
|
|
||||||
const sev = (r.severity || 'warning').toLowerCase();
|
|
||||||
const pct = CDS_PRIORITY[sev] ?? 50;
|
|
||||||
return `
|
|
||||||
<div class="cds-alert-card cds-row-${sev}">
|
|
||||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
|
||||||
<span class="cds-badge ${sev}">${cdsEscapeHtml(sev)}</span>
|
|
||||||
<span class="cds-actioned-pill ${r.actioned ? '' : 'pending'}">${r.actioned ? 'Actioned' : 'Pending'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="cds-priority-wrap mt-2">
|
|
||||||
<div class="cds-priority-bar"><div class="cds-priority-fill ${sev}" style="width:${pct}%"></div></div>
|
|
||||||
<span class="cds-priority-pct">${pct}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="fw-semibold mt-2">${cdsEscapeHtml(r.summary)}</div>
|
|
||||||
<div class="small text-muted-ci mt-1">${cdsEscapeHtml(r.recommendation)}</div>
|
|
||||||
<div class="small text-muted-ci mt-2">Generated ${cdsFmtDate(r.generatedDate)}</div>
|
|
||||||
<div class="mt-2 text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary me-1" data-edit-row="${r.id}" data-edit-patient="${r.patientId}">Edit</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" data-delete-row="${r.id}" data-delete-patient="${r.patientId}">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
document.getElementById('cds_viewModalBody').innerHTML = summaryHtml + cardsHtml;
|
|
||||||
cdsViewModalInstance.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cdsLoadGrid() {
|
|
||||||
const container = document.getElementById('cds_body');
|
|
||||||
const empty = document.getElementById('cds_empty');
|
|
||||||
let rows;
|
|
||||||
try {
|
|
||||||
rows = await Api.listAllCdsAlerts(cdsSearchTerm.trim());
|
|
||||||
} catch (err) {
|
|
||||||
cdsShowToast(err.message, 'danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const patientCount = new Set(rows.map(r => r.patientId)).size;
|
|
||||||
document.getElementById('cds_countLabel').textContent = `${rows.length} alert${rows.length === 1 ? '' : 's'} · ${patientCount} patient${patientCount === 1 ? '' : 's'}`;
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
container.innerHTML = '';
|
|
||||||
empty.classList.remove('d-none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
empty.classList.add('d-none');
|
|
||||||
|
|
||||||
const groups = new Map();
|
|
||||||
rows.forEach(r => {
|
|
||||||
if (!groups.has(r.patientId)) groups.set(r.patientId, { patientId: r.patientId, patientName: r.patientName, items: [] });
|
|
||||||
groups.get(r.patientId).items.push(r);
|
|
||||||
});
|
|
||||||
|
|
||||||
container.innerHTML = [...groups.values()].map((g, i) => {
|
|
||||||
const color = CDS_TILE_COLORS[i % CDS_TILE_COLORS.length];
|
|
||||||
const initials = g.patientName.split(' ').map(n => n[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
|
|
||||||
const overall = cdsComputeOverallRisk(g.items);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="card shadow-sm border-0" style="cursor:pointer" data-view-patient="${g.patientId}">
|
|
||||||
<div class="card-body text-center py-3">
|
|
||||||
<div class="rounded-circle bg-${color} text-white d-inline-flex align-items-center justify-content-center mb-2" style="width:44px;height:44px;font-size:14px">${cdsEscapeHtml(initials)}</div>
|
|
||||||
<div class="fw-semibold small">${cdsEscapeHtml(g.patientName)}</div>
|
|
||||||
<span class="cds-badge ${overall.level} mt-1 d-inline-block">${overall.level} risk · ${overall.score}%</span>
|
|
||||||
<div class="mt-2">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-${color}" data-view-patient="${g.patientId}">View (${g.items.length})</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
container._cdsRows = rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdsOpenEdit(row) {
|
|
||||||
cdsEditingId = row.id;
|
|
||||||
cdsEditingPatientId = row.patientId;
|
|
||||||
document.getElementById('cds_editModalTitle').textContent = `Edit Alert — ${row.patientName}`;
|
|
||||||
document.getElementById('cds_severity').value = (row.severity || 'warning').toLowerCase();
|
|
||||||
document.getElementById('cds_summary').value = row.summary || '';
|
|
||||||
document.getElementById('cds_recommendation').value = row.recommendation || '';
|
|
||||||
document.getElementById('cds_actioned').checked = !!row.actioned;
|
|
||||||
cdsEditModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cdsSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const payload = {
|
|
||||||
severity: document.getElementById('cds_severity').value,
|
|
||||||
summary: document.getElementById('cds_summary').value,
|
|
||||||
recommendation: document.getElementById('cds_recommendation').value,
|
|
||||||
actioned: document.getElementById('cds_actioned').checked
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Api.updateCdsAlert(cdsEditingPatientId, cdsEditingId, payload);
|
|
||||||
cdsShowToast('Alert updated.');
|
|
||||||
cdsEditModal.hide();
|
|
||||||
await cdsLoadGrid();
|
|
||||||
if (cdsViewModalInstance) cdsViewModalInstance.hide();
|
|
||||||
} catch (err) {
|
|
||||||
cdsShowToast(err.message, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cdsDelete(row) {
|
|
||||||
if (!confirm(`Delete this alert for ${row.patientName}?`)) return;
|
|
||||||
try {
|
|
||||||
await Api.deleteCdsAlert(row.patientId, row.id);
|
|
||||||
cdsShowToast('Deleted.');
|
|
||||||
await cdsLoadGrid();
|
|
||||||
const container = document.getElementById('cds_body');
|
|
||||||
const stillHasRows = (container._cdsRows || []).some(r => r.patientId === row.patientId);
|
|
||||||
if (stillHasRows) cdsOpenViewModal(row.patientId, container._cdsRows);
|
|
||||||
else if (cdsViewModalInstance) cdsViewModalInstance.hide();
|
|
||||||
} catch (err) {
|
|
||||||
cdsShowToast(err.message, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
if (!sessionStorage.getItem('cip_token')) { window.location.href = 'login.html'; return; }
|
|
||||||
|
|
||||||
renderSidebar('cdsalerts');
|
|
||||||
document.getElementById('clinicianLabel').textContent = `👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
|
|
||||||
document.getElementById('logoutBtn').addEventListener('click', () => { sessionStorage.clear(); window.location.href = 'login.html'; });
|
|
||||||
|
|
||||||
cdsEditModal = new bootstrap.Modal(document.getElementById('cds_editModal'));
|
|
||||||
document.getElementById('cds_form').addEventListener('submit', cdsSubmit);
|
|
||||||
|
|
||||||
document.getElementById('cds_searchInput').addEventListener('input', (e) => {
|
|
||||||
cdsSearchTerm = e.target.value;
|
|
||||||
cdsLoadGrid();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('cds_body').addEventListener('click', (e) => {
|
|
||||||
const viewEl = e.target.closest('[data-view-patient]');
|
|
||||||
if (!viewEl) return;
|
|
||||||
const container = document.getElementById('cds_body');
|
|
||||||
cdsOpenViewModal(viewEl.dataset.viewPatient, container._cdsRows || []);
|
|
||||||
});
|
|
||||||
|
|
||||||
cdsEnsureViewModal().addEventListener('click', (e) => {
|
|
||||||
const editBtn = e.target.closest('[data-edit-row]');
|
|
||||||
const delBtn = e.target.closest('[data-delete-row]');
|
|
||||||
const container = document.getElementById('cds_body');
|
|
||||||
const rows = container._cdsRows || [];
|
|
||||||
if (editBtn) {
|
|
||||||
const row = rows.find(r => String(r.id) === editBtn.dataset.editRow && r.patientId === editBtn.dataset.editPatient);
|
|
||||||
if (row) { cdsViewModalInstance.hide(); cdsOpenEdit(row); }
|
|
||||||
} else if (delBtn) {
|
|
||||||
const row = rows.find(r => String(r.id) === delBtn.dataset.deleteRow && r.patientId === delBtn.dataset.deletePatient);
|
|
||||||
if (row) cdsDelete(row);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cdsLoadGrid();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
802
.Net Capstone Project/dashboard27082026.js
Normal file
802
.Net Capstone Project/dashboard27082026.js
Normal file
@ -0,0 +1,802 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
407
.Net Capstone Project/dashboard28082026 updated.html
Normal file
407
.Net Capstone Project/dashboard28082026 updated.html
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
<!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>
|
||||||
BIN
.Net Capstone Project/files.zip
Normal file
BIN
.Net Capstone Project/files.zip
Normal file
Binary file not shown.
201
.Net Capstone Project/registerv1.html
Normal file
201
.Net Capstone Project/registerv1.html
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<!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>
|
||||||
@ -1,350 +0,0 @@
|
|||||||
// Generic engine for a "browse one resource type across all patients" page
|
|
||||||
// (Conditions, Observations, Allergies, Medications, Encounters). Each page
|
|
||||||
// just defines window.RESOURCE_PAGE_CONFIG and includes this script — no
|
|
||||||
// per-page JS needed. See conditions.html for the config shape.
|
|
||||||
|
|
||||||
let rpPatientsCache = [];
|
|
||||||
let rpSearchTerm = '';
|
|
||||||
let rpAddModal, rpEditingRowId = null, rpEditingPatientId = null;
|
|
||||||
|
|
||||||
function rpEscapeHtml(str) {
|
|
||||||
if (str === null || str === undefined) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function rpFmtDate(value) {
|
|
||||||
if (!value) return '—';
|
|
||||||
const d = new Date(value);
|
|
||||||
if (isNaN(d)) return '—';
|
|
||||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function rpShowToast(message, variant = 'success') {
|
|
||||||
const container = document.getElementById('toastContainer');
|
|
||||||
if (!container) { alert(message); return; }
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = `toast align-items-center text-bg-${variant === 'danger' ? 'danger' : 'success'} border-0 show mb-2`;
|
|
||||||
el.innerHTML = `<div class="d-flex"><div class="toast-body">${rpEscapeHtml(message)}</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" onclick="this.closest('.toast').remove()"></button></div>`;
|
|
||||||
container.appendChild(el);
|
|
||||||
setTimeout(() => el.remove(), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rpFieldInputHtml(field, value) {
|
|
||||||
const id = `rp_field_${field.key}`;
|
|
||||||
const val = value !== undefined && value !== null ? value : (field.default || '');
|
|
||||||
|
|
||||||
if (field.type === 'select') {
|
|
||||||
return `<div class="col-12">
|
|
||||||
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
|
||||||
<select class="form-select" id="${id}">
|
|
||||||
${field.options.map(o => `<option value="${o}" ${o === val ? 'selected' : ''}>${o}</option>`).join('')}
|
|
||||||
</select>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'checkbox') {
|
|
||||||
const checked = value === true || value === 'true';
|
|
||||||
return `<div class="col-12 form-check mt-1">
|
|
||||||
<input type="checkbox" class="form-check-input" id="${id}" ${checked ? 'checked' : ''}>
|
|
||||||
<label class="form-check-label small" for="${id}">${rpEscapeHtml(field.label)}</label>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'textarea') {
|
|
||||||
return `<div class="col-12">
|
|
||||||
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
|
||||||
<textarea ${field.required ? 'required' : ''} class="form-control" id="${id}" rows="2">${rpEscapeHtml(val)}</textarea>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputVal = field.type === 'date' && val ? String(val).slice(0, 10)
|
|
||||||
: field.type === 'datetime-local' && val ? String(val).slice(0, 16)
|
|
||||||
: val;
|
|
||||||
return `<div class="col-12">
|
|
||||||
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
|
||||||
<input type="${field.type}" ${field.required ? 'required' : ''} class="form-control" id="${id}" value="${rpEscapeHtml(inputVal)}">
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rpLoadPatientsForPicker() {
|
|
||||||
try {
|
|
||||||
rpPatientsCache = await Api.getPatients();
|
|
||||||
const select = document.getElementById('rp_patientSelect');
|
|
||||||
if (select) {
|
|
||||||
select.innerHTML = rpPatientsCache.map(p => `<option value="${p.id}">${rpEscapeHtml(p.fullName)} (${rpEscapeHtml(p.medicalRecordNumber)})</option>`).join('');
|
|
||||||
}
|
|
||||||
} catch { /* patient picker just won't populate; add flow will fail loudly instead */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function rpOpenAddModal() {
|
|
||||||
rpEditingRowId = null;
|
|
||||||
rpEditingPatientId = null;
|
|
||||||
const config = window.RESOURCE_PAGE_CONFIG;
|
|
||||||
document.getElementById('rp_modalTitle').textContent = `Add ${config.title.replace(/s$/, '')}`;
|
|
||||||
document.getElementById('rp_patientPickerRow').classList.remove('d-none');
|
|
||||||
document.getElementById('rp_fieldsBody').innerHTML = config.fields.map(f => rpFieldInputHtml(f, null)).join('');
|
|
||||||
rpAddModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function rpOpenEditModal(row) {
|
|
||||||
rpEditingRowId = row.id;
|
|
||||||
rpEditingPatientId = row.patientId;
|
|
||||||
const config = window.RESOURCE_PAGE_CONFIG;
|
|
||||||
document.getElementById('rp_modalTitle').textContent = `Edit ${config.title.replace(/s$/, '')} — ${row.patientName}`;
|
|
||||||
document.getElementById('rp_patientPickerRow').classList.add('d-none');
|
|
||||||
document.getElementById('rp_fieldsBody').innerHTML = config.fields.map(f => rpFieldInputHtml(f, row[f.key])).join('');
|
|
||||||
rpAddModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rpSubmitModal(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const config = window.RESOURCE_PAGE_CONFIG;
|
|
||||||
const payload = {};
|
|
||||||
for (const f of config.fields) {
|
|
||||||
const el = document.getElementById(`rp_field_${f.key}`);
|
|
||||||
let v;
|
|
||||||
if (f.type === 'checkbox') {
|
|
||||||
v = el.checked;
|
|
||||||
} else {
|
|
||||||
v = el.value;
|
|
||||||
if (f.type === 'number') v = v === '' ? null : parseFloat(v);
|
|
||||||
v = v === '' ? null : v;
|
|
||||||
}
|
|
||||||
payload[f.key] = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (rpEditingRowId) {
|
|
||||||
await config.update(rpEditingPatientId, rpEditingRowId, payload);
|
|
||||||
rpShowToast(`${config.title} updated.`);
|
|
||||||
} else {
|
|
||||||
const patientId = document.getElementById('rp_patientSelect').value;
|
|
||||||
if (!patientId) { rpShowToast('Select a patient first.', 'danger'); return; }
|
|
||||||
await config.add(patientId, payload);
|
|
||||||
rpShowToast(`${config.title} added.`);
|
|
||||||
}
|
|
||||||
rpAddModal.hide();
|
|
||||||
await rpLoadGrid();
|
|
||||||
} catch (err) {
|
|
||||||
rpShowToast(err.message, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rpDeleteRow(row) {
|
|
||||||
const config = window.RESOURCE_PAGE_CONFIG;
|
|
||||||
if (!confirm(`Delete this ${config.title.replace(/s$/, '').toLowerCase()} for ${row.patientName}?`)) return;
|
|
||||||
try {
|
|
||||||
await config.remove(row.patientId, row.id);
|
|
||||||
rpShowToast('Deleted.');
|
|
||||||
await rpLoadGrid();
|
|
||||||
// The row's Delete button lives inside the popup modal — refresh (or
|
|
||||||
// close, if that was their last record) so it doesn't show stale data.
|
|
||||||
const container = document.getElementById('rp_gridBody');
|
|
||||||
const stillHasRows = (container._rpRows || []).some(r => r.patientId === row.patientId);
|
|
||||||
if (stillHasRows) rpOpenViewModal(row.patientId);
|
|
||||||
else if (rpViewModalInstance) rpViewModalInstance.hide();
|
|
||||||
} catch (err) {
|
|
||||||
rpShowToast(err.message, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bootstrap "bg-*" classes cycled per tile so patients are visually easy to
|
|
||||||
// tell apart at a glance — not tied to any data meaning, just variety.
|
|
||||||
const RP_TILE_COLORS = ['primary', 'success', 'info', 'warning', 'danger', 'secondary'];
|
|
||||||
|
|
||||||
// The pages ship a <table><thead>...<tbody id="rp_gridBody"> wrapper (from
|
|
||||||
// before this was a tile grid). Swap that whole table out for a plain grid
|
|
||||||
// div, once, so none of the 7 HTML pages that use this engine need editing.
|
|
||||||
function rpEnsureTileContainer() {
|
|
||||||
const existing = document.getElementById('rp_gridBody');
|
|
||||||
if (existing.tagName.toLowerCase() === 'div') return existing;
|
|
||||||
const table = existing.closest('table');
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.id = 'rp_gridBody';
|
|
||||||
div.className = 'rp-tile-grid';
|
|
||||||
table.replaceWith(div);
|
|
||||||
return div;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rpViewModalInstance = null;
|
|
||||||
|
|
||||||
// The pages don't ship a "view details" modal in their HTML — build it once
|
|
||||||
// and reuse it, same approach as rpEnsureTileContainer above. Scrollable so
|
|
||||||
// patients with a lot of records don't blow out the page.
|
|
||||||
function rpEnsureViewModal() {
|
|
||||||
let el = document.getElementById('rp_viewModal');
|
|
||||||
if (el) return el;
|
|
||||||
el = document.createElement('div');
|
|
||||||
el.className = 'modal fade';
|
|
||||||
el.id = 'rp_viewModal';
|
|
||||||
el.tabIndex = -1;
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="rp_viewModalTitle"></h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body p-0" id="rp_viewModalBody"></div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
document.body.appendChild(el);
|
|
||||||
rpViewModalInstance = new bootstrap.Modal(el);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rpOpenViewModal(patientId) {
|
|
||||||
const config = window.RESOURCE_PAGE_CONFIG;
|
|
||||||
const container = document.getElementById('rp_gridBody');
|
|
||||||
const rows = (container._rpRows || []).filter(r => r.patientId === patientId);
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
rpEnsureViewModal();
|
|
||||||
document.getElementById('rp_viewModalTitle').textContent = rows[0].patientName;
|
|
||||||
|
|
||||||
const detailColumns = config.columns.filter(c => c.key !== 'patientName');
|
|
||||||
const detailRows = rows.map(row => {
|
|
||||||
const cells = detailColumns.map(c => {
|
|
||||||
if (c.render) return `<td>${c.render(row[c.key], row)}</td>`;
|
|
||||||
let v = row[c.key];
|
|
||||||
v = c.type === 'date' ? rpFmtDate(v) : rpEscapeHtml(v ?? '—');
|
|
||||||
return `<td>${v}</td>`;
|
|
||||||
}).join('');
|
|
||||||
const actions = config.readOnly ? '' : `
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary me-1" data-edit-row="${row.id}" data-edit-patient="${row.patientId}">Edit</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" data-delete-row="${row.id}" data-delete-patient="${row.patientId}">Delete</button>
|
|
||||||
</td>`;
|
|
||||||
return `<tr data-row-id="${row.id}" data-row-patient="${row.patientId}">${cells}${actions}</tr>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
document.getElementById('rp_viewModalBody').innerHTML = `
|
|
||||||
<div style="max-height:60vh;overflow-y:auto">
|
|
||||||
<table class="table table-sm resource-grid-table align-middle mb-0">
|
|
||||||
<thead><tr>${detailColumns.map(c => `<th>${rpEscapeHtml(c.label)}</th>`).join('')}${config.readOnly ? '' : '<th></th>'}</tr></thead>
|
|
||||||
<tbody>${detailRows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
rpViewModalInstance.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rpLoadGrid() {
|
|
||||||
const config = window.RESOURCE_PAGE_CONFIG;
|
|
||||||
const container = rpEnsureTileContainer();
|
|
||||||
const empty = document.getElementById('rp_gridEmpty');
|
|
||||||
const countLabel = document.getElementById('rp_countLabel');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rows = await config.listAll(rpSearchTerm.trim());
|
|
||||||
const patientCount = new Set(rows.map(r => r.patientId)).size;
|
|
||||||
if (countLabel) countLabel.textContent = `${rows.length} record${rows.length === 1 ? '' : 's'} · ${patientCount} patient${patientCount === 1 ? '' : 's'}`;
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
container.innerHTML = '';
|
|
||||||
empty.classList.remove('d-none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
empty.classList.add('d-none');
|
|
||||||
|
|
||||||
const groups = new Map();
|
|
||||||
rows.forEach(r => {
|
|
||||||
if (!groups.has(r.patientId)) groups.set(r.patientId, { patientId: r.patientId, patientName: r.patientName, items: [] });
|
|
||||||
groups.get(r.patientId).items.push(r);
|
|
||||||
});
|
|
||||||
|
|
||||||
container.innerHTML = [...groups.values()].map((g, i) => {
|
|
||||||
const color = RP_TILE_COLORS[i % RP_TILE_COLORS.length];
|
|
||||||
const initials = g.patientName.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase();
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="card shadow-sm border-0" style="cursor:pointer" data-view-patient="${g.patientId}">
|
|
||||||
<div class="card-body text-center py-3">
|
|
||||||
<div class="rounded-circle bg-${color} text-white d-inline-flex align-items-center justify-content-center mb-2" style="width:44px;height:44px;font-size:14px" aria-hidden="true">${rpEscapeHtml(initials)}</div>
|
|
||||||
<div class="fw-semibold small">${rpEscapeHtml(g.patientName)}</div>
|
|
||||||
<span class="badge bg-${color}-subtle text-${color}-emphasis rounded-pill mt-1">${g.items.length} record${g.items.length === 1 ? '' : 's'}</span>
|
|
||||||
<div class="mt-2">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-${color}" data-view-patient="${g.patientId}">View</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Stash rows for the View/Edit/Delete handlers (avoids a second fetch per click).
|
|
||||||
container._rpRows = rows;
|
|
||||||
|
|
||||||
// Lets a page compute something from the freshly-loaded rows (e.g. an
|
|
||||||
// "X% actioned" stat) without the generic engine needing to know about
|
|
||||||
// page-specific fields like "Actioned".
|
|
||||||
if (typeof config.afterLoad === 'function') config.afterLoad(rows);
|
|
||||||
} catch (err) {
|
|
||||||
rpShowToast(err.message, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rpInit() {
|
|
||||||
const config = window.RESOURCE_PAGE_CONFIG;
|
|
||||||
if (!config) return;
|
|
||||||
|
|
||||||
if (!sessionStorage.getItem('cip_token')) {
|
|
||||||
window.location.href = 'login.html';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSidebar(config.key);
|
|
||||||
|
|
||||||
document.getElementById('rp_pageIcon').textContent = config.icon;
|
|
||||||
document.getElementById('rp_pageTitle').textContent = config.title;
|
|
||||||
document.title = `${config.title} — Clinical Insight Pro`;
|
|
||||||
document.getElementById('clinicianLabel').textContent = `👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
|
|
||||||
|
|
||||||
document.getElementById('logoutBtn').addEventListener('click', () => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
window.location.href = 'login.html';
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config.readOnly) {
|
|
||||||
document.getElementById('rp_addBtn').classList.add('d-none');
|
|
||||||
}
|
|
||||||
|
|
||||||
// "View" opens a scrollable popup with that patient's records, instead of
|
|
||||||
// expanding inline (which used to push the rest of the grid down). Works
|
|
||||||
// on every page, including read-only ones (Risk Assessment, Care Plans).
|
|
||||||
rpEnsureTileContainer().addEventListener('click', (e) => {
|
|
||||||
const viewEl = e.target.closest('[data-view-patient]');
|
|
||||||
if (!viewEl) return;
|
|
||||||
rpOpenViewModal(viewEl.dataset.viewPatient);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!config.readOnly) {
|
|
||||||
rpAddModal = new bootstrap.Modal(document.getElementById('rp_modal'));
|
|
||||||
document.getElementById('rp_addBtn').addEventListener('click', rpOpenAddModal);
|
|
||||||
document.getElementById('rp_form').addEventListener('submit', rpSubmitModal);
|
|
||||||
rpLoadPatientsForPicker();
|
|
||||||
|
|
||||||
rpEnsureViewModal().addEventListener('click', (e) => {
|
|
||||||
const editBtn = e.target.closest('[data-edit-row]');
|
|
||||||
const delBtn = e.target.closest('[data-delete-row]');
|
|
||||||
const tbody = document.getElementById('rp_gridBody');
|
|
||||||
if (editBtn) {
|
|
||||||
const row = (tbody._rpRows || []).find(r => String(r.id) === editBtn.dataset.editRow && r.patientId === editBtn.dataset.editPatient);
|
|
||||||
if (row) { rpViewModalInstance.hide(); rpOpenEditModal(row); }
|
|
||||||
} else if (delBtn) {
|
|
||||||
const row = (tbody._rpRows || []).find(r => String(r.id) === delBtn.dataset.deleteRow && r.patientId === delBtn.dataset.deletePatient);
|
|
||||||
if (row) rpDeleteRow(row);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('rp_searchInput').addEventListener('input', (e) => {
|
|
||||||
rpSearchTerm = e.target.value;
|
|
||||||
rpLoadGrid();
|
|
||||||
});
|
|
||||||
|
|
||||||
rpLoadGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', rpInit);
|
|
||||||
1247
.Net Capstone Project/resource-pageorganization.js
Normal file
1247
.Net Capstone Project/resource-pageorganization.js
Normal file
File diff suppressed because it is too large
Load Diff
1258
.Net Capstone Project/resource-pageupdatedvv.js
Normal file
1258
.Net Capstone Project/resource-pageupdatedvv.js
Normal file
File diff suppressed because it is too large
Load Diff
300
.Net Capstone Project/sidebarvv.js
Normal file
300
.Net Capstone Project/sidebarvv.js
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
// 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();
|
||||||
|
}
|
||||||
328
.Net Capstone Project/styles.css
Normal file
328
.Net Capstone Project/styles.css
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
:root {
|
||||||
|
--ci-primary: #0f6e6e;
|
||||||
|
--ci-primary-dark: #0a4f4f;
|
||||||
|
--ci-accent: #2e86ab;
|
||||||
|
--ci-bg: #f4f7f8;
|
||||||
|
--ci-critical: #c0392b;
|
||||||
|
--ci-warning: #d68910;
|
||||||
|
--ci-info: #2e86ab;
|
||||||
|
--ci-success: #1e8449;
|
||||||
|
--ci-text: #1f2d2d;
|
||||||
|
--ci-muted: #5f7373;
|
||||||
|
--ci-border: #dfe7e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--ci-bg);
|
||||||
|
color: var(--ci-text);
|
||||||
|
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-ci {
|
||||||
|
background: linear-gradient(135deg, var(--ci-primary-dark), var(--ci-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-ci .navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-ci .navbar-text,
|
||||||
|
.navbar-ci .nav-link {
|
||||||
|
color: #eaf6f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel {
|
||||||
|
background: linear-gradient(135deg, var(--ci-primary-dark) 0%, var(--ci-primary) 55%, var(--ci-accent) 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 3rem 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel h1 {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-ci {
|
||||||
|
border: 1px solid var(--ci-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 10px rgba(15, 110, 110, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-ci .card-header {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid var(--ci-border);
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 14px 14px 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ci-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 0.6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-badge {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-badge.High { background-color: #fdecea; color: var(--ci-critical); border: 1px solid #f5c6c0; }
|
||||||
|
.risk-badge.Normal { background-color: #eafaf1; color: var(--ci-success); border: 1px solid #bfe8cf; }
|
||||||
|
|
||||||
|
.alert-card {
|
||||||
|
border-left: 5px solid var(--ci-info);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card.critical { border-left-color: var(--ci-critical); }
|
||||||
|
.alert-card.warning { border-left-color: var(--ci-warning); }
|
||||||
|
.alert-card.info { border-left-color: var(--ci-info); }
|
||||||
|
|
||||||
|
.severity-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-tag.critical { background-color: #fdecea; color: var(--ci-critical); }
|
||||||
|
.severity-tag.warning { background-color: #fdf3e3; color: var(--ci-warning); }
|
||||||
|
.severity-tag.info { background-color: #e8f2fb; color: var(--ci-info); }
|
||||||
|
|
||||||
|
.chart-list-item {
|
||||||
|
border-bottom: 1px dashed var(--ci-border);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.chart-list-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.patient-tile {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.patient-tile:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(15, 110, 110, 0.15);
|
||||||
|
}
|
||||||
|
.patient-tile.active {
|
||||||
|
border-color: var(--ci-primary);
|
||||||
|
box-shadow: 0 0 0 2px var(--ci-primary) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-gauge {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted-ci { color: var(--ci-muted); }
|
||||||
|
|
||||||
|
.btn-ci-primary {
|
||||||
|
background-color: var(--ci-primary);
|
||||||
|
border-color: var(--ci-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-ci-primary:hover {
|
||||||
|
background-color: var(--ci-primary-dark);
|
||||||
|
border-color: var(--ci-primary-dark);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-fhir {
|
||||||
|
background-color: #e8f2fb;
|
||||||
|
color: var(--ci-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-ci {
|
||||||
|
color: var(--ci-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ci-muted);
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 2px solid var(--ci-border);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255,255,255,0.7);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Dashboard stat cards ---- */
|
||||||
|
.stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.1rem 1.2rem;
|
||||||
|
color: #fff;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
box-shadow: 0 3px 10px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 10px 22px rgba(0,0,0,0.16);
|
||||||
|
}
|
||||||
|
.stat-card:active { transform: translateY(-1px); }
|
||||||
|
|
||||||
|
.stat-card .stat-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.stat-card .stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.stat-card .stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.stat-card .stat-arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.9rem;
|
||||||
|
bottom: 0.8rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.stat-patients { background: linear-gradient(135deg, #0f6e6e, #2e86ab); }
|
||||||
|
.stat-card.stat-conditions { background: linear-gradient(135deg, #b9770e, #d68910); }
|
||||||
|
.stat-card.stat-medications{ background: linear-gradient(135deg, #6c3483, #9b59b6); }
|
||||||
|
.stat-card.stat-allergies { background: linear-gradient(135deg, #922b21, #c0392b); }
|
||||||
|
.stat-card.stat-observations{ background: linear-gradient(135deg, #148f77, #1abc9c); }
|
||||||
|
.stat-card.stat-encounters { background: linear-gradient(135deg, #1a5276, #2e86ab); }
|
||||||
|
.stat-card.stat-careplans { background: linear-gradient(135deg, #196f3d, #27ae60); }
|
||||||
|
.stat-card.stat-alerts { background: linear-gradient(135deg, #922b21, #e74c3c); }
|
||||||
|
|
||||||
|
/* ---- Stats scope label (shown when cards are scoped to a search/patient) ---- */
|
||||||
|
.stats-scope-label {
|
||||||
|
background: #eaf6f6;
|
||||||
|
border: 1px solid #cfe9e8;
|
||||||
|
color: var(--ci-primary-dark);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.stats-scope-label a {
|
||||||
|
color: var(--ci-primary-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scrollable patient list ---- */
|
||||||
|
.patient-list-scroll {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scrollable chart resource lists (Conditions, Allergies, Medications,
|
||||||
|
Observations, Encounters panels on the patient chart) — each box scrolls
|
||||||
|
independently once it has more than ~3 items, instead of the card
|
||||||
|
growing to fit everything. !important guards against any other rule
|
||||||
|
(e.g. a card-ci default) silently overriding the height/overflow. ---- */
|
||||||
|
.chart-list-scroll {
|
||||||
|
max-height: 190px !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
padding-right: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.chart-list-scroll::-webkit-scrollbar,
|
||||||
|
.patient-list-scroll::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
}
|
||||||
|
.chart-list-scroll::-webkit-scrollbar-track,
|
||||||
|
.patient-list-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.chart-list-scroll::-webkit-scrollbar-thumb,
|
||||||
|
.patient-list-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--ci-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.chart-list-scroll::-webkit-scrollbar-thumb:hover,
|
||||||
|
.patient-list-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--ci-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Resource browser (all-patients table view) ---- */
|
||||||
|
.resource-browser-table th {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: var(--ci-muted);
|
||||||
|
border-bottom: 2px solid var(--ci-border);
|
||||||
|
}
|
||||||
|
.resource-browser-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.resource-browser-table tr:hover td {
|
||||||
|
background-color: #f4faf9;
|
||||||
|
}
|
||||||
|
.patient-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
background: #eaf6f6;
|
||||||
|
color: var(--ci-primary-dark);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.patient-chip:hover { background: #d7ecec; }
|
||||||
Loading…
Reference in New Issue
Block a user