Compare commits
25 Commits
main
...
priyanka_d
| Author | SHA1 | Date | |
|---|---|---|---|
| af93c8fa82 | |||
| 2aeb78f507 | |||
| ce2f512207 | |||
| 9d476e9a9b | |||
| 6c0b37042a | |||
| 54d93213ed | |||
| 3d2db1065f | |||
| ad7dfc309d | |||
| bfdad77519 | |||
| 529b00fe82 | |||
| a941f3f3b1 | |||
| 6011cc8a44 | |||
| c7fb4c52be | |||
| a7a3c21c9f | |||
| 9fb80aaf11 | |||
| 938412c349 | |||
| 0095700d9b | |||
| 5898469243 | |||
| 974c4115f7 | |||
| 1783fbb5e7 | |||
| 368ee47823 | |||
| eb0351c2c1 | |||
| 51bb1b2409 | |||
| e2446d4d90 | |||
| 09b19a4838 |
BIN
.Net Capstone Project/Dashboard file 29082026.zip
Normal file
BIN
.Net Capstone Project/Dashboard file 29082026.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/DashboardFixV10.zip
Normal file
BIN
.Net Capstone Project/DashboardFixV10.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/DashboardFixV11.zip
Normal file
BIN
.Net Capstone Project/DashboardFixV11.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/DashboardFixV4.zip
Normal file
BIN
.Net Capstone Project/DashboardFixV4.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/DashboardFixV7.zip
Normal file
BIN
.Net Capstone Project/DashboardFixV7.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/DashboardFixV8.zip
Normal file
BIN
.Net Capstone Project/DashboardFixV8.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/DashboardFixV9.zip
Normal file
BIN
.Net Capstone Project/DashboardFixV9.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Dashboardv5.zip
Normal file
BIN
.Net Capstone Project/Dashboardv5.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Dashboardv6.zip
Normal file
BIN
.Net Capstone Project/Dashboardv6.zip
Normal file
Binary file not shown.
193
.Net Capstone Project/PatientChartDto.cs
Normal file
193
.Net Capstone Project/PatientChartDto.cs
Normal 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;
|
||||
}
|
||||
BIN
.Net Capstone Project/PatientFieldsAndIdentifiers.zip
Normal file
BIN
.Net Capstone Project/PatientFieldsAndIdentifiers.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/ResourceCards.zip
Normal file
BIN
.Net Capstone Project/ResourceCards.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/ResourceCardsV2.zip
Normal file
BIN
.Net Capstone Project/ResourceCardsV2.zip
Normal file
Binary file not shown.
2130
.Net Capstone Project/SeedData.cs
Normal file
2130
.Net Capstone Project/SeedData.cs
Normal file
File diff suppressed because it is too large
Load Diff
BIN
.Net Capstone Project/Updated dashboard pages.zip
Normal file
BIN
.Net Capstone Project/Updated dashboard pages.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Updatedapplication.zip
Normal file
BIN
.Net Capstone Project/Updatedapplication.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Updatedcapstonefile.zip
Normal file
BIN
.Net Capstone Project/Updatedcapstonefile.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/UpdatedfileNew29082026.zip
Normal file
BIN
.Net Capstone Project/UpdatedfileNew29082026.zip
Normal file
Binary file not shown.
87
.Net Capstone Project/Updatedslidbar.js
Normal file
87
.Net Capstone Project/Updatedslidbar.js
Normal 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();
|
||||
});
|
||||
504
.Net Capstone Project/patientsv2.html
Normal file
504
.Net Capstone Project/patientsv2.html
Normal 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,'&').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>
|
||||
988
.Net Capstone Project/resource-pagev1.js
Normal file
988
.Net Capstone Project/resource-pagev1.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function rpFmtDate(value) {
|
||||
if (!value) return '—';
|
||||
const d = new Date(value);
|
||||
if (isNaN(d)) return '—';
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function rpShowToast(message, variant = 'success') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
if (!container) { alert(message); return; }
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast align-items-center text-bg-${variant === 'danger' ? 'danger' : 'success'} border-0 show mb-2`;
|
||||
el.innerHTML = `<div class="d-flex"><div class="toast-body">${rpEscapeHtml(message)}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" onclick="this.closest('.toast').remove()"></button></div>`;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => el.remove(), 5000);
|
||||
}
|
||||
|
||||
function rpFieldInputHtml(field, value) {
|
||||
const id = `rp_field_${field.key}`;
|
||||
const val = value !== undefined && value !== null ? value : (field.default || '');
|
||||
if (field.type === 'select') {
|
||||
return `<div class="col-12">
|
||||
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
||||
<select class="form-select" id="${id}">
|
||||
${field.options.map(o => `<option value="${o}" ${o === val ? 'selected' : ''}>${o}</option>`).join('')}
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
if (field.type === '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);
|
||||
1184
.Net Capstone Project/resource-pagev2.js
Normal file
1184
.Net Capstone Project/resource-pagev2.js
Normal file
File diff suppressed because it is too large
Load Diff
1217
.Net Capstone Project/resource-pagev4.js
Normal file
1217
.Net Capstone Project/resource-pagev4.js
Normal file
File diff suppressed because it is too large
Load Diff
126
.Net Capstone Project/sidebarv8.js
Normal file
126
.Net Capstone Project/sidebarv8.js
Normal 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();
|
||||
}
|
||||
BIN
Code /ClinicalInsightsPro_API_Medplum_CRUD_FIXED.zip
Normal file
BIN
Code /ClinicalInsightsPro_API_Medplum_CRUD_FIXED.zip
Normal file
Binary file not shown.
BIN
Code /ClinicalInsightsPro_MedplumCRUD-1.zip
Normal file
BIN
Code /ClinicalInsightsPro_MedplumCRUD-1.zip
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user