Compare commits

...

25 Commits

Author SHA1 Message Date
af93c8fa82 Upload files to "Code " 2026-08-31 14:01:00 +00:00
2aeb78f507 Upload files to ".Net Capstone Project" 2026-08-29 13:52:46 +00:00
ce2f512207 Upload files to ".Net Capstone Project" 2026-08-29 13:47:30 +00:00
9d476e9a9b Upload files to ".Net Capstone Project" 2026-08-29 13:42:29 +00:00
6c0b37042a Upload files to ".Net Capstone Project" 2026-08-29 13:34:43 +00:00
54d93213ed Upload files to ".Net Capstone Project" 2026-08-29 13:26:36 +00:00
3d2db1065f Upload files to ".Net Capstone Project" 2026-08-29 13:21:24 +00:00
ad7dfc309d Upload files to ".Net Capstone Project" 2026-08-29 13:07:46 +00:00
bfdad77519 Upload files to ".Net Capstone Project" 2026-08-29 13:02:58 +00:00
529b00fe82 Upload files to ".Net Capstone Project" 2026-08-29 12:55:37 +00:00
a941f3f3b1 Upload files to ".Net Capstone Project" 2026-08-29 12:50:27 +00:00
6011cc8a44 Upload files to ".Net Capstone Project" 2026-08-29 12:41:45 +00:00
c7fb4c52be Upload files to ".Net Capstone Project" 2026-08-29 12:37:44 +00:00
a7a3c21c9f Upload files to ".Net Capstone Project" 2026-08-29 12:32:31 +00:00
9fb80aaf11 Upload files to ".Net Capstone Project" 2026-08-29 12:22:57 +00:00
938412c349 Upload files to ".Net Capstone Project" 2026-08-29 12:12:43 +00:00
0095700d9b Upload files to ".Net Capstone Project" 2026-08-29 12:04:50 +00:00
5898469243 Upload files to ".Net Capstone Project" 2026-08-29 11:57:34 +00:00
974c4115f7 [UpdatedfileNew29082026.zip](/attachments/c871c5f1-f577-4fa5-8e12-19d3fcc4a521) 2026-08-29 11:48:26 +00:00
1783fbb5e7 Upload files to ".Net Capstone Project" 2026-08-29 11:19:16 +00:00
368ee47823 Upload files to ".Net Capstone Project" 2026-08-29 11:11:51 +00:00
eb0351c2c1 Dashbaord cahnges 2026-08-29 10:30:47 +00:00
51bb1b2409 Dashboard updated 29 2026-08-29 10:10:14 +00:00
e2446d4d90 Application files 2026-08-29 09:36:09 +00:00
09b19a4838 Seed 2026-08-29 07:56:43 +00:00
26 changed files with 6429 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,193 @@
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.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,87 @@
// 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();
});

View File

@ -0,0 +1,504 @@
<!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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
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>

View File

@ -0,0 +1,988 @@
// Generic engine for a "browse one resource type across all patients" page
// (Conditions, Observations, Allergies, Medications, Encounters). Each page
// just defines window.RESOURCE_PAGE_CONFIG and includes this script — no
// per-page JS needed. See conditions.html for the config shape.
let rpPatientsCache = [];
let rpSearchTerm = '';
let rpAddModal, rpDetailModal, rpEditingRowId = null, rpEditingPatientId = null;
function rpEscapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function rpFmtDate(value) {
if (!value) return '—';
const d = new Date(value);
if (isNaN(d)) return '—';
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function rpShowToast(message, variant = 'success') {
const container = document.getElementById('toastContainer');
if (!container) { alert(message); return; }
const el = document.createElement('div');
el.className = `toast align-items-center text-bg-${variant === 'danger' ? 'danger' : 'success'} border-0 show mb-2`;
el.innerHTML = `<div class="d-flex"><div class="toast-body">${rpEscapeHtml(message)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" onclick="this.closest('.toast').remove()"></button></div>`;
container.appendChild(el);
setTimeout(() => el.remove(), 5000);
}
function rpFieldInputHtml(field, value) {
const id = `rp_field_${field.key}`;
const val = value !== undefined && value !== null ? value : (field.default || '');
if (field.type === 'select') {
return `<div class="col-12">
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
<select class="form-select" id="${id}">
${field.options.map(o => `<option value="${o}" ${o === val ? 'selected' : ''}>${o}</option>`).join('')}
</select>
</div>`;
}
if (field.type === 'textarea') {
return `<div class="col-12">
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
<textarea class="form-control" rows="3" id="${id}" ${field.required ? 'required' : ''}>${rpEscapeHtml(val)}</textarea>
</div>`;
}
const inputVal = field.type === 'date' && val ? String(val).slice(0, 10)
: field.type === 'datetime-local' && val ? String(val).slice(0, 16)
: val;
return `<div class="col-12">
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
<input type="${field.type}" ${field.required ? 'required' : ''} class="form-control" id="${id}" value="${rpEscapeHtml(inputVal)}">
</div>`;
}
async function rpLoadPatientsForPicker() {
try {
rpPatientsCache = await Api.getPatients();
const select = document.getElementById('rp_patientSelect');
if (select) {
select.innerHTML = rpPatientsCache.map(p => `<option value="${p.id}">${rpEscapeHtml(p.fullName)} (${rpEscapeHtml(p.medicalRecordNumber)})</option>`).join('');
}
} catch { /* patient picker just won't populate; add flow will fail loudly instead */ }
}
async function rpLoadPractitionerRoleDropdowns() {
const practitioners = await Api.getPractitioners();
const organizations = await Api.getOrganizations();
const locations = await Api.getLocations();
const practitionerSelect =
document.getElementById('rp_field_practitionerId');
const organizationSelect =
document.getElementById('rp_field_organizationId');
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('');
}
}
// 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.
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.'
},
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.' },
conditions: { icon: '🩺', title: 'Condition', body: 'Use an ICD-10 code (e.g. E11.9) and its display name.' },
observations: { icon: '📈', title: 'Observation', body: 'Use a LOINC code (e.g. 8480-6). Put the measured value in the note.' }
};
function rpRenderFormIntro(config, mode) {
const host = document.getElementById('rp_formIntro');
if (!host) return;
const meta = RP_FORM_INTRO[config.key];
if (!meta) { host.innerHTML = ''; host.classList.add('d-none'); return; }
host.classList.remove('d-none');
host.innerHTML = `
<div class="rp-form-intro">
<div class="rp-form-intro-icon">${meta.icon}</div>
<div>
<div class="rp-form-intro-title">${rpEscapeHtml(meta.title)}</div>
<div class="rp-form-intro-body">${meta.body}</div>
</div>
</div>`;
}
async function rpOpenAddModal() {
rpEditingRowId = null;
rpEditingPatientId = null;
const config = window.RESOURCE_PAGE_CONFIG;
document.getElementById('rp_modalTitle').textContent = `Add ${config.title.replace(/s$/, '')}`;
rpRenderFormIntro(config, 'add');
// 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);
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();
}
function rpOpenEditModal(row) {
rpEditingRowId = row.id;
rpEditingPatientId = row.patientId;
const config = window.RESOURCE_PAGE_CONFIG;
//document.getElementById('rp_modalTitle').textContent = `Edit ${config.title.replace(/s$/, '')} — ${row.patientName}`;
document.getElementById('rp_modalTitle').textContent =
`Edit ${config.title.replace(/s$/, '')}`;
rpRenderFormIntro(config, 'edit');
document.getElementById('rp_patientPickerRow').classList.add('d-none');
document.getElementById('rp_fieldsBody').innerHTML = config.fields
.filter(f => !RP_AUTO_FIELDS.includes(f.key))
.map(f => rpFieldInputHtml(f, row[f.key])).join('');
rpAddModal.show();
}
async function rpSubmitModal(e) {
e.preventDefault();
const config = window.RESOURCE_PAGE_CONFIG;
const payload = {};
for (const f of config.fields) {
const el =
document.getElementById(`rp_field_${f.key}`);
console.log(f.key, el);
if (!el) {
// Auto-generated fields have no input; everything else is a real bug.
if (!RP_AUTO_FIELDS.includes(f.key)) console.error(`Missing field: ${f.key}`);
continue;
}
let v = el.value;
if (f.type === 'number') v = v === '' ? null : parseFloat(v);
if (f.type === 'boolean' || f.key === 'active' || f.key === 'enabled' || f.key === 'isActive')
{
v = v === 'true' || v === true;
}
payload[f.key] = v === '' ? null : v;
}
try {
if (config.key === 'conditions') {
const practitioners = await Api.getPractitioners();
console.log("Practitioners:", practitioners);
if (practitioners.length > 0) {
payload.recorderId = practitioners[0].id;
payload.asserterId = practitioners[0].id;
}
console.log("Payload after practitioner:", payload);
payload.clinicalStatusDisplay =
payload.clinicalStatusCode;
payload.verificationStatusDisplay =
payload.verificationStatusCode;
payload.severityDisplay =
payload.severityCode;
}
if (config.key === 'observations') {
const practitioners =
await Api.getPractitioners();
if (practitioners.length > 0) {
payload.performerPractitionerId =
practitioners[0].id;
payload.noteAuthorPractitionerId =
practitioners[0].id;
}
payload.noteTime =
new Date().toISOString();
}
const patientSelect =
document.getElementById('rp_patientSelect');
const patientId =
patientSelect ? patientSelect.value : null;
if (config.key === 'observations') {
console.log("Selected patientId:", patientId);
console.log("Patients Cache:", rpPatientsCache);
const practitioners =
await Api.getPractitioners();
if (practitioners.length > 0) {
payload.performerPractitionerId =
practitioners[0].id;
payload.noteAuthorPractitionerId =
practitioners[0].id;
}
payload.noteTime =
new Date().toISOString();
}
if (config.key === 'allergies') {
if (config.key === 'allergies') {
payload.patientId = patientId;
const practitioners =
await Api.getPractitioners();
if (practitioners.length > 0) {
payload.recorderPractitionerId =
practitioners[0].id;
}
payload.asserterPatientId =
patientId;
}
}
if (config.key === 'servicerequests') {
const practitioners =
await Api.getPractitioners();
if (practitioners.length > 0) {
payload.requesterPractitionerId =
practitioners[0].id;
payload.noteAuthorPractitionerId =
practitioners[0].id;
}
payload.noteTime =
new Date().toISOString();
payload.patientId =
patientId;
}
if (config.key === 'riskassessments') {
const practitioners =
await Api.getPractitioners();
if (practitioners.length > 0) {
payload.performerPractitionerId =
practitioners[0].id;
payload.noteAuthorPractitionerId =
practitioners[0].id;
}
payload.noteTime =
new Date().toISOString();
}
if (config.key === 'careplans') {
console.log("Selected Patient:", patientId);
payload.subjectPatientId = patientId;
payload.authorPatientId = patientId;
const practitioners =
await Api.getPractitioners();
if (practitioners.length > 0) {
payload.activityPerformerPractitionerId =
practitioners[0].id;
}
console.log("CAREPLAN PAYLOAD", payload);
}
// 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 ids = rpBuildIdentifiers(config, pid);
if (config.fields.some(f => f.key === 'identifierSystem')) {
payload.identifierSystem = ids.identifierSystem;
}
if (config.fields.some(f => f.key === 'identifierValue')) {
payload.identifierValue = ids.identifierValue;
}
}
if (rpEditingRowId) {
await config.update(rpEditingPatientId, rpEditingRowId, payload);
rpShowToast(`${config.title} updated.`);
} else {
const patientSelect =
document.getElementById('rp_patientSelect');
const patientId =
patientSelect ? patientSelect.value : null;
if (config.key === 'allergies') {
const practitioners =
await Api.getPractitioners();
if (practitioners.length > 0) {
payload.recorderPractitionerId =
practitioners[0].id;
}
payload.asserterPatientId =
patientId;
}
const standaloneResources = [
'organizations',
'locations',
'practitioners',
'practitionerroles',
'cdsrules',
'users'
];
if (
!config.standalone &&
!standaloneResources.includes(config.key) &&
!patientId
)
{
rpShowToast('Select a patient first.', 'danger');
return;
}
console.log("FINAL PAYLOAD", payload);
await config.add(patientId, payload);
rpShowToast(`${config.title} added.`);
}
rpAddModal.hide();
await rpLoadGrid();
} catch (err) {
rpShowToast(err.message, 'danger');
}
}
async function rpDeleteRow(row) {
const config = window.RESOURCE_PAGE_CONFIG;
if (!confirm(`Delete this ${config.title.replace(/s$/, '').toLowerCase()} for ${row.patientName}?`)) return;
try {
//await config.remove(row.patientId, row.id);
await config.remove(
row.patientId || null,
row.id
);
rpShowToast('Deleted.');
await rpLoadGrid();
} catch (err) {
rpShowToast(err.message, 'danger');
}
}
// ---------- Card rendering ----------
// Rows are shown as avatar cards with a count and a View button instead of a
// flat table. Patient-scoped resources group every row for one patient into a
// single card; standalone resources (organizations, practitioners, ...) get
// one card each. The old table is kept in the DOM but hidden, so the existing
// markup on every page still works untouched.
// The card styles are injected from here rather than relying on
// css/styles.css. Every previous round showed the external stylesheet not
// being picked up (avatars rendered as full-width bars because .rp-avatar
// had no size), so the layout now ships with the script that draws it.
const RP_CARD_STYLES = `
.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;
max-width: 100%;
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;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #eef3f3;
color: #5f7373;
}
.rp-card-badge.is-high { background: #fdecea; color: #c0392b; }
.rp-card-badge.is-warning { background: #fdf3e3; color: #b9770e; }
.rp-card-badge.is-normal { background: #eafaf1; color: #1e8449; }
.rp-card-badge.is-info { background: #e8f2fb; color: #2471a3; }
.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; }
/* --- Risk score chips (grid + detail table) --- */
.rp-score-chip {
display: inline-block; min-width: 52px; text-align: center;
font-weight: 700; font-size: .95rem; padding: .25rem .55rem;
border-radius: 8px; line-height: 1.2;
}
.rp-score-chip.is-high { background: #fdecea; color: #c0392b; }
.rp-score-chip.is-moderate { background: #fdf3e3; color: #b9770e; }
.rp-score-chip.is-low { background: #eafaf1; color: #1e8449; }
.rp-score-level {
font-size: .68rem; text-transform: uppercase; letter-spacing: .5px;
color: #5f7373; margin-top: .2rem; text-align: center;
}
/* --- 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; }
/* --- Add / Edit form chrome --- */
#rp_modal .modal-header {
background: linear-gradient(135deg, #0a4f4f, #0f6e6e);
color: #fff; border-bottom: none;
}
#rp_modal .modal-header .modal-title { font-weight: 700; }
#rp_modal .modal-header .btn-close { filter: invert(1) grayscale(1) brightness(2); }
#rp_modal .modal-body { padding-top: 1.1rem; }
#rp_modal .form-label {
font-size: .74rem; text-transform: uppercase; letter-spacing: .5px;
font-weight: 600; color: #5f7373; margin-bottom: .25rem;
}
#rp_modal .form-control,
#rp_modal .form-select {
border-radius: 9px; border-color: #dfe7e7; padding: .5rem .75rem;
}
#rp_modal .form-control:focus,
#rp_modal .form-select:focus {
border-color: #0f6e6e; box-shadow: 0 0 0 .18rem rgba(15,110,110,.15);
}
#rp_modal .modal-footer { border-top: 1px solid #eef3f3; }
.rp-form-intro {
display: flex; gap: .8rem; align-items: flex-start;
background: #f2f8f8; border: 1px solid #d9e8e8;
border-radius: 12px; padding: .8rem .9rem; margin-bottom: 1rem;
}
.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; }
.rp-form-intro-body code {
background: #fff; border: 1px solid #dfe7e7; border-radius: 5px;
padding: .05rem .3rem; color: #0f6e6e; font-size: .78rem;
}
/* Long add-forms (Practitioner, Location, Encounter) must scroll inside the
dialog instead of running off the bottom of the screen. */
#rp_modal .modal-content,
#rp_detailModal .modal-content { max-height: 90vh; }
#rp_modal .modal-body,
#rp_detailModal .modal-body { max-height: 70vh; overflow-y: auto; }
#rp_modal .modal-body::-webkit-scrollbar,
#rp_detailModal .modal-body::-webkit-scrollbar { width: 12px; }
#rp_modal .modal-body::-webkit-scrollbar-track,
#rp_detailModal .modal-body::-webkit-scrollbar-track { background: #e3e9e9; border-radius: 6px; }
#rp_modal .modal-body::-webkit-scrollbar-thumb,
#rp_detailModal .modal-body::-webkit-scrollbar-thumb {
background: #1a1a1a; border-radius: 6px; border: 2px solid #e3e9e9;
}
#rp_modal .modal-body,
#rp_detailModal .modal-body { scrollbar-width: thin; scrollbar-color: #1a1a1a #e3e9e9; }
`;
function rpInjectCardStyles() {
if (document.getElementById('rp-card-styles')) return;
const style = document.createElement('style');
style.id = 'rp-card-styles';
style.textContent = RP_CARD_STYLES;
document.head.appendChild(style);
}
// Colour the badge from its own text, the way a status pill should read.
function rpBadgeClass(text) {
const t = (text || '').toLowerCase();
if (/high|critical|severe|urgent|stat/.test(t)) return 'is-high';
if (/warning|moderate|on-hold|pending/.test(t)) return 'is-warning';
if (/normal|low|active|final|completed|confirmed/.test(t)) return 'is-normal';
if (/info/.test(t)) return 'is-info';
return '';
}
const RP_AVATAR_COLOURS = [
'#2e86ab', '#1e8449', '#d68910', '#7d3c98', '#c0392b',
'#117864', '#a04000', '#5499c7', '#b7950b', '#34495e'
];
function rpInitials(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('') || '?';
}
// Same name always gets the same colour.
function rpAvatarColour(name) {
let hash = 0;
for (const ch of (name || '')) hash = (hash * 31 + ch.charCodeAt(0)) % 100000;
return RP_AVATAR_COLOURS[hash % RP_AVATAR_COLOURS.length];
}
// The badge under the name: whatever the config nominates, otherwise the
// first column that isn't the patient's name.
function rpCardBadge(config, rows) {
if (config.cardBadge) return config.cardBadge(rows);
// Risk assessments show the patient's highest score, not just a status.
if (config.key === 'riskassessments') {
const best = rows
.map(r => rpParseRiskNote(r.noteText).score)
.reduce((a, b) => Math.max(a, b), 0);
return `${rpRiskLevel(best)} · ${best}%`;
}
const col = config.columns.find(c => c.key !== 'patientName');
if (!col) return '';
const value = rows[0]?.[col.key];
return value === null || value === undefined || value === '' ? '' : String(value);
}
// ---------- Identifier generation ----------
// IdentifierSystem / IdentifierValue are [Required] on several models, so the
// forms used to ask for them and a blank box meant "Validation failed". They
// are generated here instead:
// system = http://clinicalinsightspro.in/<resource>/<running number>
// value = <ResourcePrefix>-<patientId> (e.g. SerReq-pat-002)
// A suffix is added if that value is already taken, so values stay unique.
const RP_IDENTIFIER_PREFIX = {
conditions: 'Cond',
observations: 'Obs',
allergies: 'Alg',
medications: 'Med',
encounters: 'Enc',
servicerequests: 'SerReq',
riskassessments: 'Risk',
careplans: 'CarePlan',
cdsalerts: 'Alert',
organizations: 'Org',
locations: 'Loc',
practitioners: 'Prac',
practitionerroles: 'PracRole',
cdsrules: 'Rule'
};
// Fields the user should never have to fill in.
const RP_AUTO_FIELDS = ['identifierSystem', 'identifierValue'];
// ---------- Risk scoring ----------
// RiskAssessment has no score column; the contributing factors are stored as
// one semicolon-joined note ("Current smoker (+10); Prior hospitalization
// (+12)"). The percentage is the sum of those weights, capped at 100 — the
// same rule the dashboard's Assessment History uses, so both agree.
function rpParseRiskNote(noteText) {
const factors = (noteText || '')
.split(';')
.map(s => s.trim())
.filter(Boolean);
const score = Math.min(
factors.reduce((sum, f) => {
const m = f.match(/\(\+(\d+(?:\.\d+)?)\)/);
return sum + (m ? parseFloat(m[1]) : 0);
}, 0),
100
);
return { factors, score: Math.round(score) };
}
function rpRiskLevel(score) {
if (score >= 50) return 'High';
if (score >= 25) return 'Moderate';
return 'Low';
}
let rpAllRows = [];
function rpBuildIdentifiers(config, patientId) {
const prefix = RP_IDENTIFIER_PREFIX[config.key] || config.key;
// Running number = one past the highest already in use for this resource.
const seq = rpAllRows.length + 1;
const system = `http://clinicalinsightspro.in/${config.key}/${String(seq).padStart(4, '0')}`;
const base = patientId ? `${prefix}-${patientId}` : `${prefix}-${String(seq).padStart(4, '0')}`;
// Guarantee uniqueness against what's already loaded.
const taken = new Set(rpAllRows.map(r => r.identifierValue).filter(Boolean));
let value = base;
let n = 2;
while (taken.has(value)) value = `${base}-${n++}`;
return { identifierSystem: system, identifierValue: value };
}
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] };
});
} else {
rows.forEach(r => {
const key = r.patientId || 'unassigned';
if (!groups[key]) groups[key] = { title: r.patientName || key, rows: [] };
groups[key].rows.push(r);
});
}
return groups;
}
async function rpLoadGrid() {
const config = window.RESOURCE_PAGE_CONFIG;
const grid = document.getElementById('rp_cardGrid');
const empty = document.getElementById('rp_gridEmpty');
const countLabel = document.getElementById('rp_countLabel');
try {
const rows = await config.listAll(rpSearchTerm.trim());
rpAllRows = rows;
rpGroups = rpBuildGroups(rows);
const keys = Object.keys(rpGroups);
if (countLabel) {
countLabel.textContent = config.standalone
? `${rows.length} record${rows.length === 1 ? '' : 's'}`
: `${rows.length} record${rows.length === 1 ? '' : 's'} · ${keys.length} patient${keys.length === 1 ? '' : 's'}`;
}
if (!rows.length) {
grid.innerHTML = '';
empty.classList.remove('d-none');
return;
}
empty.classList.add('d-none');
grid.innerHTML = keys.map(key => {
const g = rpGroups[key];
const badge = rpCardBadge(config, g.rows);
const colour = rpAvatarColour(g.title);
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>
</div>`;
}).join('');
} catch (err) {
rpShowToast(err.message, 'danger');
}
}
// ---------- Detail modal ----------
function rpOpenDetail(groupKey) {
const config = window.RESOURCE_PAGE_CONFIG;
const group = rpGroups[groupKey];
if (!group) return;
document.getElementById('rp_detailTitle').textContent =
`${config.title}${group.title}`;
const showActions = !(config.readOnly || config.canEdit === false);
const isRisk = config.key === 'riskassessments';
document.getElementById('rp_detailHead').innerHTML =
(isRisk ? '<th>Score</th>' : '') +
config.columns
.filter(c => c.key !== 'patientName')
.map(c => `<th>${rpEscapeHtml(c.label)}</th>`).join('') +
(showActions ? '<th></th>' : '');
document.getElementById('rp_detailBody').innerHTML = group.rows.map(row => {
const risk = isRisk ? rpParseRiskNote(row.noteText) : null;
const level = risk ? rpRiskLevel(risk.score) : '';
const scoreCell = isRisk
? `<td><span class="rp-score-chip is-${level.toLowerCase()}">${risk.score}%</span>
<div class="rp-score-level">${level}</div></td>`
: '';
const cells = config.columns
.filter(c => c.key !== 'patientName')
.map(c => {
// Notes hold semicolon-separated factors — render them as bullets so
// a long line doesn't become an unreadable wall of text.
if ((c.key === 'noteText' || c.key === 'note') && row[c.key]) {
const items = String(row[c.key]).split(';').map(s => s.trim()).filter(Boolean);
return `<td><ul class="rp-note-list">${
items.map(i => `<li>${rpEscapeHtml(i)}</li>`).join('')
}</ul></td>`;
}
let v = row[c.key];
v = c.type === 'date' ? rpFmtDate(v) : rpEscapeHtml(v ?? '—');
return `<td>${v}</td>`;
}).join('');
const rowCells = scoreCell + cells;
const actions = showActions ? `
<td class="resource-row-actions text-end">
<button class="btn btn-sm btn-outline-secondary me-1" data-edit-row="${row.id}">Edit</button>
<button class="btn btn-sm btn-outline-danger" data-delete-row="${row.id}">Delete</button>
</td>` : '';
return `<tr data-row-id="${row.id}">${rowCells}${actions}</tr>`;
}).join('');
document.getElementById('rp_detailBody')._rpRows = group.rows;
rpDetailModal.show();
}
// Builds the card container and the detail modal so the existing page markup
// doesn't have to change.
function rpBuildCardShell() {
rpInjectCardStyles();
const table = document.getElementById('rp_gridBody')?.closest('.table-responsive');
if (table) {
table.classList.add('d-none');
// Banner host at the top of the Add/Edit form.
const fields = document.getElementById('rp_fieldsBody');
if (fields && !document.getElementById('rp_formIntro')) {
const intro = document.createElement('div');
intro.id = 'rp_formIntro';
intro.className = 'd-none';
fields.parentNode.insertBefore(intro, fields.previousElementSibling || fields);
}
const grid = document.createElement('div');
grid.id = 'rp_cardGrid';
grid.className = 'rp-card-grid';
table.parentNode.insertBefore(grid, table);
}
if (!document.getElementById('rp_detailModal')) {
const wrap = document.createElement('div');
wrap.innerHTML = `
<div class="modal fade" id="rp_detailModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="rp_detailTitle">Records</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="table-responsive">
<table class="table table-sm resource-grid-table align-middle mb-0">
<thead><tr id="rp_detailHead"></tr></thead>
<tbody id="rp_detailBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>`;
document.body.appendChild(wrap.firstElementChild);
}
}
function rpInit() {
const config = window.RESOURCE_PAGE_CONFIG;
if (!config) return;
if (!sessionStorage.getItem('cip_token')) {
window.location.href = 'login.html';
return;
}
renderSidebar(config.key);
document.getElementById('rp_pageIcon').textContent = config.icon;
document.getElementById('rp_pageTitle').textContent = config.title;
document.title = `${config.title} — Clinical Insight Pro`;
document.getElementById('clinicianLabel').textContent = `👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
document.getElementById('logoutBtn').addEventListener('click', () => {
sessionStorage.clear();
window.location.href = 'login.html';
});
if (config.readOnly) {
document.getElementById('rp_addBtn').classList.add('d-none');
}
// The table is hidden behind the card grid now; its header is still filled
// in so the markup stays valid for anything that inspects it.
const head = document.getElementById('rp_gridHead');
if (head) {
head.innerHTML = config.columns.map(c => `<th>${rpEscapeHtml(c.label)}</th>`).join('');
}
rpBuildCardShell();
rpDetailModal = new bootstrap.Modal(document.getElementById('rp_detailModal'));
// "View (n)" on a card opens that group's records.
document.getElementById('rp_cardGrid').addEventListener('click', (e) => {
const btn = e.target.closest('[data-group]');
if (btn) rpOpenDetail(btn.dataset.group);
});
if (!config.readOnly) {
document.querySelector('#rp_modal .modal-dialog')?.classList.add('modal-dialog-scrollable');
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();
// Edit / Delete now live inside the detail modal.
document.getElementById('rp_detailBody').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) {
const row = rows.find(r => String(r.id) === editBtn.dataset.editRow);
if (row) { rpDetailModal.hide(); rpOpenEditModal(row); }
} else if (delBtn) {
const row = rows.find(r => String(r.id) === delBtn.dataset.deleteRow);
if (row) { rpDetailModal.hide(); rpDeleteRow(row); }
}
});
}
document.getElementById('rp_searchInput').addEventListener('input', (e) => {
rpSearchTerm = e.target.value;
rpLoadGrid();
});
rpLoadGrid();
}
document.addEventListener('DOMContentLoaded', rpInit);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,126 @@
// 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();
}

Binary file not shown.

Binary file not shown.