Compare commits
44 Commits
priyanka_d
...
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 |
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.
BIN
.Net Capstone Project/Capstone26082026.zip
Normal file
BIN
.Net Capstone Project/Capstone26082026.zip
Normal file
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.
BIN
.Net Capstone Project/ClinicalInsightsPro.zip
Normal file
BIN
.Net Capstone Project/ClinicalInsightsPro.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/ClinicalInsightsPro_Complete.zip
Normal file
BIN
.Net Capstone Project/ClinicalInsightsPro_Complete.zip
Normal file
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.
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');
|
||||
}
|
||||
}
|
||||
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.
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
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.
BIN
.Net Capstone Project/Updated files.zip
Normal file
BIN
.Net Capstone Project/Updated files.zip
Normal file
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.
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>
|
||||
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