Compare commits

...

27 Commits

Author SHA1 Message Date
35558611cb Card row 2026-08-28 11:56:58 +00:00
6fcfecfa19 Update resources 28082027 2026-08-28 11:40:46 +00:00
3e5d2bea42 Updated changes codition and all 2026-08-28 11:20:02 +00:00
ce894a0b72 nisha capstone project 28-08-2026 2026-08-28 10:39:35 +00:00
e2125311ef Cachebusting 2026-08-28 10:32:48 +00:00
c589c09895 Dashboardbtml 2026-08-27 15:02:32 +00:00
1276b205a3 Updated dashboard css ja 2026-08-27 14:50:25 +00:00
d25a2aefcd Risk question 2026-08-27 14:49:32 +00:00
3793462363 Risk scoring updated file 2026-08-27 14:31:12 +00:00
e9a97a4c07 Ptinet page 2026-08-27 14:11:42 +00:00
4f61fcb392 Dashboard bundle 2026-08-27 14:07:17 +00:00
6143b192d8 Cdsert updated 2026-08-27 13:56:53 +00:00
3c628b58b0 Risk asses resources 2026-08-27 13:48:25 +00:00
b9ba92e484 R EA ource page updated 2026-08-27 13:40:13 +00:00
543afd3d6c Updated resource 2026-08-27 13:20:50 +00:00
c0a9d920bc Resiurce 2026-08-27 13:03:16 +00:00
718cddc720 Cds dashboard 2026-08-27 12:31:49 +00:00
f822c636ae Cds controoler 2026-08-27 12:23:32 +00:00
a64f3c5522 Cds updated files 2026-08-27 12:18:47 +00:00
395fb29405 Upload files to ".Net Capstone Project" 2026-08-27 12:11:04 +00:00
56f9e902a6 Upload files to ".Net Capstone Project" 2026-08-27 11:29:36 +00:00
c1703b0ca6 Upload files to ".Net Capstone Project" 2026-08-27 11:02:19 +00:00
83ba4b3848 Patient file 2026-08-27 10:54:45 +00:00
b6274e671f Proejcg 2026-08-27 10:41:28 +00:00
a84727e8a2 Clinical sidemav 2026-08-27 10:25:27 +00:00
bc0f3f8d82 nisha ppt 2026-08-21 15:28:13 +05:30
5418b0fd6f Merge pull request 'New Pull' (#1) from main into himanshu_dotnet_capstone_project
Reviewed-on: #1
2026-08-21 09:32:34 +00:00
26 changed files with 864 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,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
};
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
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>

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function rpFmtDate(value) {
if (!value) return '—';
const d = new Date(value);
if (isNaN(d)) return '—';
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function rpShowToast(message, variant = 'success') {
const container = document.getElementById('toastContainer');
if (!container) { alert(message); return; }
const el = document.createElement('div');
el.className = `toast align-items-center text-bg-${variant === 'danger' ? 'danger' : 'success'} border-0 show mb-2`;
el.innerHTML = `<div class="d-flex"><div class="toast-body">${rpEscapeHtml(message)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" onclick="this.closest('.toast').remove()"></button></div>`;
container.appendChild(el);
setTimeout(() => el.remove(), 5000);
}
function rpFieldInputHtml(field, value) {
const id = `rp_field_${field.key}`;
const val = value !== undefined && value !== null ? value : (field.default || '');
if (field.type === 'select') {
return `<div class="col-12">
<label class="form-label small">${rpEscapeHtml(field.label)}</label>
<select class="form-select" id="${id}">
${field.options.map(o => `<option value="${o}" ${o === val ? 'selected' : ''}>${o}</option>`).join('')}
</select>
</div>`;
}
if (field.type === '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);