tinyqc/app/Views/report/merged.php
2026-03-03 09:37:05 +07:00

959 lines
35 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto" x-data="reportMergedModule()">
<div class="flex justify-between items-center mb-6 no-print">
<div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Merged QC Report</h1>
<p class="text-sm mt-1 opacity-70">All controls combined in a single vertical chart</p>
</div>
<div class="flex gap-2">
<button @click="window.print()" class="btn btn-outline btn-sm" :disabled="!selectedTest">
<i class="fa-solid fa-print mr-2"></i>
Print Report
</button>
</div>
</div>
<!-- Filters -->
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6 no-print">
<div class="flex flex-wrap gap-6 items-center">
<div class="form-control">
<label class="label pt-0">
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Reporting Month</span>
</label>
<div class="join">
<button @click="prevMonth()" class="join-item btn btn-sm btn-outline border-base-300"><i
class="fa-solid fa-chevron-left"></i></button>
<input type="month" x-model="month" @change="onMonthChange()"
class="join-item input input-bordered input-sm w-36 text-center font-medium bg-base-200/30">
<button @click="nextMonth()" class="join-item btn btn-sm btn-outline border-base-300"><i
class="fa-solid fa-chevron-right"></i></button>
</div>
</div>
<div class="divider divider-horizontal mx-0 hidden sm:flex"></div>
<div class="form-control flex-1 max-w-xs">
<label class="label pt-0">
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Select Test</span>
</label>
<select x-model="selectedTest" @change="fetchData()"
class="select select-bordered select-sm w-full font-medium">
<option value="">Choose a test...</option>
<template x-for="test in tests" :key="test.testId">
<option :value="String(test.testId)" x-text="test.testName"></option>
</template>
</select>
</div>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex flex-col items-center justify-center py-24 gap-4">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-sm opacity-50 animate-pulse">Generating merged report...</p>
</div>
<!-- Empty State -->
<div x-show="!loading && !selectedTest"
class="bg-base-100 rounded-2xl border-2 border-dashed border-base-300 p-16 text-center">
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fa-solid fa-chart-bar text-2xl opacity-20"></i>
</div>
<h3 class="font-bold text-lg">No Test Selected</h3>
<p class="text-base-content/60 max-w-xs mx-auto">Please select a laboratory test to generate the merged report.
</p>
</div>
<!-- Report Content -->
<div x-show="!loading && selectedTest && controls.length > 0" class="space-y-6 animate-in fade-in duration-500">
<!-- Report Header -->
<div class="mb-6 print:mb-4">
<!-- Main Header -->
<div class="text-center border-b-2 border-black pb-3 mb-3">
<h1 class="text-2xl font-black uppercase tracking-widest mb-1">Internal QC</h1>
<h2 class="text-lg font-bold" x-text="departmentDisplayName"></h2>
<h3 class="text-sm font-bold uppercase tracking-wide">Trisensa Diagnostic Centre</h3>
</div>
<!-- Sub Header -->
<div class="flex justify-between items-center border-b border-gray-400 pb-2">
<div>
<span class="text-xs uppercase font-bold opacity-60">Test:</span>
<span class="text-base font-bold ml-1" x-text="testName"></span>
</div>
<div>
<span class="text-xs uppercase font-bold opacity-60">Period:</span>
<span class="text-base font-bold ml-1" x-text="monthDisplay"></span>
</div>
</div>
</div>
<!-- Summary Stats - Compact Horizontal Table -->
<div class="mb-4 print:mb-2">
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="table table-sm w-full">
<thead class="bg-base-200/50">
<tr>
<th class="text-left whitespace-nowrap">Control</th>
<th class="text-center">Lot</th>
<th class="text-center">N</th>
<th class="text-center">Mean</th>
<th class="text-center">SD</th>
<th class="text-center">CV%</th>
<th class="text-center">Target</th>
<th class="text-center">Bias%</th>
</tr>
</thead>
<tbody>
<template x-for="control in processedControls" :key="control.controlId">
<tr class="hover:bg-base-200/30">
<td class="font-bold whitespace-nowrap" x-text="control.controlName"></td>
<td class="text-center font-mono text-[10px]" x-text="control.lot || 'N/A'"></td>
<td class="text-center font-mono" x-text="control.stats.n || 0"></td>
<td class="text-center font-mono" x-text="formatNum(control.stats.mean)"></td>
<td class="text-center font-mono" x-text="formatNum(control.stats.sd)"></td>
<td class="text-center font-mono"
:class="control.stats.cv > 5 ? 'text-warning' : 'text-success'"
x-text="formatNum(control.stats.cv, 1) + '%'"></td>
<td class="text-center font-mono text-[10px]"
x-text="formatNum(control.mean) + '±' + formatNum(2*control.sd)"></td>
<td class="text-center font-mono"
:class="getBiasClass(control)"
x-text="control.stats.mean ? getBias(control) + '%' : '-'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Table and Chart Grid -->
<div class="flex gap-6 items-start">
<!-- Monthly Table (Left) -->
<div class="w-full lg:w-1/2">
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
<div class="p-4 border-b border-base-300 bg-base-200/50 print:p-2">
<h3 class="font-bold text-sm uppercase tracking-widest opacity-70 print:text-[10px]">Daily Results Log</h3>
</div>
<div>
<table class="table table-xs w-full">
<thead class="bg-base-200/50 sticky top-0">
<tr>
<th class="w-10 text-center print:px-1">Day</th>
<template x-for="control in controls" :key="control.controlId">
<th class="text-center print:px-1 whitespace-normal break-words min-w-[60px] max-w-[100px] leading-tight" x-text="control.controlName"></th>
</template>
</tr>
</thead>
<tbody>
<template x-for="day in daysInMonth" :key="day">
<tr :class="isWeekend(day) ? 'bg-base-200/30' : ''">
<td class="text-center font-mono font-bold print:px-1" x-text="day"></td>
<template x-for="control in controls" :key="control.controlId">
<td class="text-center print:px-1">
<div class="flex flex-col gap-0.5">
<span class="font-mono text-[11px] print:text-[10px]"
:class="getValueClass(control, day)"
x-text="getResValue(control, day)"></span>
<span x-show="getResComment(control, day)"
class="text-[8px] opacity-40 italic block max-w-[80px] truncate mx-auto print:hidden"
:title="getResComment(control, day)"
x-text="getResComment(control, day)"></span>
</div>
</td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Merged Chart Section (Right) -->
<div class="w-full lg:w-1/2 px-6">
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4">
<div class="flex justify-between items-center mb-4 print:mb-1">
<h3 class="font-bold text-sm opacity-70 uppercase tracking-widest print:text-[10px]">
Merged Levey-Jennings Chart (All Controls)
</h3>
<div class="flex gap-2 text-[10px] print:text-[8px]">
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-success"></span>In Range</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-error"></span>Out Range</span>
</div>
</div>
<div style="height: 600px;" class="w-full relative">
<canvas id="mergedChart" class="w-full h-full"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Error State -->
<div x-show="!loading && selectedTest && controls.length === 0"
class="bg-base-100 rounded-2xl border-2 border-dashed border-base-300 p-16 text-center">
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4 text-warning">
<i class="fa-solid fa-triangle-exclamation text-2xl"></i>
</div>
<h3 class="font-bold text-lg">No Data Found</h3>
<p class="text-base-content/60 max-w-xs mx-auto">There are no records found for this test and month. Please
check the selection or enter data in the Entry module.</p>
</div>
</main>
<style>
@media print {
@page {
size: portrait;
margin: 3mm;
}
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
color: #000 !important;
}
/* Force black and white except canvas */
* {
color: #000 !important;
border-color: #333 !important;
}
canvas, canvas * {
color: initial !important;
}
/* Force white backgrounds */
.bg-base-100, .bg-base-200, .bg-base-300, [class*="bg-base-"],
.table thead, .table tbody, .table tr, .table td, .table th {
background-color: #fff !important;
background: #fff !important;
}
/* Ensure header is visible in print */
[class*="print:mb-4"] {
display: block !important;
}
/* Header styling for print */
.border-b-2.border-black {
border-bottom: 2px solid #000 !important;
}
.border-b.border-gray-400 {
border-bottom: 1px solid #666 !important;
}
/* Compact control summary table */
.table-compact th, .table-compact td {
padding: 4px 3px !important;
font-size: 7px !important;
line-height: 1.3 !important;
border: 1px solid #ccc !important;
}
.table-compact th {
font-weight: bold !important;
background-color: #f5f5f5 !important;
}
/* Remove card styling in print */
.rounded-xl, .rounded-2xl {
border-radius: 0 !important;
}
.shadow-sm, .shadow {
box-shadow: none !important;
}
.no-print,
.navbar,
footer,
.drawer-side,
.divider {
display: none !important;
}
/* Remove hiding of first child - we need the header visible */
/* .space-y-6 > div:first-child was hiding the header */
.space-y-6 > div:nth-child(2) {
margin-top: 0 !important;
}
html, body {
margin: 0 !important;
padding: 0 !important;
}
main {
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
}
/* Report content - natural height */
.space-y-6 {
display: block !important;
gap: 8px !important;
}
.shadow-sm,
.shadow,
.shadow-md,
.shadow-lg,
.shadow-xl,
.shadow-2xl {
box-shadow: none !important;
}
.border,
.border-base-300,
.rounded-xl,
.rounded-2xl {
border: 1px solid #ddd !important;
border-radius: 4px !important;
}
canvas {
max-width: 100% !important;
height: auto !important;
}
.table-xs th, .table-xs td {
padding: 4px 3px !important;
font-size: 7px !important;
line-height: 1.4 !important;
}
.table {
font-size: 7px !important;
line-height: 1.4 !important;
}
.table tr {
height: auto !important;
min-height: 20px !important;
}
.table tbody tr {
padding: 4px 0 !important;
}
.overflow-x-auto {
overflow: visible !important;
}
.max-h-\[600px\] {
max-height: none !important;
overflow-y: visible !important;
}
/* Force 2-column layout in print */
.space-y-6 > .flex.gap-6 {
flex-direction: row !important;
display: flex !important;
gap: 8px !important;
page-break-inside: avoid;
break-inside: avoid;
}
[class*="lg:w-1/3"] {
width: 35% !important;
flex: 0 0 35% !important;
max-width: 35% !important;
page-break-inside: avoid;
break-inside: avoid;
}
[class*="lg:w-2/3"] {
width: 65% !important;
flex: 0 0 65% !important;
max-width: 65% !important;
page-break-inside: avoid;
break-inside: avoid;
}
.print\:block {
display: flex !important;
}
.print\:mb-6 {
margin-bottom: 0 !important;
}
.space-y-6 > * + * {
margin-top: 1px !important;
}
.flex-wrap.gap-3 {
gap: 4px !important;
}
.flex-wrap > div {
min-height: auto !important;
}
.flex-wrap .grid-cols-2 {
gap: 2px !important;
}
[class*="text-[8px]"] {
font-size: 6px !important;
}
[class*="text-[9px]"] {
font-size: 7px !important;
}
.table-xs span[class*="text-"] {
font-size: 6px !important;
}
.animate-in,
.fade-in {
animation: none !important;
}
/* Chart fills available height */
.h-\[560px\] {
flex: 1 !important;
height: auto !important;
min-height: 400px !important;
}
canvas#mergedChart {
width: 100% !important;
height: 100% !important;
}
.h-full {
height: 100% !important;
}
.print\:break-inside-avoid {
break-inside: auto !important;
page-break-inside: auto !important;
}
.print\:p-2 {
padding: 2px !important;
}
[class*="print:text-[10px]"] {
font-size: 8px !important;
}
[class*="print:text-[11px]"] {
font-size: 8px !important;
}
.print\\:scale-75 {
transform: none !important;
}
.badge-xs {
font-size: 6px !important;
padding: 1px 3px !important;
}
[class*="text-[9px]"] {
font-size: 7px !important;
}
[class*="text-[10px]"] {
font-size: 8px !important;
}
[class*="text-[11px]"] {
font-size: 8px !important;
}
.text-xs {
font-size: 8px !important;
}
.text-sm {
font-size: 9px !important;
}
.text-lg {
font-size: 11px !important;
}
.text-2xl {
font-size: 12px !important;
}
.text-3xl {
font-size: 14px !important;
}
.font-bold.text-lg {
font-size: 10px !important;
}
.font-bold.uppercase {
font-size: 8px !important;
}
.p-4 {
padding: 2px !important;
}
.mb-4,
.mb-6 {
margin-bottom: 2px !important;
}
.mb-4,
.mb-6 {
margin-bottom: 4px !important;
}
.gap-3 {
gap: 4px !important;
}
.gap-6 {
gap: 4px !important;
}
.flex-col.lg\:flex-row {
gap: 4px !important;
}
.gap-2 {
gap: 2px !important;
}
.space-y-6 > * + * {
margin-top: 4px !important;
}
.p-2 {
padding: 2px !important;
}
/* Remove chart legend in print to save space */
.chart-legend,
.legend,
canvas + div,
[class*="legend"] {
display: none !important;
}
/* Minimize chart title area */
[class*="print:mb-1"] {
margin-bottom: 2px !important;
}
/* Ensure chart title is tiny */
[class*="print:text-[10px]"] {
font-size: 7px !important;
}
/* Remove extra padding from chart container */
.p-4.h-full {
padding: 4px !important;
}
}
</style>
<?= $this->endSection(); ?>
<?= $this->section("script"); ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("reportMergedModule", () => ({
tests: [],
selectedTest: '',
controls: [],
processedControls: [],
loading: false,
month: '',
mergedChart: null,
lastRequestId: 0,
controlColors: [
{ border: '#570df8', bg: '#570df820' },
{ border: '#ff52d9', bg: '#ff52d920' },
{ border: '#10b981', bg: '#10b98120' },
{ border: '#f59e0b', bg: '#f59e0b20' },
{ border: '#ef4444', bg: '#ef444420' },
{ border: '#06b6d4', bg: '#06b6d420' },
],
init() {
const now = new Date();
this.month = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
this.fetchTests();
},
async fetchTests() {
try {
const response = await fetch(`${BASEURL}api/master/tests`);
const json = await response.json();
this.tests = json.data || [];
} catch (e) {
console.error('Failed to fetch tests:', e);
}
},
onMonthChange() {
if (this.selectedTest) {
this.fetchData();
}
},
async fetchData() {
if (!this.selectedTest) return;
const requestId = ++this.lastRequestId;
this.loading = true;
try {
const params = new URLSearchParams({
test_id: this.selectedTest,
month: this.month
});
const response = await fetch(`${BASEURL}api/entry/monthly?${params}`);
const json = await response.json();
if (requestId !== this.lastRequestId) return;
if (json.status === 'success') {
this.controls = json.data.controls || [];
this.processedControls = this.controls.map(c => {
const stats = this.calculateStats(c.results);
return { ...c, stats };
});
this.$nextTick(() => {
this.renderMergedChart();
});
}
} catch (e) {
if (requestId === this.lastRequestId) {
console.error('Failed to fetch data:', e);
}
} finally {
if (requestId === this.lastRequestId) {
this.loading = false;
}
}
},
calculateStats(results) {
if (!results || typeof results !== 'object') return { n: 0, mean: null, sd: null, cv: null };
const values = Object.values(results)
.filter(r => r !== null && r !== undefined)
.map(r => parseFloat(r.resValue))
.filter(v => !isNaN(v));
if (values.length === 0) return { n: 0, mean: null, sd: null, cv: null };
const n = values.length;
const mean = values.reduce((a, b) => a + b, 0) / n;
const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (n > 1 ? n - 1 : 1);
const sd = Math.sqrt(variance);
const cv = mean !== 0 ? (sd / mean) * 100 : 0;
return { n, mean, sd, cv };
},
renderMergedChart() {
if (this.mergedChart) {
this.mergedChart.destroy();
this.mergedChart = null;
}
const canvas = document.getElementById('mergedChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Deep clone to break reactivity
const controls = JSON.parse(JSON.stringify(this.controls));
const days = JSON.parse(JSON.stringify(this.daysInMonth));
if (!controls || controls.length === 0) return;
const colors = ['#570df8', '#ff52d9', '#10b981', '#f59e0b', '#ef4444', '#06b6d4'];
const bgColors = ['#570df820', '#ff52d920', '#10b98120', '#f59e0b20', '#ef444420', '#06b6d420'];
const labels = days;
const datasets = [];
// Store actual values for tooltips
const actualValues = {};
controls.forEach((control, index) => {
const mean = parseFloat(control.mean) || 0;
const sd = parseFloat(control.sd) || 0;
const color = colors[index % colors.length];
const bgColor = bgColors[index % bgColors.length];
const results = control.results || {};
const controlKey = control.controlId || index;
actualValues[controlKey] = {};
const dataPoints = days.map(day => {
const res = results[day];
if (res && res.resValue !== null && mean !== 0 && sd !== 0) {
const val = parseFloat(res.resValue);
// Store actual value for tooltip
actualValues[controlKey][day] = val;
// Convert to deviation from mean in SD units
return (val - mean) / sd;
}
actualValues[controlKey][day] = null;
return null;
});
const pointColors = dataPoints.map(v => {
if (v === null) return color;
// Red if outside ±2SD, otherwise use control color
return Math.abs(v) > 2 ? '#ef4444' : color;
});
datasets.push({
label: String(control.controlName || ''),
data: dataPoints,
borderColor: color,
backgroundColor: bgColor,
borderWidth: 2,
pointRadius: 4,
pointBackgroundColor: pointColors,
pointBorderColor: pointColors,
pointHoverRadius: 6,
spanGaps: true,
tension: 0.1,
// Store reference data for tooltip
controlMean: mean,
controlSd: sd,
controlKey: controlKey
});
});
const chartData = {
labels: labels,
datasets: datasets
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
usePointStyle: true,
pointStyle: 'line',
font: { size: 11 }
}
},
tooltip: {
callbacks: {
label: function(context) {
const dataset = context.dataset;
const dayIndex = context.dataIndex;
const day = days[dayIndex];
const deviation = context.parsed.x;
const actualVal = actualValues[dataset.controlKey][day];
if (actualVal === null) {
return dataset.label + ': No data';
}
const mean = dataset.controlMean;
const sd = dataset.controlSd;
return `${dataset.label}: ${actualVal.toFixed(2)} (${deviation > 0 ? '+' : ''}${deviation.toFixed(2)}σ)`;
}
}
}
},
scales: {
x: {
min: -3,
max: 3,
title: {
display: true,
text: 'Deviation from Mean (SD)',
font: { size: 11, weight: 'bold' }
},
grid: {
display: true,
color: '#e5e7eb',
drawBorder: false
},
ticks: {
font: { family: 'monospace', size: 10 },
stepSize: 1,
callback: function(value) {
if (value === 0) return '0';
return (value > 0 ? '+' : '') + value + 'σ';
}
}
},
y: {
reverse: true,
title: {
display: true,
text: 'Day',
font: { size: 11, weight: 'bold' }
},
grid: {
display: true,
color: '#e5e7eb'
},
ticks: {
stepSize: 1
}
}
},
annotation: {
annotations: {
line1: {
type: 'line',
xMin: 0,
xMax: 0,
borderColor: 'rgba(0,0,0,0.5)',
borderWidth: 2,
borderDash: [5, 5]
}
}
}
};
this.mergedChart = new Chart(ctx, {
type: 'line',
data: chartData,
options: chartOptions
});
},
get testName() {
const test = this.tests.find(t => t.testId == this.selectedTest);
return test ? test.testName : '';
},
get departmentName() {
const test = this.tests.find(t => t.testId == this.selectedTest);
return test && test.deptName ? test.deptName : 'Laboratory Department';
},
get departmentDisplayName() {
const dept = this.departmentName;
const mapping = {
'Kimia': 'Kimia Klinik',
'Imun': 'Imunologi',
'Hema': 'Hematologi',
'Mikro': 'Mikrobiologi',
'Urinal': 'Urinalisis',
'Serolo': 'Serologi'
};
return mapping[dept] || dept;
},
get monthDisplay() {
if (!this.month) return '';
const [year, month] = this.month.split('-');
const date = new Date(year, month - 1);
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
},
get daysInMonth() {
if (!this.month) return [];
const [year, month] = this.month.split('-').map(Number);
const days = new Date(year, month, 0).getDate();
const arr = new Array(days);
for (let i = 0; i < days; i++) {
arr[i] = i + 1;
}
return arr;
},
prevMonth() {
const [year, month] = this.month.split('-').map(Number);
const date = new Date(year, month - 2, 1);
this.month = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
this.onMonthChange();
},
nextMonth() {
const [year, month] = this.month.split('-').map(Number);
const date = new Date(year, month, 1);
this.month = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
this.onMonthChange();
},
isWeekend(day) {
const [year, month] = this.month.split('-').map(Number);
const date = new Date(year, month - 1, day);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6;
},
formatNum(val, dec = 2) {
if (val === null || val === undefined) return '-';
return parseFloat(val).toFixed(dec);
},
getBias(control) {
if (!control.stats.mean || !control.mean) return '-';
const bias = ((control.stats.mean - control.mean) / control.mean) * 100;
return bias > 0 ? '+' + bias.toFixed(1) : bias.toFixed(1);
},
getBiasClass(control) {
if (!control.stats.mean || !control.mean) return 'opacity-50';
const bias = Math.abs(((control.stats.mean - control.mean) / control.mean) * 100);
if (bias > 10) return 'text-error font-bold';
if (bias > 5) return 'text-warning font-bold';
return 'text-success font-bold';
},
getResValue(control, day) {
const res = control.results[day];
return res && res.resValue !== null ? res.resValue : '-';
},
getResComment(control, day) {
const res = control.results[day];
return res ? res.resComment : '';
},
getValueClass(control, day) {
const res = control.results[day];
if (!res || res.resValue === null) return 'opacity-20';
if (control.mean === null || control.sd === null) return '';
const val = parseFloat(res.resValue);
const target = parseFloat(control.mean);
const sd = parseFloat(control.sd);
const dev = Math.abs(val - target);
if (dev > 3 * sd) return 'text-error font-bold underline decoration-wavy';
if (dev > 2 * sd) return 'text-error font-bold';
return 'text-success';
}
}));
});
</script>
<?= $this->endSection(); ?>