2026-02-05 20:06:51 +07:00
|
|
|
|
<?= $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">
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
<!-- 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">
|
2026-02-05 20:06:51 +07:00
|
|
|
|
<div>
|
2026-03-03 09:02:34 +07:00
|
|
|
|
<span class="text-xs uppercase font-bold opacity-60">Test:</span>
|
|
|
|
|
|
<span class="text-base font-bold ml-1" x-text="testName"></span>
|
2026-02-05 20:06:51 +07:00
|
|
|
|
</div>
|
2026-03-03 09:02:34 +07:00
|
|
|
|
<div>
|
|
|
|
|
|
<span class="text-xs uppercase font-bold opacity-60">Period:</span>
|
|
|
|
|
|
<span class="text-base font-bold ml-1" x-text="monthDisplay"></span>
|
2026-02-05 20:06:51 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
<!-- 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-xs 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>
|
2026-02-05 20:06:51 +07:00
|
|
|
|
</div>
|
2026-03-03 09:02:34 +07:00
|
|
|
|
</div>
|
2026-02-05 20:06:51 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Table and Chart Grid -->
|
2026-03-03 09:11:32 +07:00
|
|
|
|
<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">
|
2026-02-05 20:06:51 +07:00
|
|
|
|
<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 class="overflow-x-auto max-h-[600px] overflow-y-auto">
|
2026-03-03 09:02:34 +07:00
|
|
|
|
<table class="table table-xs w-full">
|
2026-02-05 20:06:51 +07:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-03-03 09:11:32 +07:00
|
|
|
|
<!-- 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">
|
2026-02-05 20:06:51 +07:00
|
|
|
|
<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>
|
2026-03-03 09:11:32 +07:00
|
|
|
|
<div style="height: 1400px;" class="w-full relative">
|
2026-02-05 20:06:51 +07:00
|
|
|
|
<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;
|
2026-03-03 09:02:34 +07:00
|
|
|
|
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;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.no-print,
|
|
|
|
|
|
.navbar,
|
|
|
|
|
|
footer,
|
|
|
|
|
|
.drawer-side,
|
2026-03-03 09:02:34 +07:00
|
|
|
|
.divider {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
display: none !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
/* Remove hiding of first child - we need the header visible */
|
|
|
|
|
|
/* .space-y-6 > div:first-child was hiding the header */
|
2026-02-05 20:06:51 +07:00
|
|
|
|
|
|
|
|
|
|
.space-y-6 > div:nth-child(2) {
|
|
|
|
|
|
margin-top: 0 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
html, body {
|
|
|
|
|
|
margin: 0 !important;
|
|
|
|
|
|
padding: 0 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 20:06:51 +07:00
|
|
|
|
main {
|
|
|
|
|
|
padding: 0 !important;
|
|
|
|
|
|
margin: 0 !important;
|
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
/* Report content - natural height */
|
|
|
|
|
|
.space-y-6 {
|
|
|
|
|
|
display: block !important;
|
|
|
|
|
|
gap: 8px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 20:06:51 +07:00
|
|
|
|
.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 {
|
2026-03-03 09:02:34 +07:00
|
|
|
|
padding: 4px 3px !important;
|
|
|
|
|
|
font-size: 7px !important;
|
|
|
|
|
|
line-height: 1.4 !important;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.table {
|
2026-03-03 09:02:34 +07:00
|
|
|
|
font-size: 7px !important;
|
|
|
|
|
|
line-height: 1.4 !important;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.table tr {
|
2026-03-03 09:02:34 +07:00
|
|
|
|
height: auto !important;
|
|
|
|
|
|
min-height: 20px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.table tbody tr {
|
|
|
|
|
|
padding: 4px 0 !important;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.overflow-x-auto {
|
|
|
|
|
|
overflow: visible !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.max-h-\[600px\] {
|
2026-03-03 09:02:34 +07:00
|
|
|
|
max-height: none !important;
|
|
|
|
|
|
overflow-y: visible !important;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
/* Force 2-column layout in print */
|
|
|
|
|
|
.space-y-6 > .flex.gap-6 {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
flex-direction: row !important;
|
|
|
|
|
|
display: flex !important;
|
2026-03-03 09:02:34 +07:00
|
|
|
|
gap: 8px !important;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
page-break-inside: avoid;
|
|
|
|
|
|
break-inside: avoid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
[class*="lg:w-1/3"] {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
width: 35% !important;
|
|
|
|
|
|
flex: 0 0 35% !important;
|
|
|
|
|
|
max-width: 35% !important;
|
|
|
|
|
|
page-break-inside: avoid;
|
|
|
|
|
|
break-inside: avoid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
[class*="lg:w-2/3"] {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
[class*="text-[8px]"] {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
font-size: 6px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
[class*="text-[9px]"] {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
font-size: 7px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.table-xs span[class*="text-"] {
|
|
|
|
|
|
font-size: 6px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.animate-in,
|
|
|
|
|
|
.fade-in {
|
|
|
|
|
|
animation: none !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
/* Chart fills available height */
|
|
|
|
|
|
.h-\[560px\] {
|
|
|
|
|
|
flex: 1 !important;
|
|
|
|
|
|
height: auto !important;
|
|
|
|
|
|
min-height: 400px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 20:06:51 +07:00
|
|
|
|
canvas#mergedChart {
|
2026-03-03 09:02:34 +07:00
|
|
|
|
width: 100% !important;
|
|
|
|
|
|
height: 100% !important;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.h-full {
|
2026-03-03 09:02:34 +07:00
|
|
|
|
height: 100% !important;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.print\:break-inside-avoid {
|
|
|
|
|
|
break-inside: auto !important;
|
|
|
|
|
|
page-break-inside: auto !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.print\:p-2 {
|
|
|
|
|
|
padding: 2px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
[class*="print:text-[10px]"] {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
font-size: 8px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
[class*="print:text-[11px]"] {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
font-size: 8px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
.print\\:scale-75 {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
transform: none !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge-xs {
|
|
|
|
|
|
font-size: 6px !important;
|
|
|
|
|
|
padding: 1px 3px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
[class*="text-[9px]"] {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
font-size: 7px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
[class*="text-[10px]"] {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
font-size: 8px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
[class*="text-[11px]"] {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-03 09:02:34 +07:00
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
</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 = [];
|
2026-03-03 09:02:34 +07:00
|
|
|
|
|
|
|
|
|
|
// Store actual values for tooltips
|
|
|
|
|
|
const actualValues = {};
|
2026-02-05 20:06:51 +07:00
|
|
|
|
|
|
|
|
|
|
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 || {};
|
2026-03-03 09:02:34 +07:00
|
|
|
|
const controlKey = control.controlId || index;
|
|
|
|
|
|
actualValues[controlKey] = {};
|
|
|
|
|
|
|
2026-02-05 20:06:51 +07:00
|
|
|
|
const dataPoints = days.map(day => {
|
|
|
|
|
|
const res = results[day];
|
2026-03-03 09:02:34 +07:00
|
|
|
|
if (res && res.resValue !== null && mean !== 0 && sd !== 0) {
|
2026-02-05 20:06:51 +07:00
|
|
|
|
const val = parseFloat(res.resValue);
|
2026-03-03 09:02:34 +07:00
|
|
|
|
// Store actual value for tooltip
|
|
|
|
|
|
actualValues[controlKey][day] = val;
|
|
|
|
|
|
// Convert to deviation from mean in SD units
|
|
|
|
|
|
return (val - mean) / sd;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
2026-03-03 09:02:34 +07:00
|
|
|
|
actualValues[controlKey][day] = null;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
return null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const pointColors = dataPoints.map(v => {
|
2026-03-03 09:02:34 +07:00
|
|
|
|
if (v === null) return color;
|
|
|
|
|
|
// Red if outside ±2SD, otherwise use control color
|
|
|
|
|
|
return Math.abs(v) > 2 ? '#ef4444' : color;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
datasets.push({
|
|
|
|
|
|
label: String(control.controlName || ''),
|
|
|
|
|
|
data: dataPoints,
|
|
|
|
|
|
borderColor: color,
|
|
|
|
|
|
backgroundColor: bgColor,
|
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
|
pointRadius: 4,
|
|
|
|
|
|
pointBackgroundColor: pointColors,
|
|
|
|
|
|
pointBorderColor: pointColors,
|
|
|
|
|
|
pointHoverRadius: 6,
|
|
|
|
|
|
spanGaps: true,
|
2026-03-03 09:02:34 +07:00
|
|
|
|
tension: 0.1,
|
|
|
|
|
|
// Store reference data for tooltip
|
|
|
|
|
|
controlMean: mean,
|
|
|
|
|
|
controlSd: sd,
|
|
|
|
|
|
controlKey: controlKey
|
2026-02-05 20:06:51 +07:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-03-03 09:02:34 +07:00
|
|
|
|
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)}σ)`;
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
x: {
|
2026-03-03 09:02:34 +07:00
|
|
|
|
min: -3,
|
|
|
|
|
|
max: 3,
|
2026-02-05 20:06:51 +07:00
|
|
|
|
title: {
|
|
|
|
|
|
display: true,
|
2026-03-03 09:02:34 +07:00
|
|
|
|
text: 'Deviation from Mean (SD)',
|
2026-02-05 20:06:51 +07:00
|
|
|
|
font: { size: 11, weight: 'bold' }
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
display: true,
|
2026-03-03 09:02:34 +07:00
|
|
|
|
color: '#e5e7eb',
|
|
|
|
|
|
drawBorder: false
|
2026-02-05 20:06:51 +07:00
|
|
|
|
},
|
|
|
|
|
|
ticks: {
|
2026-03-03 09:02:34 +07:00
|
|
|
|
font: { family: 'monospace', size: 10 },
|
|
|
|
|
|
stepSize: 1,
|
|
|
|
|
|
callback: function(value) {
|
|
|
|
|
|
if (value === 0) return '0';
|
|
|
|
|
|
return (value > 0 ? '+' : '') + value + 'σ';
|
|
|
|
|
|
}
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
y: {
|
|
|
|
|
|
reverse: true,
|
|
|
|
|
|
title: {
|
|
|
|
|
|
display: true,
|
|
|
|
|
|
text: 'Day',
|
|
|
|
|
|
font: { size: 11, weight: 'bold' }
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
display: true,
|
|
|
|
|
|
color: '#e5e7eb'
|
|
|
|
|
|
},
|
|
|
|
|
|
ticks: {
|
|
|
|
|
|
stepSize: 1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-03 09:02:34 +07:00
|
|
|
|
},
|
|
|
|
|
|
annotation: {
|
|
|
|
|
|
annotations: {
|
|
|
|
|
|
line1: {
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
xMin: 0,
|
|
|
|
|
|
xMax: 0,
|
|
|
|
|
|
borderColor: 'rgba(0,0,0,0.5)',
|
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
|
borderDash: [5, 5]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-05 20:06:51 +07:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-03 09:02:34 +07:00
|
|
|
|
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;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-05 20:06:51 +07:00
|
|
|
|
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(); ?>
|