Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35558611cb | |||
| 6fcfecfa19 | |||
| 3e5d2bea42 | |||
| ce894a0b72 | |||
| e2125311ef | |||
| c589c09895 | |||
| 1276b205a3 | |||
| d25a2aefcd | |||
| 3793462363 | |||
| e9a97a4c07 | |||
| 4f61fcb392 | |||
| 6143b192d8 | |||
| 3c628b58b0 | |||
| b9ba92e484 | |||
| 543afd3d6c | |||
| c0a9d920bc | |||
| 718cddc720 | |||
| f822c636ae | |||
| a64f3c5522 | |||
| 395fb29405 | |||
| 56f9e902a6 | |||
| c1703b0ca6 | |||
| 83ba4b3848 | |||
| b6274e671f | |||
| a84727e8a2 | |||
| bc0f3f8d82 | |||
| 5418b0fd6f |
BIN
.Net Capstone PPT/FHIR Academy Capstone Project.pptx
Normal file
BIN
.Net Capstone PPT/FHIR Academy Capstone Project.pptx
Normal file
Binary file not shown.
BIN
.Net Capstone Project/CacheBusting_AllPages.zip
Normal file
BIN
.Net Capstone Project/CacheBusting_AllPages.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Cds controll.zip
Normal file
BIN
.Net Capstone Project/Cds controll.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Cds files.zip
Normal file
BIN
.Net Capstone Project/Cds files.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Cds updated files.zip
Normal file
BIN
.Net Capstone Project/Cds updated files.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/ClinicalInsightsPro28082026.zip
Normal file
BIN
.Net Capstone Project/ClinicalInsightsPro28082026.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/ClinicalInsightsPro_Complete.zip
Normal file
BIN
.Net Capstone Project/ClinicalInsightsPro_Complete.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/ClinicalInsightsPro_SidebarApp.zip
Normal file
BIN
.Net Capstone Project/ClinicalInsightsPro_SidebarApp.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Dashboard html file.zip
Normal file
BIN
.Net Capstone Project/Dashboard html file.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Dashboard updated cds page.zip
Normal file
BIN
.Net Capstone Project/Dashboard updated cds page.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/DashboardProfile_Bundle.zip
Normal file
BIN
.Net Capstone Project/DashboardProfile_Bundle.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Patientfiles.zip
Normal file
BIN
.Net Capstone Project/Patientfiles.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/PatientsFeature_AllFiles.zip
Normal file
BIN
.Net Capstone Project/PatientsFeature_AllFiles.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Patients_BloodGroup_Mobile_Bundle.zip
Normal file
BIN
.Net Capstone Project/Patients_BloodGroup_Mobile_Bundle.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Resources updated pages risk.zip
Normal file
BIN
.Net Capstone Project/Resources updated pages risk.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Resources.zip
Normal file
BIN
.Net Capstone Project/Resources.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/RiskQuestionnaire_Dynamic_Bundle.zip
Normal file
BIN
.Net Capstone Project/RiskQuestionnaire_Dynamic_Bundle.zip
Normal file
Binary file not shown.
147
.Net Capstone Project/RiskScoringServiceupdatedcode.cs
Normal file
147
.Net Capstone Project/RiskScoringServiceupdatedcode.cs
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
using ClinicalInsightsPro.API.Data;
|
||||||
|
using ClinicalInsightsPro.API.DTOs;
|
||||||
|
using ClinicalInsightsPro.API.Models;
|
||||||
|
using ClinicalInsightsPro.API.Services.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClinicalInsightsPro.API.Services;
|
||||||
|
|
||||||
|
// Questionnaire-based rule engine that scores an encounter as High or Normal
|
||||||
|
// risk, combining chart data (conditions, vitals) with clinician-entered
|
||||||
|
// questionnaire responses. Rule weights are intentionally simple and modular
|
||||||
|
// so they can be extended, or eventually replaced by an ML model, per scope.
|
||||||
|
public class RiskScoringService : IRiskScoringService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private const int HighRiskThreshold = 50;
|
||||||
|
|
||||||
|
public RiskScoringService(ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RiskAssessmentResultDto> AssessAsync(string patientId, QuestionnaireDto questionnaire)
|
||||||
|
{
|
||||||
|
var patient = await _db.Patients
|
||||||
|
.Include(p => p.Conditions)
|
||||||
|
.Include(p => p.Observations)
|
||||||
|
.Include(p => p.Allergies)
|
||||||
|
.Include(p => p.Medications)
|
||||||
|
.Include(p => p.Encounters)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == patientId);
|
||||||
|
|
||||||
|
if (patient == null)
|
||||||
|
throw new KeyNotFoundException($"Patient '{patientId}' was not found.");
|
||||||
|
|
||||||
|
int score = 0;
|
||||||
|
var factors = new List<string>();
|
||||||
|
|
||||||
|
foreach (var c in patient.Conditions.Where(c => c.ClinicalStatus == "active"))
|
||||||
|
{
|
||||||
|
switch (c.Code)
|
||||||
|
{
|
||||||
|
case "E11.9":
|
||||||
|
score += 20; factors.Add("Active Type 2 diabetes (+20)"); break;
|
||||||
|
case "I10":
|
||||||
|
score += 15; factors.Add("Essential hypertension (+15)"); break;
|
||||||
|
case "J45.909":
|
||||||
|
score += 10; factors.Add("Asthma (+10)"); break;
|
||||||
|
default:
|
||||||
|
score += 5; factors.Add($"Active condition: {c.Display} (+5)"); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestSystolic = patient.Observations
|
||||||
|
.Where(o => o.Code == "8480-6")
|
||||||
|
.OrderByDescending(o => o.EffectiveDate)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (latestSystolic != null && latestSystolic.Value >= 160)
|
||||||
|
{
|
||||||
|
score += 20; factors.Add($"Systolic BP {latestSystolic.Value} mmHg — hypertensive urgency range (+20)");
|
||||||
|
}
|
||||||
|
else if (latestSystolic != null && latestSystolic.Value >= 140)
|
||||||
|
{
|
||||||
|
score += 10; factors.Add($"Systolic BP {latestSystolic.Value} mmHg — stage 2 hypertension (+10)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestGlucose = patient.Observations
|
||||||
|
.Where(o => o.Code == "2339-0")
|
||||||
|
.OrderByDescending(o => o.EffectiveDate)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (latestGlucose != null && latestGlucose.Value >= 200)
|
||||||
|
{
|
||||||
|
score += 15; factors.Add($"Glucose {latestGlucose.Value} mg/dL — poorly controlled (+15)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allergies — a known high-criticality allergy raises the risk of a
|
||||||
|
// future adverse drug event, independent of what's happening today.
|
||||||
|
var highCriticalityAllergies = patient.Allergies.Count(a => a.Criticality == "high");
|
||||||
|
if (highCriticalityAllergies > 0)
|
||||||
|
{
|
||||||
|
var allergyPoints = Math.Min(highCriticalityAllergies * 10, 20);
|
||||||
|
score += allergyPoints;
|
||||||
|
factors.Add($"{highCriticalityAllergies} high-criticality allerg{(highCriticalityAllergies == 1 ? "y" : "ies")} on file (+{allergyPoints})");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medications — polypharmacy (commonly defined as 5+ concurrent
|
||||||
|
// medications) is itself an established risk factor for interactions
|
||||||
|
// and adverse events, regardless of what the medications are for.
|
||||||
|
var activeMedicationCount = patient.Medications.Count(m => m.Status == "active");
|
||||||
|
if (activeMedicationCount >= 5)
|
||||||
|
{
|
||||||
|
score += 12; factors.Add($"Polypharmacy — {activeMedicationCount} active medications (+12)");
|
||||||
|
}
|
||||||
|
else if (activeMedicationCount >= 3)
|
||||||
|
{
|
||||||
|
score += 6; factors.Add($"{activeMedicationCount} active medications (+6)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encounters — frequent recent encounters (visits, admissions, ED
|
||||||
|
// visits) in the last 12 months suggest an unstable or worsening
|
||||||
|
// condition even if no single encounter looks alarming on its own.
|
||||||
|
var recentEncounterCount = patient.Encounters.Count(e => e.EncounterDate >= DateTime.UtcNow.AddMonths(-12));
|
||||||
|
if (recentEncounterCount >= 3)
|
||||||
|
{
|
||||||
|
score += 12; factors.Add($"{recentEncounterCount} encounters within the last 12 months (+12)");
|
||||||
|
}
|
||||||
|
else if (recentEncounterCount == 2)
|
||||||
|
{
|
||||||
|
score += 6; factors.Add("2 encounters within the last 12 months (+6)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questionnaire.Smoker) { score += 10; factors.Add("Current smoker (+10)"); }
|
||||||
|
if (questionnaire.FamilyHistoryHeartDisease) { score += 8; factors.Add("Family history of heart disease (+8)"); }
|
||||||
|
if (questionnaire.PriorHospitalization) { score += 12; factors.Add("Prior hospitalization within 12 months (+12)"); }
|
||||||
|
if (questionnaire.Bmi is >= 30) { score += 8; factors.Add($"BMI {questionnaire.Bmi:F1} — obese range (+8)"); }
|
||||||
|
if (questionnaire.PainScore >= 7) { score += 5; factors.Add($"Reported pain score {questionnaire.PainScore}/10 (+5)"); }
|
||||||
|
|
||||||
|
score = Math.Min(score, 100);
|
||||||
|
var level = score >= HighRiskThreshold ? "High" : "Normal";
|
||||||
|
|
||||||
|
if (!factors.Any())
|
||||||
|
factors.Add("No significant risk factors identified from chart or questionnaire.");
|
||||||
|
|
||||||
|
var assessment = new RiskAssessment
|
||||||
|
{
|
||||||
|
PatientId = patientId,
|
||||||
|
Score = score,
|
||||||
|
RiskLevel = level,
|
||||||
|
Rationale = string.Join("; ", factors),
|
||||||
|
AssessedDate = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.RiskAssessments.Add(assessment);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new RiskAssessmentResultDto
|
||||||
|
{
|
||||||
|
PatientId = patientId,
|
||||||
|
Score = score,
|
||||||
|
RiskLevel = level,
|
||||||
|
ContributingFactors = factors,
|
||||||
|
AssessedDate = assessment.AssessedDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
.Net Capstone Project/Toggle.zip
Normal file
BIN
.Net Capstone Project/Toggle.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Updated condition and all pages.zip
Normal file
BIN
.Net Capstone Project/Updated condition and all pages.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Updated dashboard file ja.zip
Normal file
BIN
.Net Capstone Project/Updated dashboard file ja.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Updated resource file 28082026.zip
Normal file
BIN
.Net Capstone Project/Updated resource file 28082026.zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Updated resources .zip
Normal file
BIN
.Net Capstone Project/Updated resources .zip
Normal file
Binary file not shown.
BIN
.Net Capstone Project/Updated resources card row.zip
Normal file
BIN
.Net Capstone Project/Updated resources card row.zip
Normal file
Binary file not shown.
367
.Net Capstone Project/cdsalerts updated.html
Normal file
367
.Net Capstone Project/cdsalerts updated.html
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>CDS Alerts — Clinical Insight Pro</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
<style>
|
||||||
|
.cds-tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||||
|
.cds-badge {
|
||||||
|
display: inline-block; padding: 0.28rem 0.7rem; border-radius: 999px;
|
||||||
|
font-size: 0.76rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.cds-badge.critical, .cds-badge.high { background: #fdecea; color: #c0392b; }
|
||||||
|
.cds-badge.warning, .cds-badge.moderate { background: #fff6dd; color: #a9750c; }
|
||||||
|
.cds-badge.info, .cds-badge.low { background: #e8f2fb; color: #2e6fa8; }
|
||||||
|
|
||||||
|
.cds-priority-wrap { display: flex; align-items: center; gap: 0.5rem; min-width: 110px; }
|
||||||
|
.cds-priority-bar { flex: 1; height: 7px; border-radius: 999px; background: #eef1f1; overflow: hidden; }
|
||||||
|
.cds-priority-fill { height: 100%; border-radius: 999px; }
|
||||||
|
.cds-priority-fill.critical { background: #d64545; }
|
||||||
|
.cds-priority-fill.warning { background: #e0a83d; }
|
||||||
|
.cds-priority-fill.info { background: #4a90c4; }
|
||||||
|
.cds-priority-pct { font-size: 0.78rem; font-weight: 700; color: var(--ci-muted); width: 34px; text-align: right; }
|
||||||
|
|
||||||
|
.cds-overall-wrap { display: flex; align-items: center; gap: 10px; margin: 0.25rem 0 1.1rem; }
|
||||||
|
.cds-overall-bar { flex: 1; height: 9px; border-radius: 999px; background: #eef1f1; overflow: hidden; }
|
||||||
|
.cds-overall-fill { height: 100%; border-radius: 999px; }
|
||||||
|
.cds-overall-fill.high { background: #d64545; }
|
||||||
|
.cds-overall-fill.moderate { background: #e0a83d; }
|
||||||
|
.cds-overall-fill.low { background: #27ae60; }
|
||||||
|
.cds-overall-num { font-size: 1.15rem; font-weight: 700; min-width: 56px; text-align: right; }
|
||||||
|
|
||||||
|
.cds-actioned-pill { font-size: 0.72rem; font-weight: 600; padding: 0.15rem 0.55rem; border-radius: 999px; background: #eafaf1; color: #1e8449; }
|
||||||
|
.cds-actioned-pill.pending { background: #f4f4f4; color: #7a7a7a; }
|
||||||
|
.cds-alert-card { border: 1px solid #e6e9e8; border-radius: 12px; padding: 1rem 1.1rem; margin-bottom: 0.85rem; background: #fff; }
|
||||||
|
.cds-row-critical { border-left: 3px solid #d64545; }
|
||||||
|
.cds-row-warning { border-left: 3px solid #e0a83d; }
|
||||||
|
.cds-row-info { border-left: 3px solid #4a90c4; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-ci">
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<a class="navbar-brand" href="dashboard.html">🩺 Clinical Insight Pro</a>
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-3">
|
||||||
|
<span class="navbar-text" id="clinicianLabel">Clinician</span>
|
||||||
|
<button class="btn btn-outline-light btn-sm" id="logoutBtn">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="app-sidebar" id="appSidebar"></aside>
|
||||||
|
<main class="app-main">
|
||||||
|
<div class="resource-page-header">
|
||||||
|
<h4>🔔 CDS Alerts</h4>
|
||||||
|
<span class="text-muted-ci small" id="cds_countLabel"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-ci p-3 mb-3 d-flex flex-row align-items-center gap-3 flex-wrap">
|
||||||
|
<input type="text" class="form-control resource-search-bar" id="cds_searchInput" placeholder="Search by patient name or patient ID…" autocomplete="off">
|
||||||
|
<a href="cdsalert-add.html" class="btn btn-ci-primary ms-auto">+ Add Alert</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cds_body" class="cds-tile-grid"></div>
|
||||||
|
<div id="cds_empty" class="text-muted-ci text-center py-5 d-none">No CDS alerts on file.</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="cds_editModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="cds_editModalTitle">Edit Alert</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form id="cds_form">
|
||||||
|
<div class="modal-body row g-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small">Severity</label>
|
||||||
|
<select class="form-select" id="cds_severity">
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="warning" selected>Warning</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small">Summary</label>
|
||||||
|
<textarea class="form-control" id="cds_summary" rows="2" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small">Recommendation</label>
|
||||||
|
<textarea class="form-control" id="cds_recommendation" rows="2" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 form-check mt-2">
|
||||||
|
<input type="checkbox" class="form-check-input" id="cds_actioned">
|
||||||
|
<label class="form-check-label small" for="cds_actioned">Already actioned</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-ci-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="js/config.js"></script>
|
||||||
|
<script src="js/sidebar.js"></script>
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script>
|
||||||
|
const CDS_TILE_COLORS = ['primary', 'success', 'info', 'warning', 'danger', 'secondary'];
|
||||||
|
|
||||||
|
const CDS_PRIORITY = { critical: 100, warning: 60, info: 30 };
|
||||||
|
|
||||||
|
const CDS_RULE_WEIGHTS = { critical: 35, warning: 20, info: 8 };
|
||||||
|
|
||||||
|
let cdsSearchTerm = '';
|
||||||
|
let cdsEditModal, cdsEditingId = null, cdsEditingPatientId = null;
|
||||||
|
let cdsViewModalInstance = null;
|
||||||
|
|
||||||
|
function cdsEscapeHtml(s) {
|
||||||
|
if (s === null || s === undefined) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdsFmtDate(value) {
|
||||||
|
if (!value) return '—';
|
||||||
|
const d = new Date(value);
|
||||||
|
if (isNaN(d)) return '—';
|
||||||
|
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdsShowToast(message, variant = 'success') {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `toast align-items-center text-bg-${variant === 'danger' ? 'danger' : 'success'} border-0 show mb-2`;
|
||||||
|
el.innerHTML = `<div class="d-flex"><div class="toast-body">${cdsEscapeHtml(message)}</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" onclick="this.closest('.toast').remove()"></button></div>`;
|
||||||
|
container.appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdsComputeOverallRisk(alerts) {
|
||||||
|
let score = 0;
|
||||||
|
alerts.forEach(a => { score += CDS_RULE_WEIGHTS[(a.severity || '').toLowerCase()] || 0; });
|
||||||
|
score = Math.min(score, 100);
|
||||||
|
const level = score >= 70 ? 'high' : score >= 40 ? 'moderate' : 'low';
|
||||||
|
return { score, level };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdsEnsureViewModal() {
|
||||||
|
let el = document.getElementById('cds_viewModal');
|
||||||
|
if (el) return el;
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.className = 'modal fade';
|
||||||
|
el.id = 'cds_viewModal';
|
||||||
|
el.tabIndex = -1;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="cds_viewModalTitle"></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="cds_viewModalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
cdsViewModalInstance = new bootstrap.Modal(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdsOpenViewModal(patientId, allRows) {
|
||||||
|
const items = allRows.filter(r => r.patientId === patientId)
|
||||||
|
.sort((a, b) => (CDS_PRIORITY[(b.severity||'').toLowerCase()]||0) - (CDS_PRIORITY[(a.severity||'').toLowerCase()]||0));
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
cdsEnsureViewModal();
|
||||||
|
document.getElementById('cds_viewModalTitle').textContent = items[0].patientName;
|
||||||
|
|
||||||
|
const overall = cdsComputeOverallRisk(items);
|
||||||
|
const critCount = items.filter(a => (a.severity||'').toLowerCase() === 'critical').length;
|
||||||
|
const warnCount = items.filter(a => (a.severity||'').toLowerCase() === 'warning').length;
|
||||||
|
const infoCount = items.filter(a => (a.severity||'').toLowerCase() === 'info').length;
|
||||||
|
|
||||||
|
const summaryHtml = `
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<span class="cds-badge ${overall.level}">${overall.level} CDS risk</span>
|
||||||
|
<span class="small text-muted-ci">${critCount} critical (+${CDS_RULE_WEIGHTS.critical} each) · ${warnCount} warning (+${CDS_RULE_WEIGHTS.warning} each) · ${infoCount} info (+${CDS_RULE_WEIGHTS.info} each)</span>
|
||||||
|
</div>
|
||||||
|
<div class="cds-overall-wrap">
|
||||||
|
<div class="cds-overall-bar"><div class="cds-overall-fill ${overall.level}" style="width:${overall.score}%"></div></div>
|
||||||
|
<span class="cds-overall-num">${overall.score}%</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const cardsHtml = items.map(r => {
|
||||||
|
const sev = (r.severity || 'warning').toLowerCase();
|
||||||
|
const pct = CDS_PRIORITY[sev] ?? 50;
|
||||||
|
return `
|
||||||
|
<div class="cds-alert-card cds-row-${sev}">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||||
|
<span class="cds-badge ${sev}">${cdsEscapeHtml(sev)}</span>
|
||||||
|
<span class="cds-actioned-pill ${r.actioned ? '' : 'pending'}">${r.actioned ? 'Actioned' : 'Pending'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cds-priority-wrap mt-2">
|
||||||
|
<div class="cds-priority-bar"><div class="cds-priority-fill ${sev}" style="width:${pct}%"></div></div>
|
||||||
|
<span class="cds-priority-pct">${pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="fw-semibold mt-2">${cdsEscapeHtml(r.summary)}</div>
|
||||||
|
<div class="small text-muted-ci mt-1">${cdsEscapeHtml(r.recommendation)}</div>
|
||||||
|
<div class="small text-muted-ci mt-2">Generated ${cdsFmtDate(r.generatedDate)}</div>
|
||||||
|
<div class="mt-2 text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary me-1" data-edit-row="${r.id}" data-edit-patient="${r.patientId}">Edit</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" data-delete-row="${r.id}" data-delete-patient="${r.patientId}">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('cds_viewModalBody').innerHTML = summaryHtml + cardsHtml;
|
||||||
|
cdsViewModalInstance.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cdsLoadGrid() {
|
||||||
|
const container = document.getElementById('cds_body');
|
||||||
|
const empty = document.getElementById('cds_empty');
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
rows = await Api.listAllCdsAlerts(cdsSearchTerm.trim());
|
||||||
|
} catch (err) {
|
||||||
|
cdsShowToast(err.message, 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patientCount = new Set(rows.map(r => r.patientId)).size;
|
||||||
|
document.getElementById('cds_countLabel').textContent = `${rows.length} alert${rows.length === 1 ? '' : 's'} · ${patientCount} patient${patientCount === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
|
||||||
|
const groups = new Map();
|
||||||
|
rows.forEach(r => {
|
||||||
|
if (!groups.has(r.patientId)) groups.set(r.patientId, { patientId: r.patientId, patientName: r.patientName, items: [] });
|
||||||
|
groups.get(r.patientId).items.push(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = [...groups.values()].map((g, i) => {
|
||||||
|
const color = CDS_TILE_COLORS[i % CDS_TILE_COLORS.length];
|
||||||
|
const initials = g.patientName.split(' ').map(n => n[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
|
||||||
|
const overall = cdsComputeOverallRisk(g.items);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card shadow-sm border-0" style="cursor:pointer" data-view-patient="${g.patientId}">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="rounded-circle bg-${color} text-white d-inline-flex align-items-center justify-content-center mb-2" style="width:44px;height:44px;font-size:14px">${cdsEscapeHtml(initials)}</div>
|
||||||
|
<div class="fw-semibold small">${cdsEscapeHtml(g.patientName)}</div>
|
||||||
|
<span class="cds-badge ${overall.level} mt-1 d-inline-block">${overall.level} risk · ${overall.score}%</span>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-${color}" data-view-patient="${g.patientId}">View (${g.items.length})</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container._cdsRows = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdsOpenEdit(row) {
|
||||||
|
cdsEditingId = row.id;
|
||||||
|
cdsEditingPatientId = row.patientId;
|
||||||
|
document.getElementById('cds_editModalTitle').textContent = `Edit Alert — ${row.patientName}`;
|
||||||
|
document.getElementById('cds_severity').value = (row.severity || 'warning').toLowerCase();
|
||||||
|
document.getElementById('cds_summary').value = row.summary || '';
|
||||||
|
document.getElementById('cds_recommendation').value = row.recommendation || '';
|
||||||
|
document.getElementById('cds_actioned').checked = !!row.actioned;
|
||||||
|
cdsEditModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cdsSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
severity: document.getElementById('cds_severity').value,
|
||||||
|
summary: document.getElementById('cds_summary').value,
|
||||||
|
recommendation: document.getElementById('cds_recommendation').value,
|
||||||
|
actioned: document.getElementById('cds_actioned').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Api.updateCdsAlert(cdsEditingPatientId, cdsEditingId, payload);
|
||||||
|
cdsShowToast('Alert updated.');
|
||||||
|
cdsEditModal.hide();
|
||||||
|
await cdsLoadGrid();
|
||||||
|
if (cdsViewModalInstance) cdsViewModalInstance.hide();
|
||||||
|
} catch (err) {
|
||||||
|
cdsShowToast(err.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cdsDelete(row) {
|
||||||
|
if (!confirm(`Delete this alert for ${row.patientName}?`)) return;
|
||||||
|
try {
|
||||||
|
await Api.deleteCdsAlert(row.patientId, row.id);
|
||||||
|
cdsShowToast('Deleted.');
|
||||||
|
await cdsLoadGrid();
|
||||||
|
const container = document.getElementById('cds_body');
|
||||||
|
const stillHasRows = (container._cdsRows || []).some(r => r.patientId === row.patientId);
|
||||||
|
if (stillHasRows) cdsOpenViewModal(row.patientId, container._cdsRows);
|
||||||
|
else if (cdsViewModalInstance) cdsViewModalInstance.hide();
|
||||||
|
} catch (err) {
|
||||||
|
cdsShowToast(err.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (!sessionStorage.getItem('cip_token')) { window.location.href = 'login.html'; return; }
|
||||||
|
|
||||||
|
renderSidebar('cdsalerts');
|
||||||
|
document.getElementById('clinicianLabel').textContent = `👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', () => { sessionStorage.clear(); window.location.href = 'login.html'; });
|
||||||
|
|
||||||
|
cdsEditModal = new bootstrap.Modal(document.getElementById('cds_editModal'));
|
||||||
|
document.getElementById('cds_form').addEventListener('submit', cdsSubmit);
|
||||||
|
|
||||||
|
document.getElementById('cds_searchInput').addEventListener('input', (e) => {
|
||||||
|
cdsSearchTerm = e.target.value;
|
||||||
|
cdsLoadGrid();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('cds_body').addEventListener('click', (e) => {
|
||||||
|
const viewEl = e.target.closest('[data-view-patient]');
|
||||||
|
if (!viewEl) return;
|
||||||
|
const container = document.getElementById('cds_body');
|
||||||
|
cdsOpenViewModal(viewEl.dataset.viewPatient, container._cdsRows || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
cdsEnsureViewModal().addEventListener('click', (e) => {
|
||||||
|
const editBtn = e.target.closest('[data-edit-row]');
|
||||||
|
const delBtn = e.target.closest('[data-delete-row]');
|
||||||
|
const container = document.getElementById('cds_body');
|
||||||
|
const rows = container._cdsRows || [];
|
||||||
|
if (editBtn) {
|
||||||
|
const row = rows.find(r => String(r.id) === editBtn.dataset.editRow && r.patientId === editBtn.dataset.editPatient);
|
||||||
|
if (row) { cdsViewModalInstance.hide(); cdsOpenEdit(row); }
|
||||||
|
} else if (delBtn) {
|
||||||
|
const row = rows.find(r => String(r.id) === delBtn.dataset.deleteRow && r.patientId === delBtn.dataset.deletePatient);
|
||||||
|
if (row) cdsDelete(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cdsLoadGrid();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
350
.Net Capstone Project/resource-page updated.js
Normal file
350
.Net Capstone Project/resource-page updated.js
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
// Generic engine for a "browse one resource type across all patients" page
|
||||||
|
// (Conditions, Observations, Allergies, Medications, Encounters). Each page
|
||||||
|
// just defines window.RESOURCE_PAGE_CONFIG and includes this script — no
|
||||||
|
// per-page JS needed. See conditions.html for the config shape.
|
||||||
|
|
||||||
|
let rpPatientsCache = [];
|
||||||
|
let rpSearchTerm = '';
|
||||||
|
let rpAddModal, rpEditingRowId = null, rpEditingPatientId = null;
|
||||||
|
|
||||||
|
function rpEscapeHtml(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpFmtDate(value) {
|
||||||
|
if (!value) return '—';
|
||||||
|
const d = new Date(value);
|
||||||
|
if (isNaN(d)) return '—';
|
||||||
|
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpShowToast(message, variant = 'success') {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
if (!container) { alert(message); return; }
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `toast align-items-center text-bg-${variant === 'danger' ? 'danger' : 'success'} border-0 show mb-2`;
|
||||||
|
el.innerHTML = `<div class="d-flex"><div class="toast-body">${rpEscapeHtml(message)}</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" onclick="this.closest('.toast').remove()"></button></div>`;
|
||||||
|
container.appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpFieldInputHtml(field, value) {
|
||||||
|
const id = `rp_field_${field.key}`;
|
||||||
|
const val = value !== undefined && value !== null ? value : (field.default || '');
|
||||||
|
|
||||||
|
if (field.type === 'select') {
|
||||||
|
return `<div class="col-12">
|
||||||
|
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
||||||
|
<select class="form-select" id="${id}">
|
||||||
|
${field.options.map(o => `<option value="${o}" ${o === val ? 'selected' : ''}>${o}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'checkbox') {
|
||||||
|
const checked = value === true || value === 'true';
|
||||||
|
return `<div class="col-12 form-check mt-1">
|
||||||
|
<input type="checkbox" class="form-check-input" id="${id}" ${checked ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label small" for="${id}">${rpEscapeHtml(field.label)}</label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'textarea') {
|
||||||
|
return `<div class="col-12">
|
||||||
|
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
||||||
|
<textarea ${field.required ? 'required' : ''} class="form-control" id="${id}" rows="2">${rpEscapeHtml(val)}</textarea>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputVal = field.type === 'date' && val ? String(val).slice(0, 10)
|
||||||
|
: field.type === 'datetime-local' && val ? String(val).slice(0, 16)
|
||||||
|
: val;
|
||||||
|
return `<div class="col-12">
|
||||||
|
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
|
||||||
|
<input type="${field.type}" ${field.required ? 'required' : ''} class="form-control" id="${id}" value="${rpEscapeHtml(inputVal)}">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpLoadPatientsForPicker() {
|
||||||
|
try {
|
||||||
|
rpPatientsCache = await Api.getPatients();
|
||||||
|
const select = document.getElementById('rp_patientSelect');
|
||||||
|
if (select) {
|
||||||
|
select.innerHTML = rpPatientsCache.map(p => `<option value="${p.id}">${rpEscapeHtml(p.fullName)} (${rpEscapeHtml(p.medicalRecordNumber)})</option>`).join('');
|
||||||
|
}
|
||||||
|
} catch { /* patient picker just won't populate; add flow will fail loudly instead */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpOpenAddModal() {
|
||||||
|
rpEditingRowId = null;
|
||||||
|
rpEditingPatientId = null;
|
||||||
|
const config = window.RESOURCE_PAGE_CONFIG;
|
||||||
|
document.getElementById('rp_modalTitle').textContent = `Add ${config.title.replace(/s$/, '')}`;
|
||||||
|
document.getElementById('rp_patientPickerRow').classList.remove('d-none');
|
||||||
|
document.getElementById('rp_fieldsBody').innerHTML = config.fields.map(f => rpFieldInputHtml(f, null)).join('');
|
||||||
|
rpAddModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpOpenEditModal(row) {
|
||||||
|
rpEditingRowId = row.id;
|
||||||
|
rpEditingPatientId = row.patientId;
|
||||||
|
const config = window.RESOURCE_PAGE_CONFIG;
|
||||||
|
document.getElementById('rp_modalTitle').textContent = `Edit ${config.title.replace(/s$/, '')} — ${row.patientName}`;
|
||||||
|
document.getElementById('rp_patientPickerRow').classList.add('d-none');
|
||||||
|
document.getElementById('rp_fieldsBody').innerHTML = config.fields.map(f => rpFieldInputHtml(f, row[f.key])).join('');
|
||||||
|
rpAddModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpSubmitModal(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const config = window.RESOURCE_PAGE_CONFIG;
|
||||||
|
const payload = {};
|
||||||
|
for (const f of config.fields) {
|
||||||
|
const el = document.getElementById(`rp_field_${f.key}`);
|
||||||
|
let v;
|
||||||
|
if (f.type === 'checkbox') {
|
||||||
|
v = el.checked;
|
||||||
|
} else {
|
||||||
|
v = el.value;
|
||||||
|
if (f.type === 'number') v = v === '' ? null : parseFloat(v);
|
||||||
|
v = v === '' ? null : v;
|
||||||
|
}
|
||||||
|
payload[f.key] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (rpEditingRowId) {
|
||||||
|
await config.update(rpEditingPatientId, rpEditingRowId, payload);
|
||||||
|
rpShowToast(`${config.title} updated.`);
|
||||||
|
} else {
|
||||||
|
const patientId = document.getElementById('rp_patientSelect').value;
|
||||||
|
if (!patientId) { rpShowToast('Select a patient first.', 'danger'); return; }
|
||||||
|
await config.add(patientId, payload);
|
||||||
|
rpShowToast(`${config.title} added.`);
|
||||||
|
}
|
||||||
|
rpAddModal.hide();
|
||||||
|
await rpLoadGrid();
|
||||||
|
} catch (err) {
|
||||||
|
rpShowToast(err.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpDeleteRow(row) {
|
||||||
|
const config = window.RESOURCE_PAGE_CONFIG;
|
||||||
|
if (!confirm(`Delete this ${config.title.replace(/s$/, '').toLowerCase()} for ${row.patientName}?`)) return;
|
||||||
|
try {
|
||||||
|
await config.remove(row.patientId, row.id);
|
||||||
|
rpShowToast('Deleted.');
|
||||||
|
await rpLoadGrid();
|
||||||
|
// The row's Delete button lives inside the popup modal — refresh (or
|
||||||
|
// close, if that was their last record) so it doesn't show stale data.
|
||||||
|
const container = document.getElementById('rp_gridBody');
|
||||||
|
const stillHasRows = (container._rpRows || []).some(r => r.patientId === row.patientId);
|
||||||
|
if (stillHasRows) rpOpenViewModal(row.patientId);
|
||||||
|
else if (rpViewModalInstance) rpViewModalInstance.hide();
|
||||||
|
} catch (err) {
|
||||||
|
rpShowToast(err.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap "bg-*" classes cycled per tile so patients are visually easy to
|
||||||
|
// tell apart at a glance — not tied to any data meaning, just variety.
|
||||||
|
const RP_TILE_COLORS = ['primary', 'success', 'info', 'warning', 'danger', 'secondary'];
|
||||||
|
|
||||||
|
// The pages ship a <table><thead>...<tbody id="rp_gridBody"> wrapper (from
|
||||||
|
// before this was a tile grid). Swap that whole table out for a plain grid
|
||||||
|
// div, once, so none of the 7 HTML pages that use this engine need editing.
|
||||||
|
function rpEnsureTileContainer() {
|
||||||
|
const existing = document.getElementById('rp_gridBody');
|
||||||
|
if (existing.tagName.toLowerCase() === 'div') return existing;
|
||||||
|
const table = existing.closest('table');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.id = 'rp_gridBody';
|
||||||
|
div.className = 'rp-tile-grid';
|
||||||
|
table.replaceWith(div);
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rpViewModalInstance = null;
|
||||||
|
|
||||||
|
// The pages don't ship a "view details" modal in their HTML — build it once
|
||||||
|
// and reuse it, same approach as rpEnsureTileContainer above. Scrollable so
|
||||||
|
// patients with a lot of records don't blow out the page.
|
||||||
|
function rpEnsureViewModal() {
|
||||||
|
let el = document.getElementById('rp_viewModal');
|
||||||
|
if (el) return el;
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.className = 'modal fade';
|
||||||
|
el.id = 'rp_viewModal';
|
||||||
|
el.tabIndex = -1;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="rp_viewModalTitle"></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0" id="rp_viewModalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
rpViewModalInstance = new bootstrap.Modal(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpOpenViewModal(patientId) {
|
||||||
|
const config = window.RESOURCE_PAGE_CONFIG;
|
||||||
|
const container = document.getElementById('rp_gridBody');
|
||||||
|
const rows = (container._rpRows || []).filter(r => r.patientId === patientId);
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
rpEnsureViewModal();
|
||||||
|
document.getElementById('rp_viewModalTitle').textContent = rows[0].patientName;
|
||||||
|
|
||||||
|
const detailColumns = config.columns.filter(c => c.key !== 'patientName');
|
||||||
|
const detailRows = rows.map(row => {
|
||||||
|
const cells = detailColumns.map(c => {
|
||||||
|
if (c.render) return `<td>${c.render(row[c.key], row)}</td>`;
|
||||||
|
let v = row[c.key];
|
||||||
|
v = c.type === 'date' ? rpFmtDate(v) : rpEscapeHtml(v ?? '—');
|
||||||
|
return `<td>${v}</td>`;
|
||||||
|
}).join('');
|
||||||
|
const actions = config.readOnly ? '' : `
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary me-1" data-edit-row="${row.id}" data-edit-patient="${row.patientId}">Edit</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" data-delete-row="${row.id}" data-delete-patient="${row.patientId}">Delete</button>
|
||||||
|
</td>`;
|
||||||
|
return `<tr data-row-id="${row.id}" data-row-patient="${row.patientId}">${cells}${actions}</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('rp_viewModalBody').innerHTML = `
|
||||||
|
<div style="max-height:60vh;overflow-y:auto">
|
||||||
|
<table class="table table-sm resource-grid-table align-middle mb-0">
|
||||||
|
<thead><tr>${detailColumns.map(c => `<th>${rpEscapeHtml(c.label)}</th>`).join('')}${config.readOnly ? '' : '<th></th>'}</tr></thead>
|
||||||
|
<tbody>${detailRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
rpViewModalInstance.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpLoadGrid() {
|
||||||
|
const config = window.RESOURCE_PAGE_CONFIG;
|
||||||
|
const container = rpEnsureTileContainer();
|
||||||
|
const empty = document.getElementById('rp_gridEmpty');
|
||||||
|
const countLabel = document.getElementById('rp_countLabel');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await config.listAll(rpSearchTerm.trim());
|
||||||
|
const patientCount = new Set(rows.map(r => r.patientId)).size;
|
||||||
|
if (countLabel) countLabel.textContent = `${rows.length} record${rows.length === 1 ? '' : 's'} · ${patientCount} patient${patientCount === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
|
||||||
|
const groups = new Map();
|
||||||
|
rows.forEach(r => {
|
||||||
|
if (!groups.has(r.patientId)) groups.set(r.patientId, { patientId: r.patientId, patientName: r.patientName, items: [] });
|
||||||
|
groups.get(r.patientId).items.push(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = [...groups.values()].map((g, i) => {
|
||||||
|
const color = RP_TILE_COLORS[i % RP_TILE_COLORS.length];
|
||||||
|
const initials = g.patientName.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase();
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card shadow-sm border-0" style="cursor:pointer" data-view-patient="${g.patientId}">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="rounded-circle bg-${color} text-white d-inline-flex align-items-center justify-content-center mb-2" style="width:44px;height:44px;font-size:14px" aria-hidden="true">${rpEscapeHtml(initials)}</div>
|
||||||
|
<div class="fw-semibold small">${rpEscapeHtml(g.patientName)}</div>
|
||||||
|
<span class="badge bg-${color}-subtle text-${color}-emphasis rounded-pill mt-1">${g.items.length} record${g.items.length === 1 ? '' : 's'}</span>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-${color}" data-view-patient="${g.patientId}">View</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Stash rows for the View/Edit/Delete handlers (avoids a second fetch per click).
|
||||||
|
container._rpRows = rows;
|
||||||
|
|
||||||
|
// Lets a page compute something from the freshly-loaded rows (e.g. an
|
||||||
|
// "X% actioned" stat) without the generic engine needing to know about
|
||||||
|
// page-specific fields like "Actioned".
|
||||||
|
if (typeof config.afterLoad === 'function') config.afterLoad(rows);
|
||||||
|
} catch (err) {
|
||||||
|
rpShowToast(err.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpInit() {
|
||||||
|
const config = window.RESOURCE_PAGE_CONFIG;
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
if (!sessionStorage.getItem('cip_token')) {
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSidebar(config.key);
|
||||||
|
|
||||||
|
document.getElementById('rp_pageIcon').textContent = config.icon;
|
||||||
|
document.getElementById('rp_pageTitle').textContent = config.title;
|
||||||
|
document.title = `${config.title} — Clinical Insight Pro`;
|
||||||
|
document.getElementById('clinicianLabel').textContent = `👤 ${sessionStorage.getItem('cip_clinician') || 'Clinician'}`;
|
||||||
|
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.readOnly) {
|
||||||
|
document.getElementById('rp_addBtn').classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// "View" opens a scrollable popup with that patient's records, instead of
|
||||||
|
// expanding inline (which used to push the rest of the grid down). Works
|
||||||
|
// on every page, including read-only ones (Risk Assessment, Care Plans).
|
||||||
|
rpEnsureTileContainer().addEventListener('click', (e) => {
|
||||||
|
const viewEl = e.target.closest('[data-view-patient]');
|
||||||
|
if (!viewEl) return;
|
||||||
|
rpOpenViewModal(viewEl.dataset.viewPatient);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config.readOnly) {
|
||||||
|
rpAddModal = new bootstrap.Modal(document.getElementById('rp_modal'));
|
||||||
|
document.getElementById('rp_addBtn').addEventListener('click', rpOpenAddModal);
|
||||||
|
document.getElementById('rp_form').addEventListener('submit', rpSubmitModal);
|
||||||
|
rpLoadPatientsForPicker();
|
||||||
|
|
||||||
|
rpEnsureViewModal().addEventListener('click', (e) => {
|
||||||
|
const editBtn = e.target.closest('[data-edit-row]');
|
||||||
|
const delBtn = e.target.closest('[data-delete-row]');
|
||||||
|
const tbody = document.getElementById('rp_gridBody');
|
||||||
|
if (editBtn) {
|
||||||
|
const row = (tbody._rpRows || []).find(r => String(r.id) === editBtn.dataset.editRow && r.patientId === editBtn.dataset.editPatient);
|
||||||
|
if (row) { rpViewModalInstance.hide(); rpOpenEditModal(row); }
|
||||||
|
} else if (delBtn) {
|
||||||
|
const row = (tbody._rpRows || []).find(r => String(r.id) === delBtn.dataset.deleteRow && r.patientId === delBtn.dataset.deletePatient);
|
||||||
|
if (row) rpDeleteRow(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('rp_searchInput').addEventListener('input', (e) => {
|
||||||
|
rpSearchTerm = e.target.value;
|
||||||
|
rpLoadGrid();
|
||||||
|
});
|
||||||
|
|
||||||
|
rpLoadGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', rpInit);
|
||||||
Loading…
Reference in New Issue
Block a user