Skip to content

Commit 33a5644

Browse files
LEGLINK-747: Automation UI: Sort Patient in ascending order. (#1783)
* Began working on setting up the sort for Patients in ascending order in the manifest * I added a check so that it only rerenders the patient breakdown when source patient entries exist. --------- Co-authored-by: nmLantana <nick.montalto@lantanagroup.com>
1 parent a27be16 commit 33a5644

1 file changed

Lines changed: 208 additions & 47 deletions

File tree

DotNet/Automation.UI/Views/Runs/Manifest.cshtml

Lines changed: 208 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,40 @@
193193
// ─── Fetch & Render ────────────────────────────────────────────
194194
let manifestData = null;
195195
let fallbackInpatientPatternByPatient = null;
196+
let patientSortDirection = 'asc';
197+
198+
function comparePatientIds(left, right) {
199+
return String(left).localeCompare(String(right), undefined, {
200+
numeric: true,
201+
sensitivity: 'base'
202+
});
203+
}
204+
205+
function sortPatientEntries(entries) {
206+
const direction = patientSortDirection === 'desc' ? -1 : 1;
207+
208+
return [...entries].sort(([leftId], [rightId]) =>
209+
direction * comparePatientIds(leftId, rightId)
210+
);
211+
}
212+
213+
function patientSortIcon() {
214+
return patientSortDirection === 'asc'
215+
? '<i class="bi bi-sort-up ms-1"></i>'
216+
: '<i class="bi bi-sort-down ms-1"></i>';
217+
}
218+
219+
function togglePatientSort() {
220+
patientSortDirection =
221+
patientSortDirection === 'asc' ? 'desc' : 'asc';
222+
223+
if (!manifestData) return;
224+
225+
renderEligibility(manifestData);
226+
if (allPatientEntries.length > 0) {
227+
renderPatientBreakdown();
228+
}
229+
}
196230
197231
fetch(`${manifestDataUrl}?id=${runId}`)
198232
.then(r => r.ok && r.status !== 204 ? r.json() : null)
@@ -257,24 +291,7 @@
257291
<div><div class="small text-muted mb-1">CQL Referenced</div>${renderBadgeSet(m.cqlReferencedResourceTypes)}</div>`;
258292
259293
// Eligibility
260-
const el = document.getElementById('eligibilitySection');
261-
const eb = document.getElementById('eligibilityBadge');
262-
if (m.patientEligibility && Object.keys(m.patientEligibility).length) {
263-
const entries = Object.entries(m.patientEligibility);
264-
const q = entries.filter(([, ms]) => ms.length > 0).length;
265-
eb.textContent = `${q}/${entries.length} qualifying`;
266-
let html = '<div style="max-height:300px;overflow-y:auto;"><table class="au-table table table-sm small mb-0"><thead><tr><th>Patient</th><th>Qualifying Measures</th></tr></thead><tbody>';
267-
entries.forEach(([pid, measures]) => {
268-
const b = measures.length > 0
269-
? `<span class="badge au-badge-success">${measures.length} measure(s)</span>`
270-
: `<span class="badge au-badge-muted">None</span>`;
271-
html += `<tr><td><code>${escapeHtml(pid)}</code></td><td>${b}</td></tr>`;
272-
});
273-
html += '</tbody></table></div>';
274-
el.innerHTML = html;
275-
} else {
276-
el.innerHTML = '<span class="text-muted small">No eligibility data</span>';
277-
}
294+
renderEligibility(m);
278295
279296
// Resource counts table
280297
const allCounts = { ...(m.totalCountsByType || {}) };
@@ -320,31 +337,140 @@
320337
}
321338
}
322339
340+
function renderEligibility(m) {
341+
const el = document.getElementById('eligibilitySection');
342+
const eb = document.getElementById('eligibilityBadge');
343+
344+
if (!m.patientEligibility ||
345+
!Object.keys(m.patientEligibility).length) {
346+
eb.textContent = '';
347+
el.innerHTML =
348+
'<span class="text-muted small">No eligibility data</span>';
349+
return;
350+
}
351+
352+
const entries = sortPatientEntries(
353+
Object.entries(m.patientEligibility)
354+
);
355+
356+
const qualifyingCount = entries.filter(
357+
([, measures]) => measures.length > 0
358+
).length;
359+
360+
eb.textContent =
361+
`${qualifyingCount}/${entries.length} qualifying`;
362+
363+
let html = `
364+
<div style="max-height:300px;overflow-y:auto;">
365+
<table class="au-table table table-sm small mb-0">
366+
<thead>
367+
<tr>
368+
<th>
369+
<button type="button"
370+
class="btn btn-link btn-sm p-0 text-white text-decoration-none patient-sort-button"
371+
aria-label="Sort patient IDs ${patientSortDirection === 'asc' ? 'descending' : 'ascending'}">
372+
Patient
373+
${patientSortIcon()}
374+
</button>
375+
</th>
376+
<th>Qualifying Measures</th>
377+
</tr>
378+
</thead>
379+
<tbody>`;
380+
381+
entries.forEach(([pid, measures]) => {
382+
const badge = measures.length > 0
383+
? `<span class="badge au-badge-success">${measures.length} measure(s)</span>`
384+
: '<span class="badge au-badge-muted">None</span>';
385+
386+
html += `
387+
<tr>
388+
<td><code>${escapeHtml(pid)}</code></td>
389+
<td>${badge}</td>
390+
</tr>`;
391+
});
392+
393+
html += '</tbody></table></div>';
394+
el.innerHTML = html;
395+
396+
el.querySelector('.patient-sort-button')
397+
?.addEventListener('click', togglePatientSort);
398+
}
399+
323400
// ─── PATIENTS TAB ──────────────────────────────────────────────
401+
let allPatientEntries = [];
402+
let patientBarChart = null;
403+
324404
function renderPatients(m) {
325-
const container = document.getElementById('patientBreakdownContainer');
326-
const patients = Object.entries(m.resourceCountsByPatient || {});
327-
if (!patients.length) {
328-
container.innerHTML = '<span class="text-muted small">No per-patient data</span>';
405+
allPatientEntries = Object.entries(
406+
m.resourceCountsByPatient || {}
407+
);
408+
409+
const container =
410+
document.getElementById('patientBreakdownContainer');
411+
412+
if (!allPatientEntries.length) {
413+
container.innerHTML =
414+
'<span class="text-muted small">No per-patient data</span>';
329415
return;
330416
}
331417
332-
fallbackInpatientPatternByPatient = buildFallbackInpatientPatternMap(m);
418+
fallbackInpatientPatternByPatient =
419+
buildFallbackInpatientPatternMap(m);
420+
421+
renderPatientBreakdown();
422+
423+
const searchInput = document.getElementById('patientSearch');
424+
425+
searchInput.addEventListener('input', function () {
426+
renderPatientBreakdown();
427+
});
428+
}
429+
430+
function getVisiblePatientEntries() {
431+
const searchTerm =
432+
document.getElementById('patientSearch')
433+
?.value
434+
?.trim()
435+
?.toLowerCase() || '';
436+
437+
const filtered = allPatientEntries.filter(([pid]) =>
438+
!searchTerm || pid.toLowerCase().includes(searchTerm)
439+
);
440+
441+
return sortPatientEntries(filtered);
442+
}
443+
444+
function renderPatientBreakdown() {
445+
const patients = getVisiblePatientEntries();
333446
334-
// Bar chart: total resources per patient (top 50)
335-
const patientTotals = patients.map(([pid, counts]) => ({
336-
pid,
337-
total: Object.values(counts).reduce((s, c) => s + c, 0)
338-
})).sort((a, b) => b.total - a.total);
447+
renderPatientBarChart(patients);
448+
renderPatientCards(patients);
449+
}
450+
451+
function renderPatientBarChart(patients) {
452+
const patientTotals = patients
453+
.map(([pid, counts]) => ({
454+
pid,
455+
total: Object.values(counts)
456+
.reduce((sum, count) => sum + count, 0)
457+
}))
458+
.slice(0, 50);
459+
460+
const chartCanvas =
461+
document.getElementById('patientBarChart');
339462
340-
const chartPatients = patientTotals.slice(0, 50);
341-
new Chart(document.getElementById('patientBarChart'), {
463+
if (patientBarChart) {
464+
patientBarChart.destroy();
465+
}
466+
467+
patientBarChart = new Chart(chartCanvas, {
342468
type: 'bar',
343469
data: {
344-
labels: chartPatients.map(p => p.pid),
470+
labels: patientTotals.map(patient => patient.pid),
345471
datasets: [{
346472
label: 'Total Resources',
347-
data: chartPatients.map(p => p.total),
473+
data: patientTotals.map(patient => patient.total),
348474
backgroundColor: 'var(--au-accent)',
349475
borderRadius: 3
350476
}]
@@ -353,36 +479,61 @@
353479
responsive: true,
354480
maintainAspectRatio: true,
355481
plugins: {
356-
legend: { display: false },
357-
tooltip: { callbacks: { label: ctx => `${ctx.parsed.y.toLocaleString()} resources` } }
482+
legend: {
483+
display: false
484+
},
485+
tooltip: {
486+
callbacks: {
487+
label: context =>
488+
`${context.parsed.y.toLocaleString()} resources`
489+
}
490+
}
358491
},
359492
scales: {
360-
x: { ticks: { font: { size: 10 }, maxRotation: 45 } },
361-
y: { beginAtZero: true, ticks: { callback: v => v.toLocaleString() } }
493+
x: {
494+
ticks: {
495+
font: { size: 10 },
496+
maxRotation: 45
497+
}
498+
},
499+
y: {
500+
beginAtZero: true,
501+
ticks: {
502+
callback: value => value.toLocaleString()
503+
}
504+
}
362505
}
363506
}
364507
});
365-
366-
// Patient cards
367-
renderPatientCards(patients);
368-
369-
// Search
370-
document.getElementById('patientSearch').addEventListener('input', function () {
371-
const q = this.value.toLowerCase();
372-
const filtered = patients.filter(([pid]) => pid.toLowerCase().includes(q));
373-
renderPatientCards(filtered);
374-
});
375508
}
376509
377510
function renderPatientCards(patients) {
378511
const container = document.getElementById('patientBreakdownContainer');
512+
disposeTooltips(container);
379513
if (!patients.length) {
380514
container.innerHTML = '<span class="text-muted small">No matching patients</span>';
381515
return;
382516
}
383517
384518
// Compact table view for many patients
385-
let html = '<table class="au-table table table-sm small mb-0"><thead><tr><th>Patient</th><th>Inpatient Pattern</th><th class="text-end">Total</th><th>Resource Types</th></tr></thead><tbody>';
519+
let html = `
520+
<table class="au-table table table-sm small mb-0">
521+
<thead>
522+
<tr>
523+
<th>
524+
<button type="button"
525+
class="btn btn-link btn-sm p-0 text-white text-decoration-none patient-sort-button"
526+
aria-label="Sort patient IDs ${patientSortDirection === 'asc' ? 'descending' : 'ascending'}">
527+
Patient
528+
${patientSortIcon()}
529+
</button>
530+
</th>
531+
<th>Inpatient Pattern</th>
532+
<th class="text-end">Total</th>
533+
<th>Resource Types</th>
534+
</tr>
535+
</thead>
536+
<tbody>`;
386537
patients.forEach(([pid, counts]) => {
387538
const total = Object.values(counts).reduce((s, c) => s + c, 0);
388539
const inpatientPattern = manifestData?.patientInpatientPatterns?.[pid]
@@ -400,10 +551,20 @@
400551
});
401552
html += '</tbody></table>';
402553
container.innerHTML = html;
554+
container.querySelector('.patient-sort-button')
555+
?.addEventListener('click', togglePatientSort);
403556
// Re-init tooltips for new elements
404557
container.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
405558
}
406559
560+
function disposeTooltips(container) {
561+
container
562+
.querySelectorAll('[data-bs-toggle="tooltip"]')
563+
.forEach(el => {
564+
bootstrap.Tooltip.getInstance(el)?.dispose();
565+
});
566+
}
567+
407568
function normalizePatternForDisplay(value) {
408569
if (value === null || value === undefined || value === '') return defaultScheduledPattern;
409570
const key = String(value).trim();

0 commit comments

Comments
 (0)