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.
Binary file not shown.
832
.Net Capstone Project/Dashboard27.js
Normal file
832
.Net Capstone Project/Dashboard27.js
Normal file
@ -0,0 +1,832 @@
|
||||
let currentPatientId = null;
|
||||
let patientsCache = [];
|
||||
let patientSearchTerm = '';
|
||||
|
||||
function showSpinner(show) {
|
||||
document.getElementById('spinnerOverlay').style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function showToast(message, variant = 'success') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast align-items-center text-bg-${variant} border-0 show mb-2`;
|
||||
el.innerHTML = `<div class="d-flex">
|
||||
<div class="toast-body">${message}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" onclick="this.closest('.toast').remove()"></button>
|
||||
</div>`;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => el.remove(), 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str ?? '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(dateStr) {
|
||||
if (!dateStr) return '—';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------- Dynamic resource forms ----------
|
||||
// Field config per FHIR resource type. Drives the generic #addResourceModal
|
||||
// so patients, conditions, observations, allergies, medications and
|
||||
// encounters can all be added from the UI and hit the CRUD API — nothing
|
||||
// here is limited to the three seeded demo patients.
|
||||
const RESOURCE_FORMS = {
|
||||
condition: {
|
||||
title: 'Add Condition',
|
||||
fields: [
|
||||
{ key: 'code', label: 'Code (ICD-10, e.g. I10)', type: 'text', required: true },
|
||||
{ key: 'display', label: 'Display name', type: 'text', required: true },
|
||||
{ key: 'clinicalStatus', label: 'Clinical status', type: 'select', options: ['active', 'inactive', 'resolved', 'remission'], default: 'active' },
|
||||
{ key: 'onsetDate', label: 'Onset date', type: 'date', required: true }
|
||||
],
|
||||
add: (patientId, payload) => Api.addCondition(patientId, payload)
|
||||
},
|
||||
observation: {
|
||||
title: 'Add Observation',
|
||||
fields: [
|
||||
{ key: 'code', label: 'Code (LOINC, e.g. 8480-6)', type: 'text', required: true },
|
||||
{ key: 'display', label: 'Display name', type: 'text', required: true },
|
||||
{ key: 'value', label: 'Value', type: 'number', required: true },
|
||||
{ key: 'unit', label: 'Unit', type: 'text', required: true },
|
||||
{ key: 'effectiveDate', label: 'Effective date/time', type: 'datetime-local', required: true }
|
||||
],
|
||||
add: (patientId, payload) => Api.addObservation(patientId, payload)
|
||||
},
|
||||
allergy: {
|
||||
title: 'Add Allergy / Intolerance',
|
||||
fields: [
|
||||
{ key: 'substance', label: 'Substance', type: 'text', required: true },
|
||||
{ key: 'reaction', label: 'Reaction', type: 'text' },
|
||||
{ key: 'criticality', label: 'Criticality', type: 'select', options: ['low', 'high', 'unable-to-assess'], default: 'low' }
|
||||
],
|
||||
add: (patientId, payload) => Api.addAllergy(patientId, payload)
|
||||
},
|
||||
medication: {
|
||||
title: 'Add Medication',
|
||||
fields: [
|
||||
{ key: 'medicationName', label: 'Medication name', type: 'text', required: true },
|
||||
{ key: 'dosage', label: 'Dosage', type: 'text' },
|
||||
{ key: 'status', label: 'Status', type: 'select', options: ['active', 'completed', 'cancelled', 'on-hold', 'stopped'], default: 'active' },
|
||||
{ key: 'authoredOn', label: 'Authored on', type: 'date' }
|
||||
],
|
||||
add: (patientId, payload) => Api.addMedication(patientId, payload)
|
||||
},
|
||||
encounter: {
|
||||
title: 'Add Encounter',
|
||||
fields: [
|
||||
{ key: 'encounterType', label: 'Encounter type', type: 'text', required: true, placeholder: 'e.g. Outpatient' },
|
||||
{ key: 'reason', label: 'Reason', type: 'text' },
|
||||
{ key: 'encounterDate', label: 'Date/time', type: 'datetime-local', required: true },
|
||||
{ key: 'status', label: 'Status', type: 'select', options: ['planned', 'in-progress', 'finished', 'cancelled'], default: 'in-progress' }
|
||||
],
|
||||
add: (patientId, payload) => Api.addEncounter(patientId, payload)
|
||||
}
|
||||
};
|
||||
|
||||
let activeResourceType = null;
|
||||
let newPatientModal, addResourceModal;
|
||||
|
||||
function openResourceModal(type) {
|
||||
if (!currentPatientId) {
|
||||
showToast('Select a patient first.', 'danger');
|
||||
return;
|
||||
}
|
||||
activeResourceType = type;
|
||||
const config = RESOURCE_FORMS[type];
|
||||
document.getElementById('addResourceModalTitle').textContent = config.title;
|
||||
|
||||
const body = document.getElementById('addResourceModalBody');
|
||||
body.innerHTML = config.fields.map(f => {
|
||||
const id = `res_${f.key}`;
|
||||
if (f.type === 'select') {
|
||||
return `<div class="col-12">
|
||||
<label class="form-label small">${escapeHtml(f.label)}</label>
|
||||
<select class="form-select" id="${id}">
|
||||
${f.options.map(o => `<option value="${o}" ${o === f.default ? 'selected' : ''}>${o}</option>`).join('')}
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="col-12">
|
||||
<label class="form-label small">${escapeHtml(f.label)}</label>
|
||||
<input type="${f.type}" ${f.required ? 'required' : ''} class="form-control" id="${id}" placeholder="${escapeHtml(f.placeholder || '')}">
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
addResourceModal.show();
|
||||
}
|
||||
|
||||
// ---------- Dashboard stats + resource browser ----------
|
||||
// Aggregates every patient's chart + care plans into flat arrays (each item
|
||||
// tagged with its owning patient) so the top stat cards can show real
|
||||
// counts, and clicking one can browse that resource across ALL patients —
|
||||
// not just the one currently selected.
|
||||
let resourceCache = { conditions: [], observations: [], allergies: [], medications: [], encounters: [], careplans: [] };
|
||||
let resourceBrowserModal;
|
||||
|
||||
async function loadDashboardStats() {
|
||||
resourceCache = { conditions: [], observations: [], allergies: [], medications: [], encounters: [], careplans: [] };
|
||||
|
||||
try {
|
||||
const [chartResults, carePlanResults] = await Promise.all([
|
||||
Promise.all(patientsCache.map(p => Api.getChart(p.id).catch(() => null))),
|
||||
Promise.all(patientsCache.map(p => Api.getCarePlans(p.id).catch(() => [])))
|
||||
]);
|
||||
|
||||
patientsCache.forEach((p, idx) => {
|
||||
const chart = chartResults[idx];
|
||||
if (chart) {
|
||||
chart.conditions.forEach(c => resourceCache.conditions.push({ ...c, patientId: p.id, patientName: p.fullName }));
|
||||
chart.observations.forEach(o => resourceCache.observations.push({ ...o, patientId: p.id, patientName: p.fullName }));
|
||||
chart.allergies.forEach(a => resourceCache.allergies.push({ ...a, patientId: p.id, patientName: p.fullName }));
|
||||
chart.medications.forEach(m => resourceCache.medications.push({ ...m, patientId: p.id, patientName: p.fullName }));
|
||||
chart.encounters.forEach(e => resourceCache.encounters.push({ ...e, patientId: p.id, patientName: p.fullName }));
|
||||
}
|
||||
(carePlanResults[idx] || []).forEach(cp => resourceCache.careplans.push({ ...cp, patientId: p.id, patientName: p.fullName }));
|
||||
});
|
||||
} catch {
|
||||
// Stats are a nice-to-have on top of the core dashboard — don't block
|
||||
// the rest of the page if aggregation fails for some reason.
|
||||
}
|
||||
|
||||
updateStatCardsForContext();
|
||||
}
|
||||
|
||||
// Returns the patients matching the current search box text (or everyone,
|
||||
// if the box is empty). Shared by the sidebar list and the stat cards so
|
||||
// they always agree on "what's currently in view".
|
||||
function getFilteredPatients() {
|
||||
const term = patientSearchTerm.trim().toLowerCase();
|
||||
if (!term) return patientsCache;
|
||||
|
||||
return patientsCache.filter(p => {
|
||||
const fullName = (p.fullName || '').toLowerCase();
|
||||
const nameParts = fullName.split(' ').filter(Boolean);
|
||||
|
||||
// Name matches if the search term is the start of the full name, OR
|
||||
// the start of any individual name part (first name, last name, etc.)
|
||||
// — so typing "N" matches "Nisha Meshram" and "Neha Patil", but not
|
||||
// "Anjali Patil" (the 'n' isn't at the start of either name part).
|
||||
const nameMatch = fullName.startsWith(term) || nameParts.some(part => part.startsWith(term));
|
||||
|
||||
// Strip generic label prefixes ("pt-", "MRN-") before substring
|
||||
// matching — otherwise a search term can accidentally match the label
|
||||
// text itself. E.g. "MRN-100235" lowercased contains "mrn", so
|
||||
// searching "n" would match every patient's MRN regardless of their
|
||||
// actual number, even ones with nothing else in common with "n".
|
||||
const idValue = (p.id || '').toLowerCase().replace(/^pt-/, '');
|
||||
const mrnValue = (p.medicalRecordNumber || '').toLowerCase().replace(/^mrn-/, '');
|
||||
const idMatch = idValue.includes(term);
|
||||
const mrnMatch = mrnValue.includes(term);
|
||||
|
||||
return nameMatch || idMatch || mrnMatch;
|
||||
});
|
||||
}
|
||||
|
||||
// Drives the top stat cards. Three states:
|
||||
// 1. A patient is selected (currentPatientId set) -> counts for just them.
|
||||
// 2. Nothing selected but the search box has text -> counts for the
|
||||
// patients currently matching the search.
|
||||
// 3. Neither -> counts across everyone.
|
||||
function updateStatCardsForContext() {
|
||||
const setCount = (id, value) => { const el = document.getElementById(id); if (el) el.textContent = value; };
|
||||
const scopeLabel = document.getElementById('statsScopeLabel');
|
||||
|
||||
let activePatients;
|
||||
let scopeText = null;
|
||||
|
||||
if (currentPatientId) {
|
||||
const selected = patientsCache.find(p => p.id === currentPatientId);
|
||||
activePatients = selected ? [selected] : [];
|
||||
if (selected) scopeText = `Showing data for <strong>${escapeHtml(selected.fullName)}</strong>`;
|
||||
} else {
|
||||
activePatients = getFilteredPatients();
|
||||
if (patientSearchTerm.trim()) {
|
||||
scopeText = `Showing ${activePatients.length} of ${patientsCache.length} patients matching "${escapeHtml(patientSearchTerm.trim())}"`;
|
||||
}
|
||||
}
|
||||
|
||||
const activeIds = getActivePatientIds();
|
||||
|
||||
setCount('stat_patients', activePatients.length);
|
||||
setCount('stat_conditions', resourceCache.conditions.filter(c => activeIds.has(c.patientId)).length);
|
||||
setCount('stat_medications', resourceCache.medications.filter(m => activeIds.has(m.patientId)).length);
|
||||
setCount('stat_allergies', resourceCache.allergies.filter(a => activeIds.has(a.patientId)).length);
|
||||
setCount('stat_observations', resourceCache.observations.filter(o => activeIds.has(o.patientId)).length);
|
||||
setCount('stat_encounters', resourceCache.encounters.filter(e => activeIds.has(e.patientId)).length);
|
||||
setCount('stat_careplans', resourceCache.careplans.filter(cp => activeIds.has(cp.patientId)).length);
|
||||
|
||||
if (scopeLabel) {
|
||||
if (scopeText) {
|
||||
scopeLabel.innerHTML = `${scopeText} · <a href="#" id="clearStatsScopeLink">Show all patients</a>`;
|
||||
scopeLabel.classList.remove('d-none');
|
||||
} else {
|
||||
scopeLabel.innerHTML = '';
|
||||
scopeLabel.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function patientChipHtml(patientId, name) {
|
||||
return `<span class="patient-chip" data-jump-patient="${patientId}">👤 ${escapeHtml(name)}</span>`;
|
||||
}
|
||||
|
||||
// The single source of truth for "what patient(s) is the page currently
|
||||
// scoped to" — a selected patient takes priority, otherwise it's whoever
|
||||
// matches the search box (or everyone, if the box is empty). Used by both
|
||||
// the stat cards and the "browse all" modal so they always agree.
|
||||
function getActivePatientIds() {
|
||||
if (currentPatientId) return new Set([currentPatientId]);
|
||||
return new Set(getFilteredPatients().map(p => p.id));
|
||||
}
|
||||
|
||||
const RESOURCE_BROWSER_CONFIG = {
|
||||
patients: {
|
||||
title: 'Patients',
|
||||
columns: ['Name', 'Age', 'Gender', 'MRN'],
|
||||
rows: (activeIds) => patientsCache.filter(p => activeIds.has(p.id)).map(p => [
|
||||
patientChipHtml(p.id, p.fullName), p.age, escapeHtml(p.gender), escapeHtml(p.medicalRecordNumber)
|
||||
])
|
||||
},
|
||||
conditions: {
|
||||
title: 'Conditions',
|
||||
columns: ['Patient', 'Display', 'Code', 'Status', 'Onset'],
|
||||
rows: (activeIds) => resourceCache.conditions.filter(c => activeIds.has(c.patientId)).map(c => [
|
||||
patientChipHtml(c.patientId, c.patientName), escapeHtml(c.display), escapeHtml(c.code),
|
||||
escapeHtml(c.clinicalStatus), fmtDate(c.onsetDate)
|
||||
])
|
||||
},
|
||||
medications: {
|
||||
title: 'Medications',
|
||||
columns: ['Patient', 'Medication', 'Dosage', 'Status', 'Authored'],
|
||||
rows: (activeIds) => resourceCache.medications.filter(m => activeIds.has(m.patientId)).map(m => [
|
||||
patientChipHtml(m.patientId, m.patientName), escapeHtml(m.medicationName), escapeHtml(m.dosage),
|
||||
escapeHtml(m.status), fmtDate(m.authoredOn)
|
||||
])
|
||||
},
|
||||
allergies: {
|
||||
title: 'Allergies',
|
||||
columns: ['Patient', 'Substance', 'Reaction', 'Criticality'],
|
||||
rows: (activeIds) => resourceCache.allergies.filter(a => activeIds.has(a.patientId)).map(a => [
|
||||
patientChipHtml(a.patientId, a.patientName), escapeHtml(a.substance), escapeHtml(a.reaction), escapeHtml(a.criticality)
|
||||
])
|
||||
},
|
||||
observations: {
|
||||
title: 'Observations',
|
||||
columns: ['Patient', 'Display', 'Value', 'Date'],
|
||||
rows: (activeIds) => resourceCache.observations.filter(o => activeIds.has(o.patientId)).map(o => [
|
||||
patientChipHtml(o.patientId, o.patientName), escapeHtml(o.display), `${o.value} ${escapeHtml(o.unit)}`, fmtDate(o.effectiveDate)
|
||||
])
|
||||
},
|
||||
encounters: {
|
||||
title: 'Encounters',
|
||||
columns: ['Patient', 'Type', 'Reason', 'Date', 'Status'],
|
||||
rows: (activeIds) => resourceCache.encounters.filter(e => activeIds.has(e.patientId)).map(e => [
|
||||
patientChipHtml(e.patientId, e.patientName), escapeHtml(e.encounterType), escapeHtml(e.reason),
|
||||
fmtDate(e.encounterDate), escapeHtml(e.status)
|
||||
])
|
||||
},
|
||||
careplans: {
|
||||
title: 'Care Plans',
|
||||
columns: ['Patient', 'Title', 'Triggered By', 'Status', 'Created'],
|
||||
rows: (activeIds) => resourceCache.careplans.filter(cp => activeIds.has(cp.patientId)).map(cp => [
|
||||
patientChipHtml(cp.patientId, cp.patientName), escapeHtml(cp.title), escapeHtml(cp.triggeredByRule),
|
||||
escapeHtml(cp.status), fmtDate(cp.createdDate)
|
||||
])
|
||||
}
|
||||
};
|
||||
|
||||
function openResourceBrowser(resourceType) {
|
||||
const config = RESOURCE_BROWSER_CONFIG[resourceType];
|
||||
if (!config) return;
|
||||
|
||||
const activeIds = getActivePatientIds();
|
||||
const scopeSuffix = currentPatientId
|
||||
? ` — ${patientsCache.find(p => p.id === currentPatientId)?.fullName || 'selected patient'}`
|
||||
: (patientSearchTerm.trim() ? ` — matching "${patientSearchTerm.trim()}"` : '');
|
||||
|
||||
document.getElementById('resourceBrowserTitle').textContent = config.title + scopeSuffix;
|
||||
document.getElementById('resourceBrowserHead').innerHTML = config.columns.map(c => `<th>${c}</th>`).join('');
|
||||
|
||||
const rows = config.rows(activeIds);
|
||||
const body = document.getElementById('resourceBrowserBody');
|
||||
const emptyState = document.getElementById('resourceBrowserEmpty');
|
||||
|
||||
if (rows.length === 0) {
|
||||
body.innerHTML = '';
|
||||
emptyState.textContent = 'No data found.';
|
||||
emptyState.classList.remove('d-none');
|
||||
} else {
|
||||
emptyState.classList.add('d-none');
|
||||
body.innerHTML = rows.map(cells => `<tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr>`).join('');
|
||||
}
|
||||
|
||||
resourceBrowserModal.show();
|
||||
}
|
||||
|
||||
async function submitResourceForm(e) {
|
||||
e.preventDefault();
|
||||
const config = RESOURCE_FORMS[activeResourceType];
|
||||
if (!config) return;
|
||||
|
||||
const payload = {};
|
||||
for (const f of config.fields) {
|
||||
const el = document.getElementById(`res_${f.key}`);
|
||||
let val = el.value;
|
||||
if (f.type === 'number') val = val === '' ? null : parseFloat(val);
|
||||
payload[f.key] = val === '' ? null : val;
|
||||
}
|
||||
|
||||
try {
|
||||
showSpinner(true);
|
||||
await config.add(currentPatientId, payload);
|
||||
addResourceModal.hide();
|
||||
showToast(`${config.title.replace('Add ', '')} added.`);
|
||||
await loadChart(currentPatientId);
|
||||
loadDashboardStats();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
} finally {
|
||||
showSpinner(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteResource(type, id) {
|
||||
if (!currentPatientId || !confirm('Delete this item?')) return;
|
||||
const deleteFns = {
|
||||
condition: Api.deleteCondition,
|
||||
observation: Api.deleteObservation,
|
||||
allergy: Api.deleteAllergy,
|
||||
medication: Api.deleteMedication,
|
||||
encounter: Api.deleteEncounter
|
||||
};
|
||||
try {
|
||||
showSpinner(true);
|
||||
await deleteFns[type](currentPatientId, id);
|
||||
showToast('Deleted.');
|
||||
await loadChart(currentPatientId);
|
||||
loadDashboardStats();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
} finally {
|
||||
showSpinner(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setupDynamicDataControls() {
|
||||
newPatientModal = new bootstrap.Modal(document.getElementById('newPatientModal'));
|
||||
addResourceModal = new bootstrap.Modal(document.getElementById('addResourceModal'));
|
||||
resourceBrowserModal = new bootstrap.Modal(document.getElementById('resourceBrowserModal'));
|
||||
|
||||
document.getElementById('newPatientBtn').addEventListener('click', () => newPatientModal.show());
|
||||
|
||||
// Live search: filters the cached patient list by name, patient ID, or
|
||||
// MRN as the user types — no server round-trip needed.
|
||||
document.getElementById('patientSearchInput')?.addEventListener('input', (e) => {
|
||||
patientSearchTerm = e.target.value;
|
||||
renderPatientList();
|
||||
updateStatCardsForContext();
|
||||
});
|
||||
|
||||
document.getElementById('newPatientForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
firstName: document.getElementById('np_firstName').value,
|
||||
lastName: document.getElementById('np_lastName').value,
|
||||
dateOfBirth: document.getElementById('np_dob').value,
|
||||
gender: document.getElementById('np_gender').value,
|
||||
medicalRecordNumber: document.getElementById('np_mrn').value || null
|
||||
};
|
||||
try {
|
||||
showSpinner(true);
|
||||
const created = await Api.createPatient(payload);
|
||||
newPatientModal.hide();
|
||||
document.getElementById('newPatientForm').reset();
|
||||
showToast('Patient created.');
|
||||
await loadPatientList();
|
||||
loadDashboardStats();
|
||||
selectPatient(created.patient.id);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
} finally {
|
||||
showSpinner(false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('addResourceForm').addEventListener('submit', submitResourceForm);
|
||||
|
||||
// Stat cards -> open the "browse this resource across all patients" modal.
|
||||
document.getElementById('statsRow').addEventListener('click', (e) => {
|
||||
const card = e.target.closest('.stat-card');
|
||||
if (card) openResourceBrowser(card.dataset.resource);
|
||||
});
|
||||
|
||||
// "Show all patients" link inside the stats scope label — clears both
|
||||
// the search box and the selected patient, reverting the stat cards to
|
||||
// totals across everyone.
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.id !== 'clearStatsScopeLink') return;
|
||||
e.preventDefault();
|
||||
patientSearchTerm = '';
|
||||
const input = document.getElementById('patientSearchInput');
|
||||
if (input) input.value = '';
|
||||
currentPatientId = null;
|
||||
sessionStorage.removeItem('cip_patientId');
|
||||
document.getElementById('patientContent')?.classList.add('d-none');
|
||||
document.getElementById('noPatientState')?.classList.remove('d-none');
|
||||
renderPatientList();
|
||||
updateStatCardsForContext();
|
||||
});
|
||||
|
||||
// Inside the resource browser: clicking a patient chip jumps straight to
|
||||
// that patient's Chart tab.
|
||||
document.getElementById('resourceBrowserBody').addEventListener('click', (e) => {
|
||||
const chip = e.target.closest('[data-jump-patient]');
|
||||
if (!chip) return;
|
||||
resourceBrowserModal.hide();
|
||||
selectPatient(chip.dataset.jumpPatient);
|
||||
document.querySelectorAll('#dashboardTabs .nav-link').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('#dashboardTabs .nav-link[data-tab="chart"]')?.classList.add('active');
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('d-none'));
|
||||
document.getElementById('tab-chart')?.classList.remove('d-none');
|
||||
});
|
||||
|
||||
// Event delegation: "+ Add" buttons in the chart tab, and per-item delete
|
||||
// buttons rendered inside the chart lists (see loadChart()).
|
||||
document.getElementById('tab-chart').addEventListener('click', (e) => {
|
||||
const addBtn = e.target.closest('[data-add]');
|
||||
if (addBtn) { openResourceModal(addBtn.dataset.add); return; }
|
||||
|
||||
const delBtn = e.target.closest('[data-delete-type]');
|
||||
if (delBtn) { deleteResource(delBtn.dataset.deleteType, parseInt(delBtn.dataset.deleteId, 10)); }
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Init ----------
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const token = sessionStorage.getItem('cip_token');
|
||||
if (!token) {
|
||||
window.location.href = 'login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('clinicianLabel').textContent =
|
||||
`👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
|
||||
|
||||
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||
sessionStorage.clear();
|
||||
window.location.href = 'index.html';
|
||||
});
|
||||
|
||||
setupTabs();
|
||||
setupRiskForm();
|
||||
setupDynamicDataControls();
|
||||
document.getElementById('evaluateCdsBtn').addEventListener('click', evaluateCds);
|
||||
document.getElementById('refreshCarePlansBtn').addEventListener('click', () => loadCarePlans(currentPatientId));
|
||||
|
||||
await loadPatientList();
|
||||
loadDashboardStats();
|
||||
|
||||
const launchPatientId = sessionStorage.getItem('cip_patientId');
|
||||
if (launchPatientId) {
|
||||
selectPatient(launchPatientId);
|
||||
}
|
||||
});
|
||||
|
||||
function setupTabs() {
|
||||
document.querySelectorAll('#dashboardTabs .nav-link').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('#dashboardTabs .nav-link').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('d-none'));
|
||||
document.getElementById(`tab-${btn.dataset.tab}`).classList.remove('d-none');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Patients ----------
|
||||
async function loadPatientList() {
|
||||
try {
|
||||
showSpinner(true);
|
||||
patientsCache = await Api.getPatients();
|
||||
renderPatientList();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
} finally {
|
||||
showSpinner(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPatientList() {
|
||||
const container = document.getElementById('patientList');
|
||||
const emptyState = document.getElementById('patientSearchEmpty');
|
||||
const emptyTerm = document.getElementById('patientSearchEmptyTerm');
|
||||
|
||||
const term = patientSearchTerm.trim();
|
||||
const filtered = getFilteredPatients();
|
||||
|
||||
if (filtered.length === 0 && term) {
|
||||
container.innerHTML = '';
|
||||
container.classList.add('d-none');
|
||||
if (emptyTerm) emptyTerm.textContent = patientSearchTerm.trim();
|
||||
emptyState?.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
container.classList.remove('d-none');
|
||||
emptyState?.classList.add('d-none');
|
||||
|
||||
container.innerHTML = filtered.map(p => `
|
||||
<div class="card-ci patient-tile p-2 mb-2 d-flex justify-content-between align-items-start ${p.id === currentPatientId ? 'active' : ''}" data-id="${p.id}">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(p.fullName)}</div>
|
||||
<div class="small text-muted-ci">${p.age} yrs · ${escapeHtml(p.gender)} · ${escapeHtml(p.medicalRecordNumber)}</div>
|
||||
<div class="small text-muted-ci">ID: ${escapeHtml(p.id)}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" data-delete-patient="${p.id}" title="Delete patient">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.querySelectorAll('.patient-tile').forEach(tile => {
|
||||
tile.addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-delete-patient]')) return;
|
||||
selectPatient(tile.dataset.id);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('[data-delete-patient]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const id = btn.dataset.deletePatient;
|
||||
if (!confirm('Delete this patient and all their chart data?')) return;
|
||||
try {
|
||||
showSpinner(true);
|
||||
await Api.deletePatient(id);
|
||||
showToast('Patient deleted.');
|
||||
if (currentPatientId === id) {
|
||||
currentPatientId = null;
|
||||
sessionStorage.removeItem('cip_patientId');
|
||||
document.getElementById('patientContent').classList.add('d-none');
|
||||
document.getElementById('noPatientState').classList.remove('d-none');
|
||||
}
|
||||
await loadPatientList();
|
||||
loadDashboardStats();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
} finally {
|
||||
showSpinner(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function selectPatient(id) {
|
||||
currentPatientId = id;
|
||||
sessionStorage.setItem('cip_patientId', id);
|
||||
renderPatientList();
|
||||
updateStatCardsForContext();
|
||||
|
||||
document.getElementById('noPatientState').classList.add('d-none');
|
||||
document.getElementById('patientContent').classList.remove('d-none');
|
||||
|
||||
resetRiskTab();
|
||||
resetCdsTab();
|
||||
|
||||
await loadChart(id);
|
||||
await loadCarePlans(id);
|
||||
}
|
||||
|
||||
// ---------- Chart ----------
|
||||
async function loadChart(id) {
|
||||
try {
|
||||
showSpinner(true);
|
||||
const chart = await Api.getChart(id);
|
||||
|
||||
document.getElementById('patientName').textContent = chart.fullName;
|
||||
document.getElementById('patientMeta').textContent =
|
||||
`${chart.age} yrs · ${chart.gender} · ${chart.medicalRecordNumber} · Patient ID: ${chart.patientId}`;
|
||||
|
||||
document.getElementById('conditionsList').innerHTML = chart.conditions.length
|
||||
? chart.conditions.map(c => `
|
||||
<div class="chart-list-item d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(c.display)}</div>
|
||||
<div class="small text-muted-ci">${escapeHtml(c.code)} · ${escapeHtml(c.clinicalStatus)} · onset ${fmtDate(c.onsetDate)}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" data-delete-type="condition" data-delete-id="${c.id}">×</button>
|
||||
</div>`).join('')
|
||||
: '<div class="text-muted-ci">No active conditions on file.</div>';
|
||||
|
||||
document.getElementById('allergiesList').innerHTML = chart.allergies.length
|
||||
? chart.allergies.map(a => `
|
||||
<div class="chart-list-item d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold text-danger">${escapeHtml(a.substance)}</div>
|
||||
<div class="small text-muted-ci">Reaction: ${escapeHtml(a.reaction)} · Criticality: ${escapeHtml(a.criticality)}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" data-delete-type="allergy" data-delete-id="${a.id}">×</button>
|
||||
</div>`).join('')
|
||||
: '<div class="text-muted-ci">No known allergies (NKA).</div>';
|
||||
|
||||
document.getElementById('medicationsList').innerHTML = chart.medications.length
|
||||
? chart.medications.map(m => `
|
||||
<div class="chart-list-item d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(m.medicationName)}</div>
|
||||
<div class="small text-muted-ci">${escapeHtml(m.dosage)} · ${escapeHtml(m.status)} · authored ${fmtDate(m.authoredOn)}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" data-delete-type="medication" data-delete-id="${m.id}">×</button>
|
||||
</div>`).join('')
|
||||
: '<div class="text-muted-ci">No active medications.</div>';
|
||||
|
||||
document.getElementById('observationsList').innerHTML = chart.observations.length
|
||||
? chart.observations.map(o => `
|
||||
<div class="chart-list-item d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(o.display)}: ${o.value} ${escapeHtml(o.unit)}</div>
|
||||
<div class="small text-muted-ci">${fmtDate(o.effectiveDate)}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" data-delete-type="observation" data-delete-id="${o.id}">×</button>
|
||||
</div>`).join('')
|
||||
: '<div class="text-muted-ci">No recent observations.</div>';
|
||||
|
||||
document.getElementById('encountersList').innerHTML = chart.encounters.length
|
||||
? chart.encounters.map(e => `
|
||||
<div class="chart-list-item d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(e.encounterType)} — ${escapeHtml(e.reason)}</div>
|
||||
<div class="small text-muted-ci">${fmtDate(e.encounterDate)} · ${escapeHtml(e.status)}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" data-delete-type="encounter" data-delete-id="${e.id}">×</button>
|
||||
</div>`).join('')
|
||||
: '<div class="text-muted-ci">No encounters on file.</div>';
|
||||
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
} finally {
|
||||
showSpinner(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Risk ----------
|
||||
function resetRiskTab() {
|
||||
document.getElementById('riskForm').reset();
|
||||
document.getElementById('q_pain_val').textContent = '0';
|
||||
document.getElementById('riskResultEmpty').classList.remove('d-none');
|
||||
document.getElementById('riskResultBody').classList.add('d-none');
|
||||
}
|
||||
|
||||
function setupRiskForm() {
|
||||
const painInput = document.getElementById('q_pain');
|
||||
painInput.addEventListener('input', () => {
|
||||
document.getElementById('q_pain_val').textContent = painInput.value;
|
||||
});
|
||||
|
||||
document.getElementById('riskForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentPatientId) return;
|
||||
|
||||
const questionnaire = {
|
||||
smoker: document.getElementById('q_smoker').checked,
|
||||
familyHistoryHeartDisease: document.getElementById('q_family').checked,
|
||||
priorHospitalization: document.getElementById('q_hosp').checked,
|
||||
bmi: document.getElementById('q_bmi').value ? parseFloat(document.getElementById('q_bmi').value) : null,
|
||||
painScore: parseInt(document.getElementById('q_pain').value || '0', 10)
|
||||
};
|
||||
|
||||
try {
|
||||
showSpinner(true);
|
||||
const result = await Api.assessRisk(currentPatientId, questionnaire);
|
||||
renderRiskResult(result);
|
||||
showToast('Risk assessment complete.');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
} finally {
|
||||
showSpinner(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderRiskResult(result) {
|
||||
document.getElementById('riskResultEmpty').classList.add('d-none');
|
||||
document.getElementById('riskResultBody').classList.remove('d-none');
|
||||
|
||||
const gauge = document.getElementById('scoreGauge');
|
||||
gauge.textContent = result.score;
|
||||
gauge.style.background = result.riskLevel === 'High'
|
||||
? 'radial-gradient(circle, #e74c3c, #c0392b)'
|
||||
: 'radial-gradient(circle, #27ae60, #1e8449)';
|
||||
|
||||
const badge = document.getElementById('riskLevelBadge');
|
||||
badge.textContent = `${result.riskLevel} Risk`;
|
||||
badge.className = `risk-badge ${result.riskLevel}`;
|
||||
|
||||
document.getElementById('riskFactorsList').innerHTML = result.contributingFactors
|
||||
.map(f => `<li>${escapeHtml(f)}</li>`).join('');
|
||||
}
|
||||
|
||||
// ---------- CDS ----------
|
||||
function resetCdsTab() {
|
||||
document.getElementById('cdsAlertsContainer').innerHTML =
|
||||
'<div class="text-muted-ci text-center py-5">Click "Evaluate Encounter" to screen for drug-allergy conflicts and guideline gaps.</div>';
|
||||
document.getElementById('alertCountBadge').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function evaluateCds() {
|
||||
if (!currentPatientId) return;
|
||||
try {
|
||||
showSpinner(true);
|
||||
const result = await Api.evaluateCds(currentPatientId);
|
||||
renderCdsAlerts(result);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
} finally {
|
||||
showSpinner(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCdsAlerts(result) {
|
||||
const container = document.getElementById('cdsAlertsContainer');
|
||||
const badge = document.getElementById('alertCountBadge');
|
||||
|
||||
if (!result.hasAlerts) {
|
||||
container.innerHTML = '<div class="text-center py-5"><div class="fs-1">✅</div><div class="text-muted-ci">Nothing triggered — no CDS alerts for this encounter.</div></div>';
|
||||
badge.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
badge.textContent = result.alerts.length;
|
||||
badge.classList.remove('d-none');
|
||||
|
||||
container.innerHTML = result.alerts.map(a => `
|
||||
<div class="alert-card ${a.severity}" data-alert-id="${a.id}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<span class="severity-tag ${a.severity}">${escapeHtml(a.severity)}</span>
|
||||
<span class="small text-muted-ci">${escapeHtml(a.ruleId)}</span>
|
||||
</div>
|
||||
<div class="fw-semibold mt-2">${escapeHtml(a.summary)}</div>
|
||||
<div class="small text-muted-ci mt-1">${escapeHtml(a.recommendation)}</div>
|
||||
<button class="btn btn-ci-primary btn-sm mt-3 create-careplan-btn" data-alert-id="${a.id}">
|
||||
Create Care Plan & Service Request
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.querySelectorAll('.create-careplan-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => createCarePlanFromAlert(parseInt(btn.dataset.alertId, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
async function createCarePlanFromAlert(alertId) {
|
||||
try {
|
||||
showSpinner(true);
|
||||
await Api.createCarePlan({
|
||||
patientId: currentPatientId,
|
||||
alertId,
|
||||
createServiceRequest: true,
|
||||
priority: 'urgent'
|
||||
});
|
||||
showToast('Care Plan and Service Request created and written back to the FHIR store.');
|
||||
|
||||
document.querySelector(`.alert-card[data-alert-id="${alertId}"]`)?.remove();
|
||||
const badge = document.getElementById('alertCountBadge');
|
||||
const remaining = document.querySelectorAll('.alert-card').length;
|
||||
if (remaining === 0) {
|
||||
resetCdsTab();
|
||||
document.getElementById('cdsAlertsContainer').innerHTML =
|
||||
'<div class="text-center py-5"><div class="fs-1">✅</div><div class="text-muted-ci">All alerts for this encounter have been actioned.</div></div>';
|
||||
} else {
|
||||
badge.textContent = remaining;
|
||||
}
|
||||
|
||||
await loadCarePlans(currentPatientId);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
} finally {
|
||||
showSpinner(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Care Plans ----------
|
||||
async function loadCarePlans(patientId) {
|
||||
if (!patientId) return;
|
||||
try {
|
||||
const carePlans = await Api.getCarePlans(patientId);
|
||||
const tbody = document.getElementById('carePlansTableBody');
|
||||
|
||||
tbody.innerHTML = carePlans.length
|
||||
? carePlans.map(cp => `
|
||||
<tr>
|
||||
<td>${escapeHtml(cp.title)}</td>
|
||||
<td><span class="small text-muted-ci">${escapeHtml(cp.triggeredByRule)}</span></td>
|
||||
<td><span class="badge bg-success-subtle text-success">${escapeHtml(cp.status)}</span></td>
|
||||
<td class="small text-muted-ci">${fmtDate(cp.createdDate)}</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="4" class="text-muted-ci text-center py-4">No care plans yet.</td></tr>';
|
||||
} catch (err) {
|
||||
showToast(err.message, 'danger');
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.Net Capstone Project/DashboardRedesign_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/DashboardRedesign_ChangedFiles.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/DashboardStats_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/DashboardStats_ChangedFiles.zip
Normal file
Binary file not shown.
Binary file not shown.
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.
@ -1,193 +0,0 @@
|
||||
using ClinicalInsightsPro.API.Models;
|
||||
|
||||
namespace ClinicalInsightsPro.API.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the dashboard's Chart tab needs in ONE payload.
|
||||
///
|
||||
/// The demographic fields at the top are the patient header; the five lists
|
||||
/// below are the chart panels. dashboard.js reads chart.conditions.length,
|
||||
/// chart.allergies.length, etc. — so these lists must ALWAYS be non-null,
|
||||
/// even when empty. That is why each one is initialised to an empty list
|
||||
/// instead of being left null.
|
||||
/// </summary>
|
||||
public class PatientChartDto
|
||||
{
|
||||
// ---- Patient header (shown above the chart panels) ----
|
||||
|
||||
public string PatientId { get; set; } = string.Empty;
|
||||
|
||||
public string FullName { get; set; } = string.Empty;
|
||||
|
||||
public int Age { get; set; }
|
||||
|
||||
public string MedicalRecordNumber { get; set; } = string.Empty;
|
||||
|
||||
public string IdentifierSystem { get; set; } = string.Empty;
|
||||
|
||||
public string IdentifierValue { get; set; } = string.Empty;
|
||||
|
||||
public string NameUse { get; set; } = string.Empty;
|
||||
|
||||
public string? Prefix { get; set; }
|
||||
|
||||
public string GivenName { get; set; } = string.Empty;
|
||||
|
||||
public string FamilyName { get; set; } = string.Empty;
|
||||
|
||||
public string TelecomSystem { get; set; } = "phone";
|
||||
|
||||
public string TelecomUse { get; set; } = "work";
|
||||
|
||||
public string TelecomValue { get; set; } = string.Empty;
|
||||
|
||||
public string Gender { get; set; } = string.Empty;
|
||||
|
||||
// Stored on Patient (not a FHIR field) so the dashboard can show them.
|
||||
public string? BloodGroup { get; set; }
|
||||
|
||||
public string? Email { get; set; }
|
||||
|
||||
public DateTime BirthDate { get; set; }
|
||||
|
||||
public string AddressUse { get; set; } = "home";
|
||||
|
||||
public string AddressType { get; set; } = "both";
|
||||
|
||||
public string? AddressLine { get; set; }
|
||||
|
||||
public string City { get; set; } = string.Empty;
|
||||
|
||||
public string State { get; set; } = string.Empty;
|
||||
|
||||
public string PostalCode { get; set; } = string.Empty;
|
||||
|
||||
public string? MaritalStatusSystem { get; set; }
|
||||
|
||||
public string? MaritalStatusCode { get; set; }
|
||||
|
||||
public string? MaritalStatusDisplay { get; set; }
|
||||
|
||||
public string? ContactNameUse { get; set; }
|
||||
|
||||
public string? ContactPrefix { get; set; }
|
||||
|
||||
public string? ContactTelecomSystem { get; set; }
|
||||
|
||||
public string? ContactTelecomUse { get; set; }
|
||||
|
||||
public string? ContactTelecomValue { get; set; }
|
||||
|
||||
public string? ContactAddressUse { get; set; }
|
||||
|
||||
public string? ContactAddressType { get; set; }
|
||||
|
||||
public string? ContactAddressLine { get; set; }
|
||||
|
||||
public string? ContactCity { get; set; }
|
||||
|
||||
public string? ContactState { get; set; }
|
||||
|
||||
public string? ContactPostalCode { get; set; }
|
||||
|
||||
public string? ContactGender { get; set; }
|
||||
|
||||
public string? ContactOrganizationId { get; set; }
|
||||
|
||||
public DateTime? ContactPeriodStart { get; set; }
|
||||
|
||||
public DateTime? ContactPeriodEnd { get; set; }
|
||||
|
||||
public string? ManagingOrganizationId { get; set; }
|
||||
|
||||
public string? PhotoContentType { get; set; }
|
||||
|
||||
public string? PhotoUrl { get; set; }
|
||||
|
||||
public string? PhotoTitle { get; set; }
|
||||
|
||||
public string? GeneralPractitionerId { get; set; }
|
||||
|
||||
// ---- Chart panels ----
|
||||
|
||||
public List<ChartConditionDto> Conditions { get; set; } = new();
|
||||
|
||||
public List<ChartAllergyDto> Allergies { get; set; } = new();
|
||||
|
||||
public List<ChartMedicationDto> Medications { get; set; } = new();
|
||||
|
||||
public List<ChartObservationDto> Observations { get; set; } = new();
|
||||
|
||||
public List<ChartEncounterDto> Encounters { get; set; } = new();
|
||||
}
|
||||
|
||||
// Each chart row DTO mirrors the columns that actually exist on the matching
|
||||
// entity — no invented "substance"/"dosage"/"value" fields, so what the UI
|
||||
// shows is exactly what is stored in PostgreSQL.
|
||||
|
||||
public class ChartConditionDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Display { get; set; } = string.Empty;
|
||||
public string ClinicalStatus { get; set; } = string.Empty;
|
||||
public string VerificationStatus { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = string.Empty;
|
||||
public DateTime? OnsetDate { get; set; }
|
||||
public DateTime? AbatementDate { get; set; }
|
||||
public DateTime? RecordedDate { get; set; }
|
||||
}
|
||||
|
||||
public class ChartAllergyDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string ClinicalStatus { get; set; } = string.Empty;
|
||||
public string VerificationStatus { get; set; } = string.Empty;
|
||||
public string? Criticality { get; set; }
|
||||
public string? RecorderPractitionerId { get; set; }
|
||||
}
|
||||
|
||||
public class ChartMedicationDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string MedicationCode { get; set; } = string.Empty;
|
||||
public string? MedicationDisplay { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string Intent { get; set; } = string.Empty;
|
||||
public string? Priority { get; set; }
|
||||
}
|
||||
|
||||
public class ChartObservationDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string? Display { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string? CategoryDisplay { get; set; }
|
||||
public string? InterpretationCode { get; set; }
|
||||
public string? InterpretationDisplay { get; set; }
|
||||
public DateTimeOffset? EffectiveDate { get; set; }
|
||||
public string? NoteText { get; set; }
|
||||
}
|
||||
|
||||
public class ChartEncounterDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string ClassCode { get; set; } = string.Empty;
|
||||
public string ClassDisplay { get; set; } = string.Empty;
|
||||
public string? TypeDisplay { get; set; }
|
||||
public string? ServiceTypeDisplay { get; set; }
|
||||
public DateTime PeriodStart { get; set; }
|
||||
public DateTime PeriodEnd { get; set; }
|
||||
}
|
||||
|
||||
public class PatientSummaryDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string FullName { get; set; } = string.Empty;
|
||||
public int Age { get; set; }
|
||||
public string Gender { get; set; } = string.Empty;
|
||||
public string MedicalRecordNumber { get; set; } = string.Empty;
|
||||
}
|
||||
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.
Binary file not shown.
Binary file not shown.
@ -68,47 +68,30 @@ async function rpLoadPatientsForPicker() {
|
||||
} catch { /* patient picker just won't populate; add flow will fail loudly instead */ }
|
||||
}
|
||||
|
||||
async function rpLoadPractitionerRoleDropdowns() {
|
||||
// Fills the three reference dropdowns on the Practitioner Role form. `row` is
|
||||
// passed on Edit so the record's current practitioner / organization /
|
||||
// location come back selected instead of resetting to the first option.
|
||||
async function rpLoadPractitionerRoleDropdowns(row = null) {
|
||||
const look = await rpEnsureLookups(true);
|
||||
|
||||
const practitioners = await Api.getPractitioners();
|
||||
const organizations = await Api.getOrganizations();
|
||||
const locations = await Api.getLocations();
|
||||
const fill = (fieldKey, list, labelFn, selected) => {
|
||||
const el = document.getElementById(`rp_field_${fieldKey}`);
|
||||
if (!el || el.tagName !== 'SELECT') return;
|
||||
|
||||
const practitionerSelect =
|
||||
document.getElementById('rp_field_practitionerId');
|
||||
el.innerHTML = '<option value="">— none —</option>' +
|
||||
list.map(item => {
|
||||
const id = item.id ?? item.Id ?? '';
|
||||
const label = labelFn(item) || id;
|
||||
const isSel = selected && String(selected) === String(id) ? ' selected' : '';
|
||||
return `<option value="${rpEscapeHtml(id)}"${isSel}>${rpEscapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
|
||||
const organizationSelect =
|
||||
document.getElementById('rp_field_organizationId');
|
||||
if (selected) el.value = String(selected);
|
||||
};
|
||||
|
||||
const locationSelect =
|
||||
document.getElementById('rp_field_locationId');
|
||||
|
||||
if (practitionerSelect) {
|
||||
practitionerSelect.innerHTML =
|
||||
practitioners.map(p =>
|
||||
`<option value="${p.id}">
|
||||
${p.givenName} ${p.familyName}
|
||||
</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
if (organizationSelect) {
|
||||
organizationSelect.innerHTML =
|
||||
organizations.map(o =>
|
||||
`<option value="${o.id}">
|
||||
${o.name}
|
||||
</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
if (locationSelect) {
|
||||
locationSelect.innerHTML =
|
||||
locations.map(l =>
|
||||
`<option value="${l.id}">
|
||||
${l.name}
|
||||
</option>`
|
||||
).join('');
|
||||
}
|
||||
fill('practitionerId', look.practitioners, rpPractitionerLabel, row?.practitionerId);
|
||||
fill('organizationId', look.organizations, o => o.name, row?.organizationId);
|
||||
fill('locationId', look.locations, l => l.name, row?.locationId);
|
||||
}
|
||||
// A short banner at the top of the form: icon, what the resource is, and how
|
||||
// the score is worked out for risk assessments. Injected once and reused.
|
||||
@ -116,7 +99,7 @@ const RP_FORM_INTRO = {
|
||||
riskassessments: {
|
||||
icon: '📊',
|
||||
title: 'Clinical risk assessment',
|
||||
body: 'Record the contributing factors as one note, separating each with a semicolon and ending with its weight — e.g. <code>Current smoker (+10); Prior hospitalization (+12)</code>. The percentage and risk level are calculated from those weights.'
|
||||
body: 'Tick the factors that apply. The percentage and risk level are calculated from their weights, and the note is written for you.'
|
||||
},
|
||||
servicerequests: { icon: '📄', title: 'Service request', body: 'The identifier is generated automatically from the resource name and patient id.' },
|
||||
careplans: { icon: '📋', title: 'Care plan', body: 'Period covers the whole plan; the activity schedule covers the next step.' },
|
||||
@ -152,28 +135,98 @@ async function rpOpenAddModal() {
|
||||
// Resources that don't hang off a patient (organizations, locations,
|
||||
// practitioners, CDS rules, users) shouldn't show a patient dropdown at all.
|
||||
document.getElementById('rp_patientPickerRow')
|
||||
.classList.toggle('d-none', !!config.standalone);
|
||||
.classList.toggle('d-none', rpIsStandalone(config));
|
||||
document.getElementById('rp_fieldsBody').innerHTML = config.fields
|
||||
.filter(f => !RP_AUTO_FIELDS.includes(f.key))
|
||||
.map(f => rpFieldInputHtml(f, null)).join('');
|
||||
if (config.key === 'practitionerroles') {
|
||||
await rpLoadPractitionerRoleDropdowns();
|
||||
}
|
||||
rpAddModal.show();
|
||||
.filter(f => !(rpNormKey(config.key) === 'riskassessment' && f.key === 'noteText'))
|
||||
.map(f => rpFieldInputHtml(f, null)).join('')
|
||||
+ (rpNormKey(config.key) === 'riskassessment' ? rpRiskFactorsHtml() : '');
|
||||
|
||||
if (rpNormKey(config.key) === 'riskassessment') {
|
||||
rpRiskPatientId = null; // Add mode follows the dropdown
|
||||
rpWireRiskForm();
|
||||
}
|
||||
|
||||
if (rpNormKey(config.key) === 'practitionerrole') {
|
||||
try { await rpLoadPractitionerRoleDropdowns(); }
|
||||
catch (err) { rpShowToast(`Could not load reference lists: ${err.message}`, 'danger'); }
|
||||
}
|
||||
|
||||
rpAddModal.show();
|
||||
}
|
||||
|
||||
function rpOpenEditModal(row) {
|
||||
// Keeps the live total in sync, and re-renders the reproductive questions
|
||||
// when a different patient is picked (they only apply to female patients).
|
||||
function rpWireRiskForm() {
|
||||
document.querySelectorAll('.rp-risk-check')
|
||||
.forEach(el => el.addEventListener('change', rpUpdateRiskPreview));
|
||||
|
||||
document.getElementById('rp_riskFreeText')
|
||||
?.addEventListener('input', rpUpdateRiskPreview);
|
||||
|
||||
const picker = document.getElementById('rp_patientSelect');
|
||||
if (picker && !picker.dataset.riskWired) {
|
||||
picker.dataset.riskWired = '1';
|
||||
picker.addEventListener('change', () => {
|
||||
if (window.RESOURCE_PAGE_CONFIG?.key !== 'riskassessments') return;
|
||||
|
||||
// Preserve what's already ticked across the re-render.
|
||||
const checked = [...document.querySelectorAll('.rp-risk-check:checked')]
|
||||
.map(el => el.dataset.label);
|
||||
const extra = document.getElementById('rp_riskFreeText')?.value || '';
|
||||
|
||||
const host = document.getElementById('rp_riskScorePreview')?.closest('.col-12');
|
||||
if (!host) return;
|
||||
|
||||
// Rebuild just the questionnaire portion.
|
||||
const fields = document.getElementById('rp_fieldsBody');
|
||||
const keep = [...fields.children].filter(c => !c.querySelector('.rp-risk-check, #rp_riskScoreChip, #rp_riskFreeText'));
|
||||
fields.innerHTML = '';
|
||||
keep.forEach(c => fields.appendChild(c));
|
||||
fields.insertAdjacentHTML('beforeend', rpRiskFactorsHtml(checked, extra));
|
||||
rpWireRiskForm();
|
||||
});
|
||||
}
|
||||
|
||||
rpUpdateRiskPreview();
|
||||
}
|
||||
|
||||
async function rpOpenEditModal(row) {
|
||||
rpEditingRowId = row.id;
|
||||
rpEditingPatientId = row.patientId;
|
||||
rpEditingPatientId = rpRowPatientId(row);
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
//document.getElementById('rp_modalTitle').textContent = `Edit ${config.title.replace(/s$/, '')} — ${row.patientName}`;
|
||||
const key = rpNormKey(config.key);
|
||||
|
||||
// Standalone records are identified by their own name, not a patient's.
|
||||
const label = rpIsStandalone(config)
|
||||
? rpStandaloneTitle(config, row, 0)
|
||||
: '';
|
||||
document.getElementById('rp_modalTitle').textContent =
|
||||
`Edit ${config.title.replace(/s$/, '')}`;
|
||||
`Edit ${config.title.replace(/s$/, '')}${label ? ` — ${label}` : ''}`;
|
||||
|
||||
rpRenderFormIntro(config, 'edit');
|
||||
document.getElementById('rp_patientPickerRow').classList.add('d-none');
|
||||
if (key === 'riskassessment') rpRiskPatientId = rpRowPatientId(row);
|
||||
|
||||
const split = key === 'riskassessment'
|
||||
? rpSplitRiskNote(row.noteText)
|
||||
: null;
|
||||
|
||||
document.getElementById('rp_fieldsBody').innerHTML = config.fields
|
||||
.filter(f => !RP_AUTO_FIELDS.includes(f.key))
|
||||
.map(f => rpFieldInputHtml(f, row[f.key])).join('');
|
||||
.filter(f => !(key === 'riskassessment' && f.key === 'noteText'))
|
||||
.map(f => rpFieldInputHtml(f, row[f.key])).join('')
|
||||
+ (split ? rpRiskFactorsHtml(split.known, split.other) : '');
|
||||
|
||||
if (key === 'riskassessment') rpWireRiskForm();
|
||||
|
||||
// Reference dropdowns have to be rebuilt after the fields are drawn, with
|
||||
// this record's values reselected.
|
||||
if (key === 'practitionerrole') {
|
||||
try { await rpLoadPractitionerRoleDropdowns(row); }
|
||||
catch (err) { rpShowToast(`Could not load reference lists: ${err.message}`, 'danger'); }
|
||||
}
|
||||
|
||||
rpAddModal.show();
|
||||
}
|
||||
|
||||
@ -204,6 +257,12 @@ async function rpSubmitModal(e) {
|
||||
payload[f.key] = v === '' ? null : v;
|
||||
}
|
||||
|
||||
if (rpNormKey(config.key) === 'riskassessment') {
|
||||
// The note is assembled from the ticked factors so the weights are always
|
||||
// well-formed and the score parses correctly.
|
||||
payload.noteText = rpBuildRiskNote();
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.key === 'conditions') {
|
||||
|
||||
@ -353,7 +412,7 @@ if (config.key === 'careplans') {
|
||||
// Identifiers are set on create only; editing keeps the original.
|
||||
if (!rpEditingRowId) {
|
||||
const picker = document.getElementById('rp_patientSelect');
|
||||
const pid = (!config.standalone && picker) ? picker.value : null;
|
||||
const pid = (!rpIsStandalone(config) && picker) ? picker.value : null;
|
||||
const ids = rpBuildIdentifiers(config, pid);
|
||||
|
||||
if (config.fields.some(f => f.key === 'identifierSystem')) {
|
||||
@ -392,26 +451,19 @@ if (config.key === 'careplans') {
|
||||
patientId;
|
||||
}
|
||||
|
||||
const standaloneResources = [
|
||||
'organizations',
|
||||
'locations',
|
||||
'practitioners',
|
||||
'practitionerroles',
|
||||
'cdsrules',
|
||||
'users'
|
||||
];
|
||||
|
||||
if (
|
||||
!config.standalone &&
|
||||
!standaloneResources.includes(config.key) &&
|
||||
!rpIsStandalone(config) &&
|
||||
!RP_STANDALONE_KEYS.includes(rpNormKey(config.key)) &&
|
||||
!patientId
|
||||
)
|
||||
{
|
||||
rpShowToast('Select a patient first.', 'danger');
|
||||
return;
|
||||
}
|
||||
console.log("FINAL PAYLOAD", payload);
|
||||
await config.add(patientId, payload);
|
||||
// Standalone resources have no patient dropdown — sending '' as the id
|
||||
// makes the API build a URL like /patients//organizations.
|
||||
const ownerId = rpIsStandalone(config) ? null : (patientId || null);
|
||||
await config.add(ownerId, payload);
|
||||
|
||||
rpShowToast(`${config.title} added.`);
|
||||
}
|
||||
@ -425,11 +477,18 @@ console.log("FINAL PAYLOAD", payload);
|
||||
|
||||
async function rpDeleteRow(row) {
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
if (!confirm(`Delete this ${config.title.replace(/s$/, '').toLowerCase()} for ${row.patientName}?`)) return;
|
||||
const noun = config.title.replace(/s$/, '').toLowerCase();
|
||||
|
||||
// "…for undefined" is what this used to say on a standalone record, which
|
||||
// has no patient. Name the record itself instead.
|
||||
const subject = rpIsStandalone(config)
|
||||
? `"${rpStandaloneTitle(config, row, 0)}"`
|
||||
: `for ${row.patientName || 'this patient'}`;
|
||||
|
||||
if (!confirm(`Delete this ${noun} ${subject}?`)) return;
|
||||
try {
|
||||
//await config.remove(row.patientId, row.id);
|
||||
await config.remove(
|
||||
row.patientId || null,
|
||||
rpRowPatientId(row),
|
||||
row.id
|
||||
);
|
||||
rpShowToast('Deleted.');
|
||||
@ -546,6 +605,41 @@ const RP_CARD_STYLES = `
|
||||
color: #5f7373; margin-top: .2rem; text-align: center;
|
||||
}
|
||||
|
||||
/* --- Single-record detail panel (organizations, locations, practitioners) --- */
|
||||
.rp-detail-head {
|
||||
display: flex; align-items: center; gap: .9rem;
|
||||
padding-bottom: .9rem; margin-bottom: .9rem;
|
||||
border-bottom: 1px solid #eef3f3;
|
||||
}
|
||||
.rp-detail-avatar {
|
||||
width: 52px; height: 52px; min-width: 52px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 700; font-size: 1rem; line-height: 1;
|
||||
}
|
||||
.rp-detail-name { font-weight: 700; font-size: 1.05rem; color: #1f2d2d; }
|
||||
.rp-detail-sub {
|
||||
font-size: .74rem; text-transform: uppercase; letter-spacing: .5px;
|
||||
color: #5f7373; margin-top: .15rem;
|
||||
}
|
||||
.rp-detail-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: .75rem 1.25rem; margin: 0;
|
||||
}
|
||||
.rp-detail-item {
|
||||
border-bottom: 1px solid #f2f6f6; padding-bottom: .5rem;
|
||||
}
|
||||
.rp-detail-item dt {
|
||||
font-size: .7rem; text-transform: uppercase; letter-spacing: .5px;
|
||||
font-weight: 600; color: #5f7373; margin-bottom: .15rem;
|
||||
}
|
||||
.rp-detail-item dd {
|
||||
margin: 0; font-size: .92rem; color: #1f2d2d; word-break: break-word;
|
||||
}
|
||||
.rp-detail-actions {
|
||||
display: flex; gap: .5rem; justify-content: flex-end;
|
||||
margin-top: 1.1rem; padding-top: .9rem; border-top: 1px solid #eef3f3;
|
||||
}
|
||||
|
||||
/* --- Notes rendered as bullets instead of one long line --- */
|
||||
.rp-note-list { margin: 0; padding-left: 1.05rem; font-size: .85rem; }
|
||||
.rp-note-list li { margin-bottom: .15rem; }
|
||||
@ -580,6 +674,37 @@ const RP_CARD_STYLES = `
|
||||
.rp-form-intro-icon { font-size: 1.5rem; line-height: 1; }
|
||||
.rp-form-intro-title { font-weight: 700; color: #0a4f4f; margin-bottom: .15rem; }
|
||||
.rp-form-intro-body { font-size: .82rem; color: #5f7373; line-height: 1.45; }
|
||||
/* --- Risk factor questionnaire inside the Add/Edit form --- */
|
||||
.rp-risk-score {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
background: #f2f8f8; border: 1px solid #d9e8e8;
|
||||
border-radius: 10px; padding: .55rem .8rem; margin-bottom: .3rem;
|
||||
}
|
||||
.rp-risk-score-label { font-weight: 600; color: #0a4f4f; font-size: .9rem; }
|
||||
.rp-risk-group {
|
||||
font-size: .74rem; text-transform: uppercase; letter-spacing: .5px;
|
||||
font-weight: 700; color: #0a4f4f; margin: .6rem 0 .35rem;
|
||||
}
|
||||
.rp-risk-item {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
padding: .45rem .6rem; border: 1px solid #e6ecec; border-radius: 9px;
|
||||
margin-bottom: .35rem; cursor: pointer; font-size: .9rem;
|
||||
transition: background .1s ease, border-color .1s ease;
|
||||
}
|
||||
.rp-risk-item:hover { background: #f6fafa; border-color: #cfe0e0; }
|
||||
.rp-risk-item input { margin: 0; flex-shrink: 0; }
|
||||
.rp-risk-item span:first-of-type { flex: 1; }
|
||||
.rp-risk-weight {
|
||||
font-size: .74rem; font-weight: 700; color: #5f7373;
|
||||
background: #eef3f3; border-radius: 999px; padding: .1rem .45rem;
|
||||
}
|
||||
.rp-risk-item input:checked ~ .rp-risk-weight { background: #d9ece4; color: #1e8449; }
|
||||
.rp-risk-hint { font-size: .76rem; color: #5f7373; margin-top: .3rem; line-height: 1.45; }
|
||||
.rp-risk-hint code {
|
||||
background: #f2f8f8; border: 1px solid #dfe7e7; border-radius: 4px;
|
||||
padding: .02rem .25rem; color: #0f6e6e;
|
||||
}
|
||||
|
||||
.rp-form-intro-body code {
|
||||
background: #fff; border: 1px solid #dfe7e7; border-radius: 5px;
|
||||
padding: .05rem .3rem; color: #0f6e6e; font-size: .78rem;
|
||||
@ -645,20 +770,36 @@ function rpAvatarColour(name) {
|
||||
|
||||
// The badge under the name: whatever the config nominates, otherwise the
|
||||
// first column that isn't the patient's name.
|
||||
function rpCardBadge(config, rows) {
|
||||
function rpCardBadge(config, rows, title) {
|
||||
if (config.cardBadge) return config.cardBadge(rows);
|
||||
|
||||
// Risk assessments show the patient's highest score, not just a status.
|
||||
if (config.key === 'riskassessments') {
|
||||
if (rpNormKey(config.key) === 'riskassessment') {
|
||||
const best = rows
|
||||
.map(r => rpParseRiskNote(r.noteText).score)
|
||||
.reduce((a, b) => Math.max(a, b), 0);
|
||||
return `${rpRiskLevel(best)} · ${best}%`;
|
||||
}
|
||||
|
||||
const row = rows[0];
|
||||
|
||||
// Standalone records: show a second, different detail — the name is already
|
||||
// the card title, so repeating it in the badge tells the user nothing.
|
||||
if (rpIsStandalone(config)) {
|
||||
const subFn = RP_CARD_SUBTITLE[rpNormKey(config.key)];
|
||||
const sub = subFn ? subFn(row) : '';
|
||||
if (sub) return sub;
|
||||
|
||||
const col = (config.columns || []).find(c =>
|
||||
c.key !== 'patientName' &&
|
||||
row?.[c.key] !== null && row?.[c.key] !== undefined && row?.[c.key] !== '' &&
|
||||
String(row[c.key]) !== String(title));
|
||||
return col ? String(row[col.key]) : '';
|
||||
}
|
||||
|
||||
const col = config.columns.find(c => c.key !== 'patientName');
|
||||
if (!col) return '';
|
||||
const value = rows[0]?.[col.key];
|
||||
const value = row?.[col.key];
|
||||
return value === null || value === undefined || value === '' ? '' : String(value);
|
||||
}
|
||||
|
||||
@ -689,6 +830,143 @@ const RP_IDENTIFIER_PREFIX = {
|
||||
// Fields the user should never have to fill in.
|
||||
const RP_AUTO_FIELDS = ['identifierSystem', 'identifierValue'];
|
||||
|
||||
// ---------- Risk factor questionnaire ----------
|
||||
// The note is what actually stores the factors, but typing
|
||||
// "Current smoker (+10); ..." by hand is error-prone — one wrong bracket and
|
||||
// the score comes out wrong. So the Add/Edit form asks the same tick-box
|
||||
// questions the dashboard does, and assembles the note from the answers.
|
||||
// Weights match RiskScoringService exactly, so both routes agree.
|
||||
const RP_RISK_FACTORS = [
|
||||
{ group: 'General health', label: 'Current smoker', weight: 10 },
|
||||
{ group: 'General health', label: 'Family history of heart disease', weight: 8 },
|
||||
{ group: 'General health', label: 'Prior hospitalization', weight: 12 },
|
||||
{ group: 'General health', label: 'Resting heart rate over 100 bpm', weight: 8 },
|
||||
{ group: 'General health', label: 'BMI 30 or above', weight: 8 },
|
||||
{ group: 'General health', label: 'Pain score 7 or above', weight: 5 },
|
||||
|
||||
{ group: 'Reproductive health', label: 'Currently pregnant', weight: 6, femaleOnly: true },
|
||||
{ group: 'Reproductive health', label: 'Currently breastfeeding', weight: 4, femaleOnly: true },
|
||||
|
||||
{ group: 'Conditions & medications', label: 'Active condition reported as not well controlled', weight: 10 },
|
||||
{ group: 'Conditions & medications', label: 'New chronic condition diagnosed in the past 6 months', weight: 10 },
|
||||
{ group: 'Conditions & medications', label: 'Reported missed medication doses', weight: 8 },
|
||||
{ group: 'Conditions & medications', label: 'History of severe/anaphylactic allergic reaction', weight: 10 }
|
||||
];
|
||||
|
||||
// Gender of whoever is picked in the patient dropdown, so the reproductive
|
||||
// questions only appear for female patients — same rule as the dashboard.
|
||||
// Whose gender gates the reproductive questions. On Add that's the dropdown;
|
||||
// on Edit the dropdown is hidden and stale, so the row's own patient wins.
|
||||
let rpRiskPatientId = null;
|
||||
|
||||
function rpSelectedGender() {
|
||||
const id = rpRiskPatientId
|
||||
|| document.getElementById('rp_patientSelect')?.value
|
||||
|| null;
|
||||
if (!id) return '';
|
||||
const patient = rpPatientsCache.find(p => p.id === id);
|
||||
return (patient?.gender || '').toLowerCase();
|
||||
}
|
||||
|
||||
function rpRiskFactorsHtml(checkedLabels = [], freeText = '') {
|
||||
const gender = rpSelectedGender();
|
||||
const visible = RP_RISK_FACTORS.filter(f => !f.femaleOnly || gender === 'female');
|
||||
|
||||
const groups = [];
|
||||
visible.forEach(f => {
|
||||
let g = groups.find(x => x.name === f.group);
|
||||
if (!g) { g = { name: f.group, items: [] }; groups.push(g); }
|
||||
g.items.push(f);
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="col-12">
|
||||
<div class="rp-risk-score" id="rp_riskScorePreview">
|
||||
<span class="rp-score-chip is-low" id="rp_riskScoreChip">0%</span>
|
||||
<span class="rp-risk-score-label" id="rp_riskScoreLabel">Low risk</span>
|
||||
</div>
|
||||
</div>
|
||||
${groups.map(g => `
|
||||
<div class="col-12">
|
||||
<div class="rp-risk-group">${rpEscapeHtml(g.name)}</div>
|
||||
${g.items.map(f => {
|
||||
const id = `rp_risk_${f.label.replace(/[^a-z0-9]/gi, '_')}`;
|
||||
const checked = checkedLabels.includes(f.label) ? 'checked' : '';
|
||||
return `<label class="rp-risk-item">
|
||||
<input type="checkbox" class="form-check-input rp-risk-check"
|
||||
id="${id}" data-label="${rpEscapeHtml(f.label)}"
|
||||
data-weight="${f.weight}" ${checked}>
|
||||
<span>${rpEscapeHtml(f.label)}</span>
|
||||
<span class="rp-risk-weight">+${f.weight}</span>
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</div>`).join('')}
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="rp_riskFreeText">Additional factors & notes</label>
|
||||
<textarea class="form-control" rows="2" id="rp_riskFreeText"
|
||||
placeholder="e.g. 3 active condition(s) on chart (+15)">${rpEscapeHtml(freeText)}</textarea>
|
||||
<div class="rp-risk-hint">
|
||||
Factors the rules engine derived from the chart land here. Any
|
||||
<code>(+n)</code> weight in this box is counted in the score above.
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Live total as boxes are ticked.
|
||||
function rpUpdateRiskPreview() {
|
||||
const chip = document.getElementById('rp_riskScoreChip');
|
||||
if (!chip) return;
|
||||
|
||||
let score = 0;
|
||||
document.querySelectorAll('.rp-risk-check:checked')
|
||||
.forEach(el => { score += Number(el.dataset.weight || 0); });
|
||||
|
||||
// Weights can also sit in the free-text box — the rules engine adds factors
|
||||
// the form has no box for, such as "3 active condition(s) on chart (+15)".
|
||||
// Counting them keeps the preview equal to what the grid will show.
|
||||
const extra = document.getElementById('rp_riskFreeText')?.value || '';
|
||||
extra.split(';').forEach(part => {
|
||||
const m = part.match(/\(\+(\d+(?:\.\d+)?)\)/);
|
||||
if (m) score += parseFloat(m[1]);
|
||||
});
|
||||
|
||||
score = Math.min(Math.round(score), 100);
|
||||
|
||||
const level = rpRiskLevel(score);
|
||||
chip.textContent = `${score}%`;
|
||||
chip.className = `rp-score-chip is-${level.toLowerCase()}`;
|
||||
document.getElementById('rp_riskScoreLabel').textContent = `${level} risk`;
|
||||
}
|
||||
|
||||
// Turns the ticked boxes back into the note the API stores.
|
||||
function rpBuildRiskNote() {
|
||||
const parts = [];
|
||||
document.querySelectorAll('.rp-risk-check:checked').forEach(el => {
|
||||
parts.push(`${el.dataset.label} (+${el.dataset.weight})`);
|
||||
});
|
||||
|
||||
const extra = document.getElementById('rp_riskFreeText')?.value.trim();
|
||||
if (extra) parts.push(extra);
|
||||
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
// Splits an existing note back into ticked boxes + leftover free text, so
|
||||
// editing an assessment doesn't lose anything that wasn't a known factor.
|
||||
function rpSplitRiskNote(noteText) {
|
||||
const parts = (noteText || '').split(';').map(s => s.trim()).filter(Boolean);
|
||||
const known = [];
|
||||
const other = [];
|
||||
|
||||
parts.forEach(part => {
|
||||
const match = RP_RISK_FACTORS.find(f =>
|
||||
part.toLowerCase().startsWith(f.label.toLowerCase()));
|
||||
if (match) known.push(match.label); else other.push(part);
|
||||
});
|
||||
|
||||
return { known, other: other.join('; ') };
|
||||
}
|
||||
|
||||
// ---------- Risk scoring ----------
|
||||
// RiskAssessment has no score column; the contributing factors are stored as
|
||||
// one semicolon-joined note ("Current smoker (+10); Prior hospitalization
|
||||
@ -737,22 +1015,205 @@ function rpBuildIdentifiers(config, patientId) {
|
||||
return { identifierSystem: system, identifierValue: value };
|
||||
}
|
||||
|
||||
// Resources that don't belong to a patient. Their pages don't set
|
||||
// `standalone: true` in RESOURCE_PAGE_CONFIG, so the key list is what makes
|
||||
// them render one card per record instead of one lumped "unassigned" card.
|
||||
//
|
||||
// Keys are compared after normalising (lower-cased, letters only, trailing
|
||||
// "s" dropped), so 'Organizations', 'organization', 'practitioner-roles' and
|
||||
// 'practitionerRoles' all match. The old exact-string comparison is why an
|
||||
// Organizations page whose config.key was spelled differently fell through to
|
||||
// the patient branch and rendered a single "unassigned" card.
|
||||
const RP_STANDALONE_KEYS = [
|
||||
'organization',
|
||||
'location',
|
||||
'practitioner',
|
||||
'practitionerrole',
|
||||
'cdsrule',
|
||||
'user',
|
||||
'device',
|
||||
'healthcareservice'
|
||||
];
|
||||
|
||||
// Normalised comparison key: 'PractitionerRoles' -> 'practitionerrole'.
|
||||
function rpNormKey(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z]/g, '')
|
||||
.replace(/s$/, '');
|
||||
}
|
||||
|
||||
// Any field that would tie a row to a patient. If a row has none of these,
|
||||
// there is nothing to group by and the record must stand on its own.
|
||||
const RP_PATIENT_LINK_KEYS = [
|
||||
'patientId', 'patientID', 'PatientId',
|
||||
'subjectPatientId', 'asserterPatientId', 'authorPatientId',
|
||||
'patientName'
|
||||
];
|
||||
|
||||
function rpRowPatientId(row) {
|
||||
if (!row) return null;
|
||||
return row.patientId || row.PatientId || row.patientID || row.subjectPatientId || null;
|
||||
}
|
||||
|
||||
function rpRowHasPatient(row) {
|
||||
return !!row && RP_PATIENT_LINK_KEYS.some(k => row[k]);
|
||||
}
|
||||
|
||||
function rpIsStandalone(config, rows) {
|
||||
if (!config) return false;
|
||||
if (config.standalone === true) return true;
|
||||
if (config.standalone === false) return false;
|
||||
|
||||
const key = rpNormKey(config.key);
|
||||
if (RP_STANDALONE_KEYS.includes(key)) return true;
|
||||
|
||||
// Title is checked too, in case the page's key is something bespoke.
|
||||
if (RP_STANDALONE_KEYS.includes(rpNormKey(config.title))) return true;
|
||||
|
||||
// Last resort: the page's own filename (organizations.html -> organization).
|
||||
const page = rpNormKey((window.location.pathname.split('/').pop() || '').replace(/\.html?$/i, ''));
|
||||
if (page && RP_STANDALONE_KEYS.includes(page)) return true;
|
||||
|
||||
// Data-driven fallback: no row is linked to a patient => nothing to group by.
|
||||
const sample = Array.isArray(rows) ? rows : rpAllRows;
|
||||
if (Array.isArray(sample) && sample.length > 0) {
|
||||
return !sample.some(rpRowHasPatient);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- Reference lookups ----------
|
||||
// Practitioner roles and locations store ids, not names. The cards and the
|
||||
// detail panel resolve those ids once per page load so a card reads
|
||||
// "Dr. Meera Joshi — Cardiology", not a bare GUID.
|
||||
let rpLookups = { loaded: false, practitioners: [], organizations: [], locations: [] };
|
||||
|
||||
async function rpSafeList(name) {
|
||||
try {
|
||||
if (!window.Api || typeof Api[name] !== 'function') return [];
|
||||
const result = await Api[name]();
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function rpEnsureLookups(force = false) {
|
||||
if (rpLookups.loaded && !force) return rpLookups;
|
||||
const [practitioners, organizations, locations] = await Promise.all([
|
||||
rpSafeList('getPractitioners'),
|
||||
rpSafeList('getOrganizations'),
|
||||
rpSafeList('getLocations')
|
||||
]);
|
||||
rpLookups = { loaded: true, practitioners, organizations, locations };
|
||||
return rpLookups;
|
||||
}
|
||||
|
||||
function rpPractitionerLabel(p) {
|
||||
if (!p) return '';
|
||||
return [p.prefix, p.givenName, p.familyName].filter(Boolean).join(' ')
|
||||
|| p.name || p.fullName || '';
|
||||
}
|
||||
|
||||
function rpLookupName(list, id, labelFn) {
|
||||
if (!id) return '';
|
||||
const hit = (list || []).find(x => String(x.id ?? x.Id) === String(id));
|
||||
return hit ? (labelFn ? labelFn(hit) : (hit.name || '')) : '';
|
||||
}
|
||||
|
||||
// Fills in the display names a row is missing, without overwriting anything
|
||||
// the API already sent.
|
||||
async function rpDecorateRows(config, rows) {
|
||||
const key = rpNormKey(config.key);
|
||||
if (!Array.isArray(rows) || !rows.length) return rows;
|
||||
if (!['practitionerrole', 'location', 'organization'].includes(key)) return rows;
|
||||
|
||||
const look = await rpEnsureLookups();
|
||||
|
||||
rows.forEach(r => {
|
||||
if (!r) return;
|
||||
if (!r.practitionerName) {
|
||||
r.practitionerName = rpLookupName(look.practitioners,
|
||||
r.practitionerId || r.practitionerID, rpPractitionerLabel);
|
||||
}
|
||||
if (!r.organizationName) {
|
||||
r.organizationName = rpLookupName(look.organizations,
|
||||
r.organizationId || r.managingOrganizationId || r.partOfId);
|
||||
}
|
||||
if (!r.locationName) {
|
||||
r.locationName = rpLookupName(look.locations, r.locationId);
|
||||
}
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// What to put on the card for a standalone record — the first column alone
|
||||
// is often not the useful label (a practitioner's card should read
|
||||
// "Dr. Meera Joshi", not just "Meera").
|
||||
const RP_CARD_TITLE = {
|
||||
practitioner: r => rpPractitionerLabel(r),
|
||||
practitionerrole: r => r.practitionerName || r.roleDisplay || r.roleText
|
||||
|| r.specialtyDisplay || r.code || '',
|
||||
organization: r => r.name || r.organizationName || r.alias || '',
|
||||
location: r => r.name || r.locationName || r.alias || '',
|
||||
cdsrule: r => r.ruleId || r.name || r.title || '',
|
||||
user: r => [r.firstName, r.lastName].filter(Boolean).join(' ') || r.username || ''
|
||||
};
|
||||
|
||||
// Generic title: the first field that actually reads like a name.
|
||||
const RP_TITLE_FALLBACK_KEYS = ['name', 'displayName', 'title', 'fullName', 'label', 'code'];
|
||||
|
||||
function rpStandaloneTitle(config, row, index) {
|
||||
const fn = RP_CARD_TITLE[rpNormKey(config.key)];
|
||||
const fromFn = fn ? fn(row) : '';
|
||||
if (fromFn) return fromFn;
|
||||
|
||||
for (const k of RP_TITLE_FALLBACK_KEYS) {
|
||||
if (row[k]) return String(row[k]);
|
||||
}
|
||||
const firstCol = config.columns?.find(c => c.key !== 'patientName');
|
||||
if (firstCol && row[firstCol.key]) return String(row[firstCol.key]);
|
||||
return `${config.title.replace(/s$/, '')} ${index + 1}`;
|
||||
}
|
||||
|
||||
// The line under the name on a standalone card — the second useful column,
|
||||
// never a repeat of the title itself.
|
||||
const RP_CARD_SUBTITLE = {
|
||||
practitioner: r => r.qualificationDisplay || r.specialtyDisplay || r.gender || '',
|
||||
practitionerrole: r => [r.roleDisplay || r.specialtyDisplay, r.organizationName]
|
||||
.filter(Boolean).join(' · '),
|
||||
organization: r => r.typeDisplay || r.type || r.city || r.identifierValue || '',
|
||||
location: r => [r.typeDisplay || r.type, r.organizationName || r.city]
|
||||
.filter(Boolean).join(' · '),
|
||||
cdsrule: r => r.description || r.severity || '',
|
||||
user: r => r.role || r.email || ''
|
||||
};
|
||||
|
||||
let rpGroups = {};
|
||||
|
||||
function rpBuildGroups(rows) {
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
const groups = {};
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
|
||||
if (config.standalone) {
|
||||
// One card per record; the title is the first column's value.
|
||||
const titleKey = config.columns[0].key;
|
||||
rows.forEach(r => {
|
||||
groups[r.id] = { title: String(r[titleKey] ?? '—'), rows: [r] };
|
||||
if (rpIsStandalone(config, list)) {
|
||||
// One card per record — organizations, locations, practitioners and
|
||||
// practitioner roles are never lumped together.
|
||||
list.forEach((r, i) => {
|
||||
// The index is always part of the key, so two records that share an id
|
||||
// (or send none at all) can never collapse onto the same card.
|
||||
const id = r.id ?? r.Id ?? r.uuid ?? '';
|
||||
const key = `rec-${i}-${id}`;
|
||||
groups[key] = {
|
||||
title: rpStandaloneTitle(config, r, i) || '—',
|
||||
rows: [r],
|
||||
single: true
|
||||
};
|
||||
});
|
||||
} else {
|
||||
rows.forEach(r => {
|
||||
const key = r.patientId || 'unassigned';
|
||||
if (!groups[key]) groups[key] = { title: r.patientName || key, rows: [] };
|
||||
list.forEach(r => {
|
||||
const key = rpRowPatientId(r) || 'unassigned';
|
||||
if (!groups[key]) groups[key] = { title: r.patientName || key, rows: [], single: false };
|
||||
groups[key].rows.push(r);
|
||||
});
|
||||
}
|
||||
@ -767,14 +1228,24 @@ async function rpLoadGrid() {
|
||||
const countLabel = document.getElementById('rp_countLabel');
|
||||
|
||||
try {
|
||||
const rows = await config.listAll(rpSearchTerm.trim());
|
||||
const result = await config.listAll(rpSearchTerm.trim());
|
||||
// Some endpoints wrap the collection ({ items: [...] } / .NET { $values }).
|
||||
const rows = Array.isArray(result)
|
||||
? result
|
||||
: (result?.items || result?.data || result?.$values || result?.results || []);
|
||||
|
||||
rpAllRows = rows;
|
||||
await rpDecorateRows(config, rows);
|
||||
|
||||
rpGroups = rpBuildGroups(rows);
|
||||
const keys = Object.keys(rpGroups);
|
||||
const standalone = rpIsStandalone(config, rows);
|
||||
|
||||
if (countLabel) {
|
||||
countLabel.textContent = config.standalone
|
||||
? `${rows.length} record${rows.length === 1 ? '' : 's'}`
|
||||
const noun = config.title.toLowerCase();
|
||||
const one = noun.replace(/s$/, '');
|
||||
countLabel.textContent = standalone
|
||||
? `${rows.length} ${rows.length === 1 ? one : noun}`
|
||||
: `${rows.length} record${rows.length === 1 ? '' : 's'} · ${keys.length} patient${keys.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
@ -787,16 +1258,20 @@ async function rpLoadGrid() {
|
||||
|
||||
grid.innerHTML = keys.map(key => {
|
||||
const g = rpGroups[key];
|
||||
const badge = rpCardBadge(config, g.rows);
|
||||
const badge = rpCardBadge(config, g.rows, g.title);
|
||||
const colour = rpAvatarColour(g.title);
|
||||
|
||||
// A standalone card holds exactly one record, so "View (1)" is noise —
|
||||
// it opens that record's own details instead.
|
||||
const label = g.single ? 'View details' : `View (${g.rows.length})`;
|
||||
|
||||
return `
|
||||
<div class="rp-card">
|
||||
<div class="rp-avatar" style="background:${colour}">${rpEscapeHtml(rpInitials(g.title))}</div>
|
||||
<div class="rp-card-name" title="${rpEscapeHtml(g.title)}">${rpEscapeHtml(g.title)}</div>
|
||||
${badge ? `<div class="rp-card-badge ${rpBadgeClass(badge)}">${rpEscapeHtml(badge)}</div>` : ''}
|
||||
<button class="rp-card-view" style="color:${colour};border-color:${colour}"
|
||||
data-group="${rpEscapeHtml(key)}">View (${g.rows.length})</button>
|
||||
data-group="${rpEscapeHtml(key)}">${label}</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
@ -805,17 +1280,129 @@ async function rpLoadGrid() {
|
||||
}
|
||||
|
||||
// ---------- Detail modal ----------
|
||||
// Rows currently shown in the detail modal, for the Edit / Delete handlers.
|
||||
let rpDetailRows = [];
|
||||
|
||||
// Field labels come from the page config; anything the config doesn't name
|
||||
// gets a readable label derived from its key (managingOrganizationId ->
|
||||
// "Managing Organization").
|
||||
function rpFieldLabel(config, key) {
|
||||
const fromCol = (config.columns || []).find(c => c.key === key);
|
||||
if (fromCol?.label) return fromCol.label;
|
||||
const fromField = (config.fields || []).find(f => f.key === key);
|
||||
if (fromField?.label) return fromField.label;
|
||||
return key
|
||||
.replace(/Id$/, '')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, c => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Ids are shown as the name they point at, with the raw id kept as a tooltip.
|
||||
function rpDisplayValue(config, row, key, type) {
|
||||
const idToName = {
|
||||
practitionerId: row.practitionerName,
|
||||
organizationId: row.organizationName,
|
||||
managingOrganizationId: row.organizationName,
|
||||
partOfId: row.organizationName,
|
||||
locationId: row.locationName
|
||||
};
|
||||
if (idToName[key]) return idToName[key];
|
||||
|
||||
const v = row[key];
|
||||
if (v === null || v === undefined || v === '') return '—';
|
||||
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
|
||||
if (type === 'date' || type === 'datetime-local') return rpFmtDate(v);
|
||||
if (typeof v === 'object') return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// One organization / location / practitioner / role, laid out as its own
|
||||
// labelled field list rather than a single squashed table row.
|
||||
function rpRenderSingleDetail(config, row, title) {
|
||||
const list = document.getElementById('rp_detailSingle');
|
||||
const tableWrap = document.getElementById('rp_detailTableWrap');
|
||||
if (!list) return false;
|
||||
|
||||
list.classList.remove('d-none');
|
||||
tableWrap?.classList.add('d-none');
|
||||
|
||||
// Every key worth showing: the configured columns first, then any remaining
|
||||
// form fields, then the generated identifiers.
|
||||
const seen = new Set(['patientName', 'patientId']);
|
||||
const entries = [];
|
||||
|
||||
const push = (key, type) => {
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
if (!(key in row) && !['practitionerId', 'organizationId', 'locationId'].includes(key)) return;
|
||||
entries.push({ key, label: rpFieldLabel(config, key), value: rpDisplayValue(config, row, key, type) });
|
||||
};
|
||||
|
||||
(config.columns || []).forEach(c => push(c.key, c.type));
|
||||
(config.fields || []).forEach(f => push(f.key, f.type));
|
||||
Object.keys(row).forEach(k => {
|
||||
if (/^(id|Id)$/.test(k)) return;
|
||||
if (/Name$/.test(k) && ['practitionerName', 'organizationName', 'locationName'].includes(k)) return;
|
||||
push(k);
|
||||
});
|
||||
|
||||
const showActions = !(config.readOnly || config.canEdit === false);
|
||||
|
||||
list.innerHTML = `
|
||||
<div class="rp-detail-head">
|
||||
<div class="rp-detail-avatar" style="background:${rpAvatarColour(title)}">
|
||||
${rpEscapeHtml(rpInitials(title))}
|
||||
</div>
|
||||
<div>
|
||||
<div class="rp-detail-name">${rpEscapeHtml(title)}</div>
|
||||
<div class="rp-detail-sub">${rpEscapeHtml(config.title.replace(/s$/, ''))}${
|
||||
row.id ? ` · ${rpEscapeHtml(String(row.id))}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="rp-detail-grid">
|
||||
${entries.map(e => `
|
||||
<div class="rp-detail-item">
|
||||
<dt>${rpEscapeHtml(e.label)}</dt>
|
||||
<dd>${rpEscapeHtml(e.value)}</dd>
|
||||
</div>`).join('')}
|
||||
</dl>
|
||||
${showActions ? `
|
||||
<div class="rp-detail-actions">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-edit-row="${rpEscapeHtml(String(row.id))}">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" data-delete-row="${rpEscapeHtml(String(row.id))}">Delete</button>
|
||||
</div>` : ''}`;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function rpOpenDetail(groupKey) {
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
const group = rpGroups[groupKey];
|
||||
if (!group) return;
|
||||
|
||||
rpDetailRows = group.rows;
|
||||
|
||||
// One record per card => show that record's own fields.
|
||||
if (group.single && group.rows.length === 1) {
|
||||
document.getElementById('rp_detailTitle').textContent = group.title;
|
||||
if (rpRenderSingleDetail(config, group.rows[0], group.title)) {
|
||||
const body = document.getElementById('rp_detailBody');
|
||||
if (body) body._rpRows = group.rows;
|
||||
rpDetailModal.show();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('rp_detailSingle')?.classList.add('d-none');
|
||||
document.getElementById('rp_detailTableWrap')?.classList.remove('d-none');
|
||||
|
||||
document.getElementById('rp_detailTitle').textContent =
|
||||
`${config.title} — ${group.title}`;
|
||||
|
||||
const showActions = !(config.readOnly || config.canEdit === false);
|
||||
|
||||
const isRisk = config.key === 'riskassessments';
|
||||
const isRisk = rpNormKey(config.key) === 'riskassessment';
|
||||
|
||||
document.getElementById('rp_detailHead').innerHTML =
|
||||
(isRisk ? '<th>Score</th>' : '') +
|
||||
@ -898,7 +1485,8 @@ function rpBuildCardShell() {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="table-responsive">
|
||||
<div id="rp_detailSingle" class="rp-detail-list d-none"></div>
|
||||
<div class="table-responsive" id="rp_detailTableWrap">
|
||||
<table class="table table-sm resource-grid-table align-middle mb-0">
|
||||
<thead><tr id="rp_detailHead"></tr></thead>
|
||||
<tbody id="rp_detailBody"></tbody>
|
||||
@ -910,6 +1498,18 @@ function rpBuildCardShell() {
|
||||
</div>`;
|
||||
document.body.appendChild(wrap.firstElementChild);
|
||||
}
|
||||
|
||||
// If the page shipped its own detail modal, make sure the pieces the
|
||||
// single-record view needs are present.
|
||||
const detailBody = document.getElementById('rp_detailBody');
|
||||
const tableWrap = detailBody?.closest('.table-responsive');
|
||||
if (tableWrap && !tableWrap.id) tableWrap.id = 'rp_detailTableWrap';
|
||||
if (tableWrap && !document.getElementById('rp_detailSingle')) {
|
||||
const single = document.createElement('div');
|
||||
single.id = 'rp_detailSingle';
|
||||
single.className = 'rp-detail-list d-none';
|
||||
tableWrap.parentNode.insertBefore(single, tableWrap);
|
||||
}
|
||||
}
|
||||
|
||||
function rpInit() {
|
||||
@ -958,20 +1558,25 @@ function rpInit() {
|
||||
rpAddModal = new bootstrap.Modal(document.getElementById('rp_modal'));
|
||||
document.getElementById('rp_addBtn').addEventListener('click', rpOpenAddModal);
|
||||
document.getElementById('rp_form').addEventListener('submit', rpSubmitModal);
|
||||
if (!config.standalone) rpLoadPatientsForPicker();
|
||||
if (!rpIsStandalone(config)) rpLoadPatientsForPicker();
|
||||
|
||||
// Edit / Delete now live inside the detail modal.
|
||||
document.getElementById('rp_detailBody').addEventListener('click', (e) => {
|
||||
// Edit / Delete live inside the detail modal — the listener sits on the
|
||||
// modal itself so it covers both the grouped table and the single-record
|
||||
// panel.
|
||||
document.getElementById('rp_detailModal').addEventListener('click', (e) => {
|
||||
const editBtn = e.target.closest('[data-edit-row]');
|
||||
const delBtn = e.target.closest('[data-delete-row]');
|
||||
const tbody = document.getElementById('rp_detailBody');
|
||||
const rows = tbody._rpRows || [];
|
||||
if (!editBtn && !delBtn) return;
|
||||
|
||||
const rows = rpDetailRows.length
|
||||
? rpDetailRows
|
||||
: (document.getElementById('rp_detailBody')?._rpRows || []);
|
||||
|
||||
if (editBtn) {
|
||||
const row = rows.find(r => String(r.id) === editBtn.dataset.editRow);
|
||||
const row = rows.find(r => String(r.id) === editBtn.dataset.editRow) || rows[0];
|
||||
if (row) { rpDetailModal.hide(); rpOpenEditModal(row); }
|
||||
} else if (delBtn) {
|
||||
const row = rows.find(r => String(r.id) === delBtn.dataset.deleteRow);
|
||||
const row = rows.find(r => String(r.id) === delBtn.dataset.deleteRow) || rows[0];
|
||||
if (row) { rpDetailModal.hide(); rpDeleteRow(row); }
|
||||
}
|
||||
});
|
||||
@ -985,4 +1590,4 @@ function rpInit() {
|
||||
rpLoadGrid();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', rpInit);
|
||||
document.addEventListener('DOMContentLoaded', rpInit);
|
||||
BIN
.Net Capstone Project/ScopedStats_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/ScopedStats_ChangedFiles.zip
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
.Net Capstone Project/SidebarAndBackground.zip
Normal file
BIN
.Net Capstone Project/SidebarAndBackground.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/SignIn_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/SignIn_ChangedFiles.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/SwaggerAutoAuth_ChangedFiles.zip
Normal file
BIN
.Net Capstone Project/SwaggerAutoAuth_ChangedFiles.zip
Normal file
Binary file not shown.
Binary file not shown.
BIN
.Net Capstone Project/Updated files.zip
Normal file
BIN
.Net Capstone Project/Updated files.zip
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,87 +0,0 @@
|
||||
// Single source of truth for the left navigation on EVERY page.
|
||||
//
|
||||
// Why this file self-initialises:
|
||||
// The old version only drew the menu when a page explicitly called
|
||||
// renderSidebar('key'). Only dashboard.js and resource-page.js did that, so
|
||||
// any page with its own custom script (or its own hardcoded <aside> markup)
|
||||
// ended up showing a stale, shorter menu — or none at all. Now the script
|
||||
// runs itself on DOMContentLoaded and OVERWRITES whatever is in the sidebar
|
||||
// container, so simply including this file is enough to get the full list.
|
||||
//
|
||||
// Adding a nav item? Add it to SIDEBAR_ITEMS below 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' }
|
||||
];
|
||||
|
||||
// Pages that deliberately have no sidebar (public / auth screens).
|
||||
const SIDEBAR_EXCLUDED_PAGES = ['index.html', 'login.html', 'register.html', 'callback.html'];
|
||||
|
||||
// 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 : '';
|
||||
}
|
||||
|
||||
function renderSidebar(activeKey) {
|
||||
// Accept either #appSidebar or any element carrying .app-sidebar, so pages
|
||||
// that hardcoded their own <aside> still get taken over.
|
||||
const container =
|
||||
document.getElementById('appSidebar') ||
|
||||
document.querySelector('.app-sidebar');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const key = activeKey || sidebarKeyFromUrl();
|
||||
const collapsed = localStorage.getItem('cip_sidebar_collapsed') === '1';
|
||||
|
||||
container.innerHTML = `
|
||||
<button class="sidebar-toggle" id="sidebarToggle" type="button"
|
||||
title="Collapse / expand menu" aria-label="Toggle navigation">☰</button>
|
||||
<nav class="sidebar-nav">
|
||||
${SIDEBAR_ITEMS.map(i => `
|
||||
<a href="${i.href}" class="sidebar-link ${i.key === key ? 'active' : ''}" title="${i.label}">
|
||||
<span class="sidebar-icon">${i.icon}</span><span class="sidebar-text">${i.label}</span>
|
||||
</a>`).join('')}
|
||||
</nav>
|
||||
`;
|
||||
|
||||
container.dataset.sidebarRendered = '1';
|
||||
applySidebarState(collapsed);
|
||||
|
||||
document.getElementById('sidebarToggle').addEventListener('click', () => {
|
||||
const nowCollapsed = !document.body.classList.contains('sidebar-collapsed');
|
||||
applySidebarState(nowCollapsed);
|
||||
localStorage.setItem('cip_sidebar_collapsed', nowCollapsed ? '1' : '0');
|
||||
});
|
||||
}
|
||||
|
||||
function applySidebarState(collapsed) {
|
||||
document.body.classList.toggle('sidebar-collapsed', collapsed);
|
||||
}
|
||||
|
||||
// Auto-run. If a page's own script calls renderSidebar() later with an
|
||||
// explicit key, that simply redraws with the same list — harmless.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const file = (window.location.pathname.split('/').pop() || '').toLowerCase();
|
||||
if (SIDEBAR_EXCLUDED_PAGES.includes(file)) return;
|
||||
renderSidebar();
|
||||
});
|
||||
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.
@ -1,504 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Patients — 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>
|
||||
/* The Add/Edit Patient form is long — scroll inside the dialog. */
|
||||
#pt_modal .modal-content { max-height: 90vh; }
|
||||
#pt_modal .modal-body { max-height: 70vh; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #1a1a1a #e3e9e9; }
|
||||
#pt_modal .modal-body::-webkit-scrollbar { width: 12px; }
|
||||
#pt_modal .modal-body::-webkit-scrollbar-track { background: #e3e9e9; border-radius: 6px; }
|
||||
#pt_modal .modal-body::-webkit-scrollbar-thumb { background: #1a1a1a; border-radius: 6px; border: 2px solid #e3e9e9; }
|
||||
|
||||
/* Patient cards — inlined so the layout never depends on css/styles.css
|
||||
being fresh (the avatars previously rendered as full-width bars because
|
||||
.rp-avatar had no size). */
|
||||
.rp-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.rp-card {
|
||||
background: #fff; border: 1px solid #e3e9e9; border-radius: 14px;
|
||||
padding: 1.4rem 1rem 1.2rem;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
text-align: center; gap: .7rem;
|
||||
box-shadow: 0 1px 3px rgba(16,40,40,.06);
|
||||
transition: box-shadow .12s ease, transform .12s ease;
|
||||
}
|
||||
.rp-card:hover { box-shadow: 0 6px 18px rgba(16,40,40,.12); transform: translateY(-2px); }
|
||||
.rp-avatar {
|
||||
width: 64px; height: 64px; min-width: 64px; min-height: 64px; flex: 0 0 64px;
|
||||
border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 700; font-size: 1.15rem; letter-spacing: .5px; line-height: 1;
|
||||
}
|
||||
.rp-card-name { font-weight: 600; font-size: 1rem; color: #1f2d2d; line-height: 1.25; word-break: break-word; }
|
||||
.rp-card-badge {
|
||||
font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .4px;
|
||||
border-radius: 999px; padding: .2rem .7rem; background: #eef3f3; color: #5f7373;
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.rp-card-sub { font-size: .78rem; color: #5f7373; }
|
||||
.rp-card-actions { display: flex; gap: .35rem; flex-wrap: wrap; justify-content: center; }
|
||||
.rp-card-view {
|
||||
display: inline-block; font-size: .85rem; padding: .3rem 1rem;
|
||||
border: 1px solid #0f6e6e; color: #0f6e6e; background: #fff;
|
||||
border-radius: 8px; cursor: pointer; text-decoration: none; line-height: 1.4;
|
||||
}
|
||||
.rp-card-view:hover { background: #0f6e6e; color: #fff !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-ci">
|
||||
<div class="container-fluid px-4">
|
||||
<a class="navbar-brand" href="dashboard.html">🩺 Clinical Insight Pro</a>
|
||||
<div class="ms-auto d-flex align-items-center gap-3">
|
||||
<span class="navbar-text" id="clinicianLabel">Clinician</span>
|
||||
<button class="btn btn-outline-light btn-sm" id="logoutBtn">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-shell">
|
||||
<aside class="app-sidebar" id="appSidebar"></aside>
|
||||
<main class="app-main">
|
||||
<div class="resource-page-header">
|
||||
<h4>🧑⚕️ Patients</h4>
|
||||
<span class="text-muted-ci small" id="pt_countLabel"></span>
|
||||
</div>
|
||||
|
||||
<div class="card-ci p-3 mb-3 d-flex flex-row align-items-center gap-3 flex-wrap">
|
||||
<input type="text" class="form-control resource-search-bar" id="pt_searchInput" placeholder="Search by name or patient ID…" autocomplete="off">
|
||||
<button class="btn btn-ci-primary ms-auto" id="pt_addBtn">+ Add Patient</button>
|
||||
</div>
|
||||
|
||||
<div class="card-ci p-3">
|
||||
<div class="rp-card-grid" id="pt_cardGrid"></div>
|
||||
<div class="table-responsive d-none">
|
||||
<table class="table table-sm resource-grid-table align-middle mb-0">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Age</th><th>Gender</th><th>MRN</th><th>Patient ID</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="pt_gridBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pt_gridEmpty" class="text-muted-ci text-center py-4 d-none">No data found.</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toastContainer"></div>
|
||||
|
||||
<div class="modal fade" id="pt_modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="pt_modalTitle">
|
||||
Add Patient
|
||||
</h5>
|
||||
<button type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="pt_form">
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">
|
||||
Medical Record Number
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="pt_mrn"
|
||||
placeholder="Leave blank — continues MRN-IN-100006, 100007, …">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Blood Group</label>
|
||||
<select class="form-select" id="pt_bloodGroup">
|
||||
<option value="">—</option>
|
||||
<option>A+</option><option>A-</option>
|
||||
<option>B+</option><option>B-</option>
|
||||
<option>AB+</option><option>AB-</option>
|
||||
<option>O+</option><option>O-</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email"
|
||||
class="form-control"
|
||||
id="pt_email"
|
||||
placeholder="name@example.com">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">
|
||||
Given Name
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="pt_givenName"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">
|
||||
Family Name
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="pt_familyName"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">
|
||||
Birth Date
|
||||
</label>
|
||||
<input type="date"
|
||||
class="form-control"
|
||||
id="pt_birthDate">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">
|
||||
Gender
|
||||
</label>
|
||||
<select class="form-select"
|
||||
id="pt_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">
|
||||
Phone Number
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="pt_phone">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">
|
||||
City
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="pt_city">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">
|
||||
State
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="pt_state">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">
|
||||
Postal Code
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="pt_postalCode">
|
||||
</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">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/sidebar.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script>
|
||||
let ptCache = [];
|
||||
let ptSearchTerm = '';
|
||||
let ptModal, ptEditingId = null;
|
||||
|
||||
function ptEscapeHtml(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
function ptShowToast(message, variant = 'success') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast align-items-center text-bg-${variant === 'danger' ? 'danger' : 'success'} border-0 show mb-2`;
|
||||
el.innerHTML = `<div class="d-flex"><div class="toast-body">${ptEscapeHtml(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 ptFiltered() {
|
||||
const term = ptSearchTerm.trim().toLowerCase();
|
||||
if (!term) return ptCache;
|
||||
return ptCache.filter(p => {
|
||||
const fullName = (p.fullName || '').toLowerCase();
|
||||
const parts = fullName.split(' ').filter(Boolean);
|
||||
const nameMatch = fullName.startsWith(term) || parts.some(x => x.startsWith(term));
|
||||
const idTerm = term.replace(/^pt-/, '');
|
||||
const mrnTerm = term.replace(/^mrn-/, '');
|
||||
const idMatch = (p.id || '').toLowerCase().replace(/^pt-/, '').includes(idTerm);
|
||||
const mrnMatch = (p.medicalRecordNumber || '').toLowerCase().replace(/^mrn-/, '').includes(mrnTerm);
|
||||
return nameMatch || idMatch || mrnMatch;
|
||||
});
|
||||
}
|
||||
|
||||
async function ptLoadGrid() {
|
||||
try {
|
||||
ptCache = await Api.getPatients();
|
||||
} catch (err) {
|
||||
ptShowToast(err.message, 'danger');
|
||||
return;
|
||||
}
|
||||
const rows = ptFiltered();
|
||||
document.getElementById('pt_countLabel').textContent = `${rows.length} of ${ptCache.length} patient${ptCache.length === 1 ? '' : 's'}`;
|
||||
|
||||
const body = document.getElementById('pt_gridBody');
|
||||
const empty = document.getElementById('pt_gridEmpty');
|
||||
if (rows.length === 0) {
|
||||
body.innerHTML = '';
|
||||
empty.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('d-none');
|
||||
|
||||
// Avatar card per patient, matching the other resource pages.
|
||||
const grid = document.getElementById('pt_cardGrid');
|
||||
grid.innerHTML = rows.map(p => {
|
||||
const colour = ptAvatarColour(p.fullName);
|
||||
return `
|
||||
<div class="rp-card">
|
||||
<div class="rp-avatar" style="background:${colour}">${ptEscapeHtml(ptInitials(p.fullName))}</div>
|
||||
<div class="rp-card-name" title="${ptEscapeHtml(p.fullName)}">${ptEscapeHtml(p.fullName)}</div>
|
||||
<div class="rp-card-badge">${p.age} YRS · ${ptEscapeHtml(p.gender).toUpperCase()}</div>
|
||||
<div class="rp-card-sub">${ptEscapeHtml(p.medicalRecordNumber)}</div>
|
||||
<div class="rp-card-sub">${ptEscapeHtml(p.id)}</div>
|
||||
<a class="rp-card-view" style="color:${colour};border-color:${colour}"
|
||||
href="dashboard.html?patient=${encodeURIComponent(p.id)}">View chart</a>
|
||||
<div class="rp-card-actions">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-edit="${p.id}">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" data-delete="${p.id}">Delete</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
grid._ptRows = rows;
|
||||
body.innerHTML = '';
|
||||
}
|
||||
|
||||
const PT_AVATAR_COLOURS = ['#2e86ab','#1e8449','#d68910','#7d3c98','#c0392b','#117864','#a04000','#5499c7','#b7950b','#34495e'];
|
||||
|
||||
function ptInitials(name) {
|
||||
return (name || '').split(' ').filter(Boolean)
|
||||
.filter(part => !/^(dr|mr|mrs|ms|miss)\.?$/i.test(part))
|
||||
.slice(0, 2).map(p => p[0].toUpperCase()).join('') || '?';
|
||||
}
|
||||
|
||||
function ptAvatarColour(name) {
|
||||
let hash = 0;
|
||||
for (const ch of (name || '')) hash = (hash * 31 + ch.charCodeAt(0)) % 100000;
|
||||
return PT_AVATAR_COLOURS[hash % PT_AVATAR_COLOURS.length];
|
||||
}
|
||||
|
||||
function ptOpenAdd() {
|
||||
ptEditingId = null;
|
||||
document.getElementById('pt_modalTitle').textContent = 'Add Patient';
|
||||
document.getElementById('pt_form').reset();
|
||||
ptModal.show();
|
||||
}
|
||||
|
||||
async function ptOpenEdit(row) {
|
||||
|
||||
try {
|
||||
|
||||
const p = await Api.getPatientById(row.id);
|
||||
|
||||
ptEditingId = p.id;
|
||||
|
||||
document.getElementById('pt_modalTitle').textContent =
|
||||
`Edit - ${p.givenName || ''} ${p.familyName || ''}`;
|
||||
|
||||
document.getElementById('pt_mrn').value =
|
||||
p.medicalRecordNumber || '';
|
||||
|
||||
document.getElementById('pt_givenName').value =
|
||||
p.givenName || '';
|
||||
|
||||
document.getElementById('pt_familyName').value =
|
||||
p.familyName || '';
|
||||
|
||||
document.getElementById('pt_birthDate').value =
|
||||
p.birthDate
|
||||
? new Date(p.birthDate).toISOString().split('T')[0]
|
||||
: '';
|
||||
|
||||
document.getElementById('pt_gender').value =
|
||||
p.gender || 'unknown';
|
||||
|
||||
document.getElementById('pt_phone').value =
|
||||
p.telecomValue || '';
|
||||
|
||||
document.getElementById('pt_city').value =
|
||||
p.city || '';
|
||||
|
||||
document.getElementById('pt_state').value =
|
||||
p.state || '';
|
||||
|
||||
document.getElementById('pt_bloodGroup').value =
|
||||
p.bloodGroup || '';
|
||||
|
||||
document.getElementById('pt_email').value =
|
||||
p.email || '';
|
||||
|
||||
document.getElementById('pt_postalCode').value =
|
||||
p.postalCode || '';
|
||||
|
||||
ptModal.show();
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
|
||||
ptShowToast(err.message, 'danger');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
async function ptSubmit(e) {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
medicalRecordNumber:
|
||||
document.getElementById('pt_mrn').value,
|
||||
|
||||
givenName:
|
||||
document.getElementById('pt_givenName').value,
|
||||
|
||||
familyName:
|
||||
document.getElementById('pt_familyName').value,
|
||||
|
||||
birthDate:
|
||||
document.getElementById('pt_birthDate').value,
|
||||
|
||||
gender:
|
||||
document.getElementById('pt_gender').value,
|
||||
|
||||
telecomValue:
|
||||
document.getElementById('pt_phone').value,
|
||||
|
||||
city:
|
||||
document.getElementById('pt_city').value,
|
||||
|
||||
state:
|
||||
document.getElementById('pt_state').value,
|
||||
|
||||
postalCode:
|
||||
document.getElementById('pt_postalCode').value,
|
||||
|
||||
bloodGroup:
|
||||
document.getElementById('pt_bloodGroup').value || null,
|
||||
|
||||
email:
|
||||
document.getElementById('pt_email').value || null
|
||||
};
|
||||
try {
|
||||
if (ptEditingId) {
|
||||
if (!payload.birthDate) {
|
||||
ptShowToast('Date of birth is required to save changes.', 'danger');
|
||||
return;
|
||||
}
|
||||
await Api.updatePatient(ptEditingId, payload);
|
||||
ptShowToast('Patient updated.');
|
||||
} else {
|
||||
await Api.createPatient(payload);
|
||||
ptShowToast('Patient created.');
|
||||
}
|
||||
ptModal.hide();
|
||||
await ptLoadGrid();
|
||||
} catch (err) {
|
||||
ptShowToast(err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function ptDelete(p) {
|
||||
if (!confirm(`Delete patient ${p.fullName} and all their chart data?`)) return;
|
||||
try {
|
||||
await Api.deletePatient(p.id);
|
||||
ptShowToast('Patient deleted.');
|
||||
await ptLoadGrid();
|
||||
} catch (err) {
|
||||
ptShowToast(err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!sessionStorage.getItem('cip_token')) { window.location.href = 'login.html'; return; }
|
||||
|
||||
renderSidebar('patients');
|
||||
document.getElementById('clinicianLabel').textContent = `👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
|
||||
document.getElementById('logoutBtn').addEventListener('click', () => { sessionStorage.clear(); window.location.href = 'login.html'; });
|
||||
|
||||
ptModal = new bootstrap.Modal(document.getElementById('pt_modal'));
|
||||
document.getElementById('pt_addBtn').addEventListener('click', ptOpenAdd);
|
||||
document.getElementById('pt_form').addEventListener('submit', ptSubmit);
|
||||
|
||||
document.getElementById('pt_searchInput').addEventListener('input', (e) => {
|
||||
ptSearchTerm = e.target.value;
|
||||
ptLoadGrid();
|
||||
});
|
||||
|
||||
document.getElementById('pt_cardGrid').addEventListener('click', (e) => {
|
||||
const editBtn = e.target.closest('[data-edit]');
|
||||
const delBtn = e.target.closest('[data-delete]');
|
||||
// Rows live on the card grid now; the old table body is left empty by
|
||||
// the card renderer, so reading _ptRows from it always gave [].
|
||||
const rows = document.getElementById('pt_cardGrid')._ptRows || [];
|
||||
if (editBtn) {
|
||||
const p = rows.find(r => r.id === editBtn.dataset.edit);
|
||||
if (p) ptOpenEdit(p);
|
||||
} else if (delBtn) {
|
||||
const p = rows.find(r => r.id === delBtn.dataset.delete);
|
||||
if (p) ptDelete(p);
|
||||
}
|
||||
});
|
||||
|
||||
ptLoadGrid();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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>
|
||||
@ -152,7 +152,7 @@ async function rpOpenAddModal() {
|
||||
// Resources that don't hang off a patient (organizations, locations,
|
||||
// practitioners, CDS rules, users) shouldn't show a patient dropdown at all.
|
||||
document.getElementById('rp_patientPickerRow')
|
||||
.classList.toggle('d-none', !!config.standalone);
|
||||
.classList.toggle('d-none', rpIsStandalone(config));
|
||||
document.getElementById('rp_fieldsBody').innerHTML = config.fields
|
||||
.filter(f => !RP_AUTO_FIELDS.includes(f.key))
|
||||
.filter(f => !(config.key === 'riskassessments' && f.key === 'noteText'))
|
||||
@ -414,7 +414,7 @@ if (config.key === 'careplans') {
|
||||
// Identifiers are set on create only; editing keeps the original.
|
||||
if (!rpEditingRowId) {
|
||||
const picker = document.getElementById('rp_patientSelect');
|
||||
const pid = (!config.standalone && picker) ? picker.value : null;
|
||||
const pid = (!rpIsStandalone(config) && picker) ? picker.value : null;
|
||||
const ids = rpBuildIdentifiers(config, pid);
|
||||
|
||||
if (config.fields.some(f => f.key === 'identifierSystem')) {
|
||||
@ -463,7 +463,7 @@ if (config.key === 'careplans') {
|
||||
];
|
||||
|
||||
if (
|
||||
!config.standalone &&
|
||||
!rpIsStandalone(config) &&
|
||||
!standaloneResources.includes(config.key) &&
|
||||
!patientId
|
||||
)
|
||||
@ -966,17 +966,47 @@ function rpBuildIdentifiers(config, patientId) {
|
||||
return { identifierSystem: system, identifierValue: value };
|
||||
}
|
||||
|
||||
// Resources that don't belong to a patient. Their pages don't set
|
||||
// `standalone: true` in RESOURCE_PAGE_CONFIG, so the key list is what makes
|
||||
// them render one card per record instead of one lumped "unassigned" card.
|
||||
const RP_STANDALONE_KEYS = [
|
||||
'organizations',
|
||||
'locations',
|
||||
'practitioners',
|
||||
'practitionerroles',
|
||||
'cdsrules',
|
||||
'users'
|
||||
];
|
||||
|
||||
function rpIsStandalone(config) {
|
||||
return config.standalone === true || RP_STANDALONE_KEYS.includes(config.key);
|
||||
}
|
||||
|
||||
// What to put on the card for a standalone record — the first column alone
|
||||
// is often not the useful label (a practitioner's card should read
|
||||
// "Dr. Meera Joshi", not just "Meera").
|
||||
const RP_CARD_TITLE = {
|
||||
practitioners: r => [r.prefix, r.givenName, r.familyName].filter(Boolean).join(' '),
|
||||
practitionerroles: r => r.practitionerName || r.roleDisplay || r.id,
|
||||
organizations: r => r.name,
|
||||
locations: r => r.name,
|
||||
cdsrules: r => r.ruleId,
|
||||
users: r => [r.firstName, r.lastName].filter(Boolean).join(' ') || r.username
|
||||
};
|
||||
|
||||
let rpGroups = {};
|
||||
|
||||
function rpBuildGroups(rows) {
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
const groups = {};
|
||||
|
||||
if (config.standalone) {
|
||||
// One card per record; the title is the first column's value.
|
||||
const titleKey = config.columns[0].key;
|
||||
if (rpIsStandalone(config)) {
|
||||
// One card per record.
|
||||
const titleFn = RP_CARD_TITLE[config.key]
|
||||
|| (r => String(r[config.columns[0].key] ?? '—'));
|
||||
|
||||
rows.forEach(r => {
|
||||
groups[r.id] = { title: String(r[titleKey] ?? '—'), rows: [r] };
|
||||
groups[r.id] = { title: titleFn(r) || '—', rows: [r] };
|
||||
});
|
||||
} else {
|
||||
rows.forEach(r => {
|
||||
@ -1002,7 +1032,7 @@ async function rpLoadGrid() {
|
||||
const keys = Object.keys(rpGroups);
|
||||
|
||||
if (countLabel) {
|
||||
countLabel.textContent = config.standalone
|
||||
countLabel.textContent = rpIsStandalone(config)
|
||||
? `${rows.length} record${rows.length === 1 ? '' : 's'}`
|
||||
: `${rows.length} record${rows.length === 1 ? '' : 's'} · ${keys.length} patient${keys.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
@ -1187,7 +1217,7 @@ function rpInit() {
|
||||
rpAddModal = new bootstrap.Modal(document.getElementById('rp_modal'));
|
||||
document.getElementById('rp_addBtn').addEventListener('click', rpOpenAddModal);
|
||||
document.getElementById('rp_form').addEventListener('submit', rpSubmitModal);
|
||||
if (!config.standalone) rpLoadPatientsForPicker();
|
||||
if (!rpIsStandalone(config)) rpLoadPatientsForPicker();
|
||||
|
||||
// Edit / Delete now live inside the detail modal.
|
||||
document.getElementById('rp_detailBody').addEventListener('click', (e) => {
|
||||
@ -152,14 +152,17 @@ async function rpOpenAddModal() {
|
||||
// Resources that don't hang off a patient (organizations, locations,
|
||||
// practitioners, CDS rules, users) shouldn't show a patient dropdown at all.
|
||||
document.getElementById('rp_patientPickerRow')
|
||||
.classList.toggle('d-none', !!config.standalone);
|
||||
.classList.toggle('d-none', rpIsStandalone(config));
|
||||
document.getElementById('rp_fieldsBody').innerHTML = config.fields
|
||||
.filter(f => !RP_AUTO_FIELDS.includes(f.key))
|
||||
.filter(f => !(config.key === 'riskassessments' && f.key === 'noteText'))
|
||||
.map(f => rpFieldInputHtml(f, null)).join('')
|
||||
+ (config.key === 'riskassessments' ? rpRiskFactorsHtml() : '');
|
||||
|
||||
if (config.key === 'riskassessments') rpWireRiskForm();
|
||||
if (config.key === 'riskassessments') {
|
||||
rpRiskPatientId = null; // Add mode follows the dropdown
|
||||
rpWireRiskForm();
|
||||
}
|
||||
|
||||
if (config.key === 'practitionerroles') {
|
||||
await rpLoadPractitionerRoleDropdowns();
|
||||
@ -173,6 +176,9 @@ function rpWireRiskForm() {
|
||||
document.querySelectorAll('.rp-risk-check')
|
||||
.forEach(el => el.addEventListener('change', rpUpdateRiskPreview));
|
||||
|
||||
document.getElementById('rp_riskFreeText')
|
||||
?.addEventListener('input', rpUpdateRiskPreview);
|
||||
|
||||
const picker = document.getElementById('rp_patientSelect');
|
||||
if (picker && !picker.dataset.riskWired) {
|
||||
picker.dataset.riskWired = '1';
|
||||
@ -209,6 +215,8 @@ function rpOpenEditModal(row) {
|
||||
`Edit ${config.title.replace(/s$/, '')}`;
|
||||
rpRenderFormIntro(config, 'edit');
|
||||
document.getElementById('rp_patientPickerRow').classList.add('d-none');
|
||||
if (config.key === 'riskassessments') rpRiskPatientId = row.patientId;
|
||||
|
||||
const split = config.key === 'riskassessments'
|
||||
? rpSplitRiskNote(row.noteText)
|
||||
: null;
|
||||
@ -406,7 +414,7 @@ if (config.key === 'careplans') {
|
||||
// Identifiers are set on create only; editing keeps the original.
|
||||
if (!rpEditingRowId) {
|
||||
const picker = document.getElementById('rp_patientSelect');
|
||||
const pid = (!config.standalone && picker) ? picker.value : null;
|
||||
const pid = (!rpIsStandalone(config) && picker) ? picker.value : null;
|
||||
const ids = rpBuildIdentifiers(config, pid);
|
||||
|
||||
if (config.fields.some(f => f.key === 'identifierSystem')) {
|
||||
@ -455,7 +463,7 @@ if (config.key === 'careplans') {
|
||||
];
|
||||
|
||||
if (
|
||||
!config.standalone &&
|
||||
!rpIsStandalone(config) &&
|
||||
!standaloneResources.includes(config.key) &&
|
||||
!patientId
|
||||
)
|
||||
@ -658,6 +666,11 @@ const RP_CARD_STYLES = `
|
||||
background: #eef3f3; border-radius: 999px; padding: .1rem .45rem;
|
||||
}
|
||||
.rp-risk-item input:checked ~ .rp-risk-weight { background: #d9ece4; color: #1e8449; }
|
||||
.rp-risk-hint { font-size: .76rem; color: #5f7373; margin-top: .3rem; line-height: 1.45; }
|
||||
.rp-risk-hint code {
|
||||
background: #f2f8f8; border: 1px solid #dfe7e7; border-radius: 4px;
|
||||
padding: .02rem .25rem; color: #0f6e6e;
|
||||
}
|
||||
|
||||
.rp-form-intro-body code {
|
||||
background: #fff; border: 1px solid #dfe7e7; border-radius: 5px;
|
||||
@ -793,10 +806,16 @@ const RP_RISK_FACTORS = [
|
||||
|
||||
// Gender of whoever is picked in the patient dropdown, so the reproductive
|
||||
// questions only appear for female patients — same rule as the dashboard.
|
||||
// Whose gender gates the reproductive questions. On Add that's the dropdown;
|
||||
// on Edit the dropdown is hidden and stale, so the row's own patient wins.
|
||||
let rpRiskPatientId = null;
|
||||
|
||||
function rpSelectedGender() {
|
||||
const select = document.getElementById('rp_patientSelect');
|
||||
if (!select) return '';
|
||||
const patient = rpPatientsCache.find(p => p.id === select.value);
|
||||
const id = rpRiskPatientId
|
||||
|| document.getElementById('rp_patientSelect')?.value
|
||||
|| null;
|
||||
if (!id) return '';
|
||||
const patient = rpPatientsCache.find(p => p.id === id);
|
||||
return (patient?.gender || '').toLowerCase();
|
||||
}
|
||||
|
||||
@ -834,9 +853,13 @@ function rpRiskFactorsHtml(checkedLabels = [], freeText = '') {
|
||||
}).join('')}
|
||||
</div>`).join('')}
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="rp_riskFreeText">Additional note (optional)</label>
|
||||
<label class="form-label" for="rp_riskFreeText">Additional factors & notes</label>
|
||||
<textarea class="form-control" rows="2" id="rp_riskFreeText"
|
||||
placeholder="Anything not covered by the boxes above">${rpEscapeHtml(freeText)}</textarea>
|
||||
placeholder="e.g. 3 active condition(s) on chart (+15)">${rpEscapeHtml(freeText)}</textarea>
|
||||
<div class="rp-risk-hint">
|
||||
Factors the rules engine derived from the chart land here. Any
|
||||
<code>(+n)</code> weight in this box is counted in the score above.
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@ -848,7 +871,17 @@ function rpUpdateRiskPreview() {
|
||||
let score = 0;
|
||||
document.querySelectorAll('.rp-risk-check:checked')
|
||||
.forEach(el => { score += Number(el.dataset.weight || 0); });
|
||||
score = Math.min(score, 100);
|
||||
|
||||
// Weights can also sit in the free-text box — the rules engine adds factors
|
||||
// the form has no box for, such as "3 active condition(s) on chart (+15)".
|
||||
// Counting them keeps the preview equal to what the grid will show.
|
||||
const extra = document.getElementById('rp_riskFreeText')?.value || '';
|
||||
extra.split(';').forEach(part => {
|
||||
const m = part.match(/\(\+(\d+(?:\.\d+)?)\)/);
|
||||
if (m) score += parseFloat(m[1]);
|
||||
});
|
||||
|
||||
score = Math.min(Math.round(score), 100);
|
||||
|
||||
const level = rpRiskLevel(score);
|
||||
chip.textContent = `${score}%`;
|
||||
@ -933,17 +966,58 @@ function rpBuildIdentifiers(config, patientId) {
|
||||
return { identifierSystem: system, identifierValue: value };
|
||||
}
|
||||
|
||||
// Resources that don't belong to a patient. Their pages don't set
|
||||
// `standalone: true` in RESOURCE_PAGE_CONFIG, so the key list is what makes
|
||||
// them render one card per record instead of one lumped "unassigned" card.
|
||||
const RP_STANDALONE_KEYS = [
|
||||
'organizations',
|
||||
'locations',
|
||||
'practitioners',
|
||||
'practitionerroles',
|
||||
'cdsrules',
|
||||
'users'
|
||||
];
|
||||
|
||||
function rpIsStandalone(config, rows) {
|
||||
if (config.standalone === true) return true;
|
||||
if (RP_STANDALONE_KEYS.includes(config.key)) return true;
|
||||
|
||||
// Data-driven fallback: no row has a patientId => nothing to group by.
|
||||
const sample = Array.isArray(rows) ? rows : rpAllRows;
|
||||
if (Array.isArray(sample) && sample.length > 0) {
|
||||
return !sample.some(r => r && r.patientId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// What to put on the card for a standalone record — the first column alone
|
||||
// is often not the useful label (a practitioner's card should read
|
||||
// "Dr. Meera Joshi", not just "Meera").
|
||||
const RP_CARD_TITLE = {
|
||||
practitioners: r => [r.prefix, r.givenName, r.familyName].filter(Boolean).join(' '),
|
||||
practitionerroles: r => r.practitionerName || r.roleDisplay || r.id,
|
||||
organizations: r => r.name,
|
||||
locations: r => r.name,
|
||||
cdsrules: r => r.ruleId,
|
||||
users: r => [r.firstName, r.lastName].filter(Boolean).join(' ') || r.username
|
||||
};
|
||||
|
||||
let rpGroups = {};
|
||||
|
||||
function rpBuildGroups(rows) {
|
||||
const config = window.RESOURCE_PAGE_CONFIG;
|
||||
const groups = {};
|
||||
|
||||
if (config.standalone) {
|
||||
// One card per record; the title is the first column's value.
|
||||
const titleKey = config.columns[0].key;
|
||||
rows.forEach(r => {
|
||||
groups[r.id] = { title: String(r[titleKey] ?? '—'), rows: [r] };
|
||||
if (rpIsStandalone(config, rows)) {
|
||||
// One card per record.
|
||||
const titleFn = RP_CARD_TITLE[config.key]
|
||||
|| (r => String(r[config.columns[0].key] ?? '—'));
|
||||
|
||||
rows.forEach((r, i) => {
|
||||
// Fall back to the index if the API didn't send an id — otherwise every
|
||||
// record would collide on groups[undefined] and you'd see one card.
|
||||
const key = (r.id ?? r.Id ?? `row-${i}`).toString();
|
||||
groups[key] = { title: titleFn(r) || '—', rows: [r] };
|
||||
});
|
||||
} else {
|
||||
rows.forEach(r => {
|
||||
@ -969,7 +1043,7 @@ async function rpLoadGrid() {
|
||||
const keys = Object.keys(rpGroups);
|
||||
|
||||
if (countLabel) {
|
||||
countLabel.textContent = config.standalone
|
||||
countLabel.textContent = rpIsStandalone(config)
|
||||
? `${rows.length} record${rows.length === 1 ? '' : 's'}`
|
||||
: `${rows.length} record${rows.length === 1 ? '' : 's'} · ${keys.length} patient${keys.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
@ -1154,7 +1228,7 @@ function rpInit() {
|
||||
rpAddModal = new bootstrap.Modal(document.getElementById('rp_modal'));
|
||||
document.getElementById('rp_addBtn').addEventListener('click', rpOpenAddModal);
|
||||
document.getElementById('rp_form').addEventListener('submit', rpSubmitModal);
|
||||
if (!config.standalone) rpLoadPatientsForPicker();
|
||||
if (!rpIsStandalone(config)) rpLoadPatientsForPicker();
|
||||
|
||||
// Edit / Delete now live inside the detail modal.
|
||||
document.getElementById('rp_detailBody').addEventListener('click', (e) => {
|
||||
@ -1,126 +0,0 @@
|
||||
// Single source of truth for the left navigation on EVERY page.
|
||||
//
|
||||
// Two problems this file solves:
|
||||
//
|
||||
// 1. Pages that never call renderSidebar() — it self-initialises on
|
||||
// DOMContentLoaded, so just including the script is enough.
|
||||
//
|
||||
// 2. Pages with their own custom script that renders a DIFFERENT (shorter)
|
||||
// menu — a MutationObserver watches the container and re-asserts the full
|
||||
// list whenever something replaces it. This is why CDS Alerts and
|
||||
// Observations were showing only 10 items while the Dashboard showed all
|
||||
// of them: those pages build their own nav after this script has run.
|
||||
//
|
||||
// Adding a nav item? Add it to SIDEBAR_ITEMS and it appears everywhere.
|
||||
|
||||
const SIDEBAR_ITEMS = [
|
||||
{ key: 'dashboard', href: 'dashboard.html', icon: '🏠', label: 'Dashboard' },
|
||||
{ key: 'patients', href: 'patients.html', icon: '🧑\u200d⚕️', label: 'Patients' },
|
||||
{ key: 'conditions', href: 'conditions.html', icon: '🩺', label: 'Conditions' },
|
||||
{ key: 'observations', href: 'observations.html', icon: '📈', label: 'Observations' },
|
||||
{ key: 'allergies', href: 'allergies.html', icon: '⚠️', label: 'Allergies' },
|
||||
{ key: 'medications', href: 'medications.html', icon: '💊', label: 'Medications' },
|
||||
{ key: 'encounters', href: 'encounters.html', icon: '📅', label: 'Encounters' },
|
||||
{ key: 'organizations', href: 'organizations.html', icon: '🏥', label: 'Organizations' },
|
||||
{ key: 'locations', href: 'locations.html', icon: '📍', label: 'Locations' },
|
||||
{ key: 'practitioners', href: 'practitioners.html', icon: '👨⚕️', label: 'Practitioners' },
|
||||
{ key: 'practitionerroles', href: 'practitionerroles.html', icon: '🩹', label: 'Practitioner Roles' },
|
||||
{ key: 'servicerequests', href: 'servicerequests.html', icon: '📄', label: 'Service Requests' },
|
||||
{ key: 'riskassessments', href: 'riskassessments.html', icon: '📊', label: 'Risk Assessment' },
|
||||
{ key: 'cdsalerts', href: 'cdsalerts.html', icon: '🔔', label: 'CDS Alerts' },
|
||||
{ key: 'careplans', href: 'careplans.html', icon: '📋', label: 'Care Plans' },
|
||||
{ key: 'cdsrules', href: 'cdsrules.html', icon: '⚙️', label: 'CDS Rules' },
|
||||
{ key: 'users', href: 'users.html', icon: '👤', label: 'Users' }
|
||||
];
|
||||
|
||||
// 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 : '';
|
||||
}
|
||||
|
||||
function renderSidebar(activeKey) {
|
||||
const container = sidebarContainer();
|
||||
if (!container) return;
|
||||
|
||||
const key = activeKey || sidebarKeyFromUrl();
|
||||
const collapsed = localStorage.getItem('cip_sidebar_collapsed') === '1';
|
||||
|
||||
sidebarWriting = true;
|
||||
|
||||
container.innerHTML = `
|
||||
<button class="sidebar-toggle" id="sidebarToggle" type="button"
|
||||
title="Collapse / expand menu" aria-label="Toggle navigation">☰</button>
|
||||
<nav class="sidebar-nav">
|
||||
${SIDEBAR_ITEMS.map(i => `
|
||||
<a href="${i.href}" class="sidebar-link ${i.key === key ? 'active' : ''}" title="${i.label}">
|
||||
<span class="sidebar-icon">${i.icon}</span><span class="sidebar-text">${i.label}</span>
|
||||
</a>`).join('')}
|
||||
</nav>
|
||||
`;
|
||||
|
||||
applySidebarState(collapsed);
|
||||
|
||||
document.getElementById('sidebarToggle').addEventListener('click', () => {
|
||||
const nowCollapsed = !document.body.classList.contains('sidebar-collapsed');
|
||||
applySidebarState(nowCollapsed);
|
||||
localStorage.setItem('cip_sidebar_collapsed', nowCollapsed ? '1' : '0');
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
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; }
|
||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user