Rev 36368 | Blame | Compare with Previous | Last modification | View Log | RSS feed
<link href="https://cdn.datatables.net/fixedcolumns/3.3.0/css/fixedColumns.bootstrap.css" rel="stylesheet"/><style>.timeline-table {width: 100%;border-collapse: collapse;font-size: 12px;}.timeline-table thead th {background: #2c3e50;color: #fff;padding: 6px 5px;text-align: center;font-weight: 600;font-size: 11px;border: 1px solid #34495e;white-space: nowrap;}.timeline-table tbody td {padding: 5px 4px;text-align: center;border: 1px solid #e0e0e0;vertical-align: middle;white-space: nowrap;}.timeline-table tbody tr:nth-child(even) {background-color: #f8f9fa;}.timeline-table tbody tr:hover {background-color: #eaf2ff;}.status-badge {display: inline-block;padding: 2px 6px;border-radius: 10px;font-size: 10px;font-weight: 600;text-transform: uppercase;}.status-open {background: #d4edda;color: #155724;}.status-pending {background: #fff3cd;color: #856404;}.status-completed {background: #cce5ff;color: #004085;}.status-rejected {background: #e2e3e5;color: #383d41;}.tl-cell {display: inline-flex;align-items: center;gap: 5px;}.tl-dot {height: 8px;width: 8px;border-radius: 50%;display: inline-block;flex-shrink: 0;}.tl-dot.completed {background-color: #28a745;}.tl-dot.delay {background-color: #dc3545;}.tl-dot.wip {background-color: #ffc107;}.tl-dot.not-started {background-color: #adb5bd;}.tl-date {font-size: 10px;color: #555;}.legend {display: inline-flex;gap: 14px;flex-wrap: wrap;margin: 0;}.legend-item {display: flex;align-items: center;gap: 5px;font-size: 11px;color: #555;}.dataTables_wrapper .top-controls {display: flex;align-items: center;justify-content: space-between;flex-wrap: nowrap;padding: 8px 0;}.dataTables_wrapper .top-controls .dataTables_length {flex-shrink: 0;}.dataTables_wrapper .top-controls .timeline-legend-row {flex: 1;display: flex;justify-content: center;}.dataTables_wrapper .top-controls .dataTables_filter {flex-shrink: 0;}.section-header {font-size: 11px;color: #95a5a6;text-transform: uppercase;letter-spacing: 0.5px;padding: 2px 0;}.timeline-table tbody tr {cursor: pointer;}.timeline-table tbody tr.row-selected,.DTFC_LeftBodyWrapper table tbody tr.row-selected {background-color: #d6eaf8 !important;}.ob-stepper {display: flex;align-items: center;justify-content: center;margin: 0 0 20px;padding: 0;}.ob-step {display: flex;align-items: center;}.ob-step-circle {width: 28px;height: 28px;border-radius: 50%;display: flex;align-items: center;justify-content: center;font-size: 11px;font-weight: 700;color: #fff;flex-shrink: 0;}.ob-step-circle.completed {background: #28a745;}.ob-step-circle.inProgress {background: #007bff;}.ob-step-circle.notStarted {background: #adb5bd;}.ob-step-line {width: 50px;height: 3px;}.ob-step-line.completed {background: #28a745;}.ob-step-line.inProgress {background: #007bff;}.ob-step-line.notStarted {background: #dee2e6;}.ob-step-label {font-size: 10px;text-align: center;margin-top: 4px;color: #555;}.ob-step-wrap {display: flex;flex-direction: column;align-items: center;}.ob-kv .row {padding: 4px 0;border-bottom: 1px solid #f0f0f0;}.ob-kv .text-muted {font-size: 12px;}.ob-kv .col-sm-8 {font-size: 12px;}.ob-badge-ok {display: inline-block;padding: 2px 8px;border-radius: 3px;background: #d4edda;color: #155724;font-size: 11px;}.ob-badge-fail {display: inline-block;padding: 2px 8px;border-radius: 3px;background: #f8d7da;color: #721c24;font-size: 11px;}.ob-badge-pending {display: inline-block;padding: 2px 8px;border-radius: 3px;background: #fff3cd;color: #856404;font-size: 11px;}.ob-badge-na {display: inline-block;padding: 2px 8px;border-radius: 3px;background: #e2e3e5;color: #6c757d;font-size: 11px;}a.ob-step-circle {text-decoration: none;color: #fff;cursor: pointer;transition: transform 0.15s, box-shadow 0.15s;}a.ob-step-circle:hover {transform: scale(1.2);box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.3);color: #fff;}.ob-action-bar {padding: 8px 10px;margin-bottom: 12px;background: #f8f9fa;border: 1px solid #e9ecef;border-radius: 4px;display: flex;flex-wrap: wrap;gap: 8px;align-items: center;}.ob-action-bar .btn-warning {font-size: 11px;font-weight: 600;}.ob-action-bar .ob-badge-ok {font-size: 11px;}.loi-header {border-bottom: 2px solid #9b59b6;}.onb-header {border-bottom: 2px solid #2980b9;}.store-header {border-bottom: 2px solid #27ae60;}/* Per-row "Delay" summary column on the matrix view.Surfaces the worst-overdue event for each partner so users can spot thebiggest blockers without scanning across all 19 stage cells. */.tl-delay-cell {text-align: center;min-width: 110px;line-height: 1.3;}.tl-delay-chip {display: inline-block;padding: 2px 7px;border-radius: 10px;font-size: 10px;font-weight: 700;color: #fff;}.tl-delay-chip.high {background: #d9534f;}.tl-delay-chip.med {background: #f0ad4e;}.tl-delay-chip.low {background: #f7dc6f;color: #333;}.tl-delay-team {display: block;font-size: 10px;color: #555;margin-top: 2px;max-width: 110px;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}.tl-delay-none {color: #ccc;}/* "Show delayed only" filter toggle styling — sits next to the legend inthe DataTables top-controls flex row. */.tl-delay-filter {display: inline-flex;align-items: center;gap: 6px;font-size: 11px;font-weight: 600;color: #555;margin-left: 16px;cursor: pointer;user-select: none;}.tl-delay-filter input[type="checkbox"] {margin: 0;cursor: pointer;}.tl-delay-filter.active {color: #d9534f;}/* Tab strip — sits between the breadcrumb and the matrix table.Three preset views over the same data: All / Delayed Only / By Team. */.tl-tab-strip {display: flex;align-items: center;gap: 4px;padding: 8px 0 12px;border-bottom: 1px solid #e0e0e0;margin-bottom: 10px;}.tl-tab {background: #fff;border: 1px solid #d0d7de;border-bottom: none;border-radius: 4px 4px 0 0;padding: 8px 16px;font-size: 12px;font-weight: 600;color: #555;cursor: pointer;position: relative;bottom: -1px;transition: background 0.15s, color 0.15s;}.tl-tab:hover {background: #f5f7fa;color: #2c3e50;}.tl-tab.active {background: #2c3e50;color: #fff;border-color: #2c3e50;}.tl-tab i {margin-right: 5px;}.tl-tab-count {display: inline-block;margin-left: 6px;padding: 1px 7px;background: #d9534f;color: #fff;border-radius: 10px;font-size: 10px;font-weight: 700;}.tl-tab.active .tl-tab-count {background: #fff;color: #d9534f;}.tl-team-picker {margin-left: auto;display: flex;align-items: center;gap: 8px;}.tl-team-picker select {height: 30px;font-size: 12px;min-width: 200px;}</style><section class="wrapper"><div class="row"><div class="col-lg-12"><h3 class="page-header" style="color:#2c3e50;"><i class="icon_document_alt"></i> Partner Onboarding Timeline</h3><ol class="breadcrumb"><li><i class="fa fa-home"></i><a href="${rc.contextPath}/dashboard">Home</a></li><li><i class="icon_document_alt"></i>Onboarding Timeline</li></ol></div></div>## Tab strip — three preset views over the same matrix.## "all" = no filter, "delayed" = only rows with active delays, "team" = delayed + team filter dropdown.## Each tab is a button that flips JS filter state and re-draws the DataTable.<div class="col-lg-12 tl-tab-strip"><button type="button" class="tl-tab active" data-tab="all"><i class="fa fa-list"></i> All Partners</button><button type="button" class="tl-tab" data-tab="delayed"><i class="fa fa-exclamation-triangle"></i> Delayed Only#if($worstDelayMap && $worstDelayMap.size() > 0)<span class="tl-tab-count">$worstDelayMap.size()</span>#end</button><button type="button" class="tl-tab" data-tab="team"><i class="fa fa-users"></i> By Team</button><div class="tl-team-picker" id="tlTeamPicker" style="display:none;"><select id="tlTeamFilter" class="form-control input-sm"><option value="">All teams</option></select></div><button type="button" class="btn btn-sm btn-info" id="btnShowSummary" style="margin-left:auto;"><i class="fa fa-bar-chart"></i> Show Summary</button></div><div class="col-lg-12" style="padding:8px 0 4px;"><div class="legend" style="justify-content:flex-start;"><div class="legend-item"><span class="tl-dot completed"></span> Done</div><div class="legend-item"><span class="tl-dot completed" style="outline:3px solid #ffe0e0;"></span><spanstyle="background:#fff0f0;padding:1px 5px;border-radius:3px;margin-left:2px;">Done (crossed TAT)</span></div><div class="legend-item"><span class="tl-dot delay"></span> Crossed TAT</div><div class="legend-item"><span class="tl-dot wip"></span> On Time</div><div class="legend-item"><span class="tl-dot not-started"></span> Not Started</div></div></div><div class="col-lg-12" style="overflow-x:auto;"><table class="timeline-table" id="storeTimeline"><thead><tr><th rowspan="2">ID</th><th rowspan="2">Partner</th><th rowspan="2">Code</th><th rowspan="2">Status</th><th rowspan="2" style="background:#c0392b;">Delay</th><th colspan="4" style="background:#8e44ad;"><span class="section-header">LOI Process</span></th><th colspan="4" style="background:#2471a3;"><span class="section-header">Onboarding</span></th><th colspan="8" style="background:#1e8449;"><span class="section-header">Store Setup</span></th><th colspan="3" style="background:#d35400;"><span class="section-header">Launch</span></th></tr><tr><!-- LOI Process --><th style="background:#9b59b6;">LOI Form</th><th style="background:#9b59b6;">BM Approval</th><th style="background:#9b59b6;">Doc Approval</th><th style="background:#9b59b6;">Payment Approval</th><!-- Onboarding --><th style="background:#2980b9;">Onboarding</th><th style="background:#2980b9;">Verification</th><th style="background:#2980b9;">Store Code</th><th style="background:#2980b9;">Welcome Call</th><!-- Store Setup --><th style="background:#27ae60;">Recce</th><th style="background:#27ae60;">WOD</th><th style="background:#27ae60;">Fin Code</th><th style="background:#27ae60;">All Brand DMS</th><th style="background:#27ae60;">Branding</th><th style="background:#27ae60;">Full Stock</th><th style="background:#27ae60;">PO Creation</th><th style="background:#27ae60;">PO Approval</th><!-- Launch --><th style="background:#e67e22;">Billing</th><th style="background:#e67e22;">Inauguration</th><th style="background:#e67e22;">Training</th></tr></thead><tbody>#foreach($st in $storeTimelines)## Lookup per-row delay metadata once. partnerDelayMap maps eventName -> DelayReportItem## (used for hover tooltips on DELAY cells). worstDelay is the single worst-overdue## item for this partner (drives the new "Delay" column and the row-level filter).#set($partnerDelayMap = $delayIndex.get($st.getOnboardingId()))#set($worstDelay = $worstDelayMap.get($st.getOnboardingId()))#if($worstDelay)<tr data-has-delay="true" data-delay-team="$!{worstDelay.getResponsibleTeam()}">#else<tr data-has-delay="false" data-delay-team="">#end<td><strong>$st.getOnboardingId()</strong></td><td style="text-align:left;max-width:120px;overflow:hidden;text-overflow:ellipsis;"title="$st.getOutletName() ($st.getCity())">$st.getOutletName() ($st.getCity())</td>#if($st.getCode())<td>$st.getCode()</td>#else<td style="color:#ccc;">-</td>#end<td>#set($statusLower = $st.getStatus().toLowerCase())#if($statusLower == "open")<span class="status-badge status-open">$st.getStatus()</span>#elseif($statusLower == "pending")<span class="status-badge status-pending">$st.getStatus()</span>#elseif($statusLower == "completed")<span class="status-badge status-completed">$st.getStatus()</span>#else<span class="status-badge status-rejected">$st.getStatus()</span>#end</td>## --- New "Delay" summary column ---## Renders the worst-overdue event with a severity-coloured chip + responsible team.## Sort key (data-order) is daysOverdue so DataTables sorts numerically, not lexically.#if($worstDelay)#if($worstDelay.getDaysOverdue() > 7)#set($delaySeverity = "high")#elseif($worstDelay.getDaysOverdue() > 3)#set($delaySeverity = "med")#else#set($delaySeverity = "low")#end<td class="tl-delay-cell" data-order="$worstDelay.getDaysOverdue()"title="$worstDelay.getEventName() - planned $worstDelay.getPlannedDate().format($dateFormatter), overdue $worstDelay.getDaysOverdue() day(s), team: $worstDelay.getResponsibleTeam()"><span class="tl-delay-chip $delaySeverity">$worstDelay.getDaysOverdue()d overdue</span><span class="tl-delay-team">$worstDelay.getResponsibleTeam()</span></td>#else<td class="tl-delay-cell tl-delay-none" data-order="-1">-</td>#end## Lookup late-completion metadata for this partner once.## latePartnerMap maps eventName -> DelayReportItem for events completed## after their TAT deadline (daysOverdue = how many days late).#set($latePartnerMap = false)#if($lateIndex)#set($latePartnerMap = $lateIndex.get($st.getOnboardingId()))#end#if($st.getObtm() && $st.getObtm().size() > 0)#foreach($obtm in $st.getObtm())#if($obtm.getStatus().toString() == "COMPLETED")## Check if this event was completed late (after TAT deadline)#set($cellLate = false)#if($latePartnerMap)#set($cellLate = $latePartnerMap.get($obtm.getTitle().name()))#end#if($cellLate)<td style="background:#fff0f0;"title="Completed $cellLate.getDaysOverdue() day(s) late (planned: $cellLate.getPlannedDate().format($dateFormatter), actual: $obtm.getCompletedTimestamp().format($dateFormatter))"><spanclass="tl-cell"><spanclass="tl-dot completed"></span>#if($obtm.getCompletedTimestamp())<spanclass="tl-date">$obtm.getCompletedTimestamp().format($dateFormatter)</span>#end</span></td>#else<td><span class="tl-cell"><spanclass="tl-dot completed"></span>#if($obtm.getCompletedTimestamp())<spanclass="tl-date">$obtm.getCompletedTimestamp().format($dateFormatter)</span>#end</span></td>#end#elseif($obtm.getStatus().toString() == "DELAY")## Look up the matching DelayReportItem to enrich this red cell with## planned date, days overdue, and responsible team in the title tooltip.## $obtm.getTitle() is a StoreTimeline enum; .name() gives the string key.#set($cellDelay = false)#if($partnerDelayMap)#set($cellDelay = $partnerDelayMap.get($obtm.getTitle().name()))#end#if($cellDelay)<td style="background:#fff0f0;"title="Planned: $cellDelay.getPlannedDate().format($dateFormatter) | Overdue: $cellDelay.getDaysOverdue() day(s) | Team: $cellDelay.getResponsibleTeam()"><spanclass="tl-cell"><spanclass="tl-dot delay"></span>#if($obtm.getCompletedTimestamp())<spanclass="tl-date">$obtm.getCompletedTimestamp().format($dateFormatter)</span>#end</span></td>#else<td style="background:#fff0f0;"title="Delayed (planned $!{obtm.getCompletedTimestamp().format($dateFormatter)})"><spanclass="tl-cell"><spanclass="tl-dot delay"></span>#if($obtm.getCompletedTimestamp())<spanclass="tl-date">$obtm.getCompletedTimestamp().format($dateFormatter)</span>#end</span></td>#end#elseif($obtm.getStatus().toString() == "WIP")<td style="background:#fffde7;"><span class="tl-cell"><spanclass="tl-dot wip"></span>#if($obtm.getCompletedTimestamp())<spanclass="tl-date">$obtm.getCompletedTimestamp().format($dateFormatter)</span>#end</span></td>#elseif($obtm.getStatus().toString() == "NOT_STARTED")<td><span class="tl-cell"><spanclass="tl-dot not-started"></span>#if($obtm.getCompletedTimestamp())<spanclass="tl-date">$obtm.getCompletedTimestamp().format($dateFormatter)</span>#end</span></td>#else<td style="color:#ccc;">-</td>#end#end#else#foreach($i in [1..19])<td style="color:#ccc;">-</td>#end#end</tr>#end</tbody></table></div><div id="timeline-detail-container" style="display:none; margin-top:15px;"><div class="panel panel-default"><div class="panel-heading"><button type="button" class="close" id="closeDetailPanel">×</button><h4 class="panel-title"><span id="detail-partner-name"></span><small id="detail-partner-code" class="text-muted" style="margin-left:10px;"></small></h4></div><div class="panel-body" id="timeline-detail-body"><div class="text-center"><i class="fa fa-spinner fa-spin"></i> Loading...</div></div></div></div></section><script type="text/javascript">$(document).ready(function () {// Custom DataTables search filter — applies the active tab's filter rules.// Tab state lives on window so it survives the AJAX re-render of this panel:// storeTimelineActiveTab: 'all' | 'delayed' | 'team'// storeTimelineTeamFilter: string (only meaningful when tab === 'team')// Filter is registered once on window to stay idempotent across re-loads.window.storeTimelineActiveTab = window.storeTimelineActiveTab || 'all';window.storeTimelineTeamFilter = window.storeTimelineTeamFilter || '';if (!window.storeTimelineDelayFilterRegistered) {window.storeTimelineDelayFilterRegistered = true;$.fn.dataTable.ext.search.push(function (settings, data, dataIndex) {if (settings.nTable.id !== 'storeTimeline') return true;var tab = window.storeTimelineActiveTab;if (tab === 'all') return true;var rowNode = settings.aoData[dataIndex].nTr;// Both 'delayed' and 'team' tabs require an active delayif ($(rowNode).attr('data-has-delay') !== 'true') return false;if (tab === 'team' && window.storeTimelineTeamFilter) {return $(rowNode).attr('data-delay-team') === window.storeTimelineTeamFilter;}return true;});}var dtable = $('#storeTimeline').DataTable({scrollX: true,scrollY: '75vh',scrollCollapse: true,orderCellsTop: true,order: [[0, "desc"]],paging: false,fixedColumns: {leftColumns: 5},dom: '<"top-controls"f>rt<"bottom"i>',language: {search: "Search Partners:"}});// Legend is now rendered as static HTML above the table (not inside DataTables controls).// Populate the team dropdown from the unique data-delay-team values on rows.// Only delayed rows have a team set, so this naturally excludes empty values.var teamSet = {};$('#storeTimeline tbody tr[data-has-delay="true"]').each(function () {var team = $(this).attr('data-delay-team');if (team) teamSet[team] = true;});var teamSelect = $('#tlTeamFilter');Object.keys(teamSet).sort().forEach(function (team) {teamSelect.append($('<option></option>').val(team).text(team));});// applyTab — single source of truth for switching tabs.// Updates window state, button highlighting, dropdown visibility,// sort order, and triggers a DataTable redraw.function applyTab(tab) {window.storeTimelineActiveTab = tab;$('.tl-tab').removeClass('active');$('.tl-tab[data-tab="' + tab + '"]').addClass('active');$('#tlTeamPicker').toggle(tab === 'team');if (tab !== 'team') {window.storeTimelineTeamFilter = '';teamSelect.val('');}dtable.order([0, 'desc']).draw();}// Tab click → apply the corresponding filter view$('.tl-tab').on('click', function () {applyTab($(this).data('tab'));});// Team dropdown change → re-apply the team filter (only meaningful on the By Team tab)teamSelect.on('change', function () {window.storeTimelineTeamFilter = $(this).val();dtable.draw();});// Honor an optional defaultTab model attribute set by the controller.// Used by /partnerDelayPanel (which now delegates here) to land users// on the Delayed Only tab without an extra click.var initialTab = '$!{defaultTab}';if (initialTab === 'delayed' || initialTab === 'team') {applyTab(initialTab);}// Row click -> load detail panel$('#storeTimeline tbody').on('click', 'tr', function () {var $row = $(this);var onboardingId = $.trim($row.find('td:first').text());if (!onboardingId || isNaN(onboardingId)) return;// Highlight selected row (both main table and fixed-column clone)$('#storeTimeline tbody tr, .DTFC_LeftBodyWrapper table tbody tr').removeClass('row-selected');var rowIndex = dtable.row($row).index();$('#storeTimeline tbody tr').eq(rowIndex).addClass('row-selected');$('.DTFC_LeftBodyWrapper table tbody tr').eq(rowIndex).addClass('row-selected');var partnerName = $.trim($row.find('td:eq(1)').text());var partnerCode = $.trim($row.find('td:eq(2)').text());$('#detail-partner-name').text(partnerName);$('#detail-partner-code').text(partnerCode !== '-' ? partnerCode : '');$('#timeline-detail-body').html('<div class="text-center"><i class="fa fa-spinner fa-spin"></i> Loading...</div>');$('#storeTimeline_wrapper').hide();$('#timeline-detail-container').show();$.ajax({url: '${rc.contextPath}/partnerTimelineDetail',data: {onboardingId: onboardingId},success: function (html) {$('#timeline-detail-body').html(html);},error: function () {$('#timeline-detail-body').html('<div class="alert alert-danger">Failed to load details.</div>');}});$('html, body').animate({scrollTop: $('#timeline-detail-container').offset().top - 60}, 300);});// Close detail panel$(document).on('click', '#closeDetailPanel', function () {$('#timeline-detail-container').hide();$('#storeTimeline_wrapper').show();dtable.columns.adjust();$('#storeTimeline tbody tr, .DTFC_LeftBodyWrapper table tbody tr').removeClass('row-selected');});// Show Summary modal — renders Chart.js charts on first openvar chartsRendered = false;$('#btnShowSummary').on('click', function () {$('#summaryModal').modal('show');if (chartsRendered) return;chartsRendered = true;// --- Chart 1: Delays by Stage (horizontal bar) ---var stageLabels = [#foreach($e in $delaysByStage.entrySet())'$e.getKey()'#if($foreach.hasNext),#end#end];var stageCounts = [#foreach($e in $delaysByStage.entrySet())$e.getValue()#if($foreach.hasNext),#end#end];new Chart(document.getElementById('chartDelaysByStage'), {type: 'bar',data: {labels: stageLabels,datasets: [{label: 'Delayed Partners',data: stageCounts,backgroundColor: '#e74c3c'}]},options: {indexAxis: 'y',responsive: true,maintainAspectRatio: false,plugins: {legend: {display: false},title: {display: true, text: 'Active Delays by Stage ($totalDelays total)', font: {size: 14}}},scales: {x: {beginAtZero: true, ticks: {stepSize: 1}}}}});// --- Chart 2: Delays by Team (donut) ---var teamLabels = [#foreach($e in $delaysByTeam.entrySet())'$e.getKey()'#if($foreach.hasNext),#end#end];var teamCounts = [#foreach($e in $delaysByTeam.entrySet())$e.getValue()#if($foreach.hasNext),#end#end];var teamColors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#34495e'];new Chart(document.getElementById('chartDelaysByTeam'), {type: 'doughnut',data: {labels: teamLabels,datasets: [{data: teamCounts, backgroundColor: teamColors.slice(0, teamLabels.length)}]},options: {responsive: true,maintainAspectRatio: false,plugins: {title: {display: true, text: 'Delays by Responsible Team', font: {size: 14}},legend: {position: 'right'}}}});// --- Chart 3: Delay Severity (donut) ---new Chart(document.getElementById('chartDelaySeverity'), {type: 'doughnut',data: {labels: ['Critical (>7 days)', 'Medium (3-7 days)', 'Low (\u22643 days)'],datasets: [{data: [$severityCritical, $severityMedium, $severityLow],backgroundColor: ['#d9534f', '#f0ad4e', '#f7dc6f']}]},options: {responsive: true,maintainAspectRatio: false,plugins: {title: {display: true, text: 'Delay Severity Breakdown', font: {size: 14}},legend: {position: 'right'}}}});// --- Chart 4: Late Completions by Stage (horizontal bar) ---var lateLabels = [#foreach($e in $lateByStage.entrySet())'$e.getKey()'#if($foreach.hasNext),#end#end];var lateCounts = [#foreach($e in $lateByStage.entrySet())$e.getValue()#if($foreach.hasNext),#end#end];new Chart(document.getElementById('chartLateByStage'), {type: 'bar',data: {labels: lateLabels,datasets: [{label: 'Late Completions',data: lateCounts,backgroundColor: '#e67e22'}]},options: {indexAxis: 'y',responsive: true,maintainAspectRatio: false,plugins: {legend: {display: false},title: {display: true, text: 'Late Completions by Stage ($totalLate total)', font: {size: 14}}},scales: {x: {beginAtZero: true, ticks: {stepSize: 1}}}}});});});</script><script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>## Summary Modal<div class="modal fade" id="summaryModal" tabindex="-1" role="dialog"><div class="modal-dialog" style="width:90%;max-width:1100px;"><div class="modal-content"><div class="modal-header" style="background:#2c3e50;color:#fff;"><button type="button" class="close" data-dismiss="modal" style="color:#fff;opacity:0.8;">×</button><h4 class="modal-title"><i class="fa fa-bar-chart"></i> Onboarding Timeline Summary</h4></div><div class="modal-body" style="padding:20px;"><div class="row"><div class="col-md-6" style="height:320px;margin-bottom:20px;"><canvas id="chartDelaysByStage"></canvas></div><div class="col-md-6" style="height:320px;margin-bottom:20px;"><canvas id="chartDelaysByTeam"></canvas></div></div><div class="row"><div class="col-md-6" style="height:320px;margin-bottom:20px;"><canvas id="chartDelaySeverity"></canvas></div><div class="col-md-6" style="height:320px;margin-bottom:20px;"><canvas id="chartLateByStage"></canvas></div></div></div><div class="modal-footer"><button type="button" class="btn btn-default" data-dismiss="modal">Close</button></div></div></div></div>