1099 lines
36 KiB
PHP
1099 lines
36 KiB
PHP
<?= $this->extend("layout/main_layout"); ?>
|
|
|
|
<?= $this->section("content"); ?>
|
|
<main class="flex-1 p-6 overflow-auto" x-data="traditionalReportModule()">
|
|
<div class="flex justify-between items-center mb-6 no-print">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-base-content tracking-tight">Traditional QC Chart</h1>
|
|
<p class="text-sm mt-1 opacity-70">Internal Quality Control Chart - Laboratory Report</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 Period</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 traditional 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 traditional QC chart report.</p>
|
|
</div>
|
|
|
|
<!-- Report Content -->
|
|
<div x-show="!loading && selectedTest && controls.length > 0" class="traditional-report">
|
|
<!-- Report Header -->
|
|
<div class="mb-6 print:mb-4">
|
|
<!-- Main Header -->
|
|
<div class="text-center text-sm border-b-2 border-black pb-3 mb-3">
|
|
<h3 class="font-black uppercase tracking-widest">Internal QC</h3>
|
|
<h3 class="font-bold" x-text="departmentDisplayName"></h3>
|
|
<h3 class="font-bold uppercase tracking-wide">Trisensa Diagnostic Centre</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Test Info Table -->
|
|
<div class="info-section">
|
|
<table class="info-table">
|
|
<tr>
|
|
<td class="info-label">Institution</td>
|
|
<td class="value">Laboratorium Trisensa</td>
|
|
<td class="info-label">Instrument</td>
|
|
<td class="value" x-text="instrumentName"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="info-label">Test</td>
|
|
<td class="value" x-text="testName"></td>
|
|
<td class="info-label">Unit / Method</td>
|
|
<td class="value" x-text="testUnit + ' / ' + testMethod"></td>
|
|
</tr>
|
|
</table>
|
|
|
|
<!-- PC Range Table -->
|
|
<table class="pc-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="pc-subheader">Control</th>
|
|
<th class="pc-subheader">Lot No</th>
|
|
<th class="pc-subheader">- 3S</th>
|
|
<th class="pc-subheader">TARGET</th>
|
|
<th class="pc-subheader">+ 3S</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="(control, index) in controls" :key="control.controlId">
|
|
<tr>
|
|
<td class="pc-control-name" x-text="control.controlName"></td>
|
|
<td class="pc-lot" x-text="control.lot || '-'"></td>
|
|
<td class="pc-value" x-text="formatNum(getMinus3SD(control), 1)"></td>
|
|
<td class="pc-value" x-text="formatNum(control.mean, 1)"></td>
|
|
<td class="pc-value" x-text="formatNum(getPlus3SD(control), 1)"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Main Content Grid -->
|
|
<div class="main-grid">
|
|
<!-- Left: Data Table -->
|
|
<div class="data-table-section">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="col-no">No.</th>
|
|
<th class="col-date">DATE</th>
|
|
<template x-for="control in controls" :key="control.controlId">
|
|
<th class="col-control" x-text="control.controlName"></th>
|
|
</template>
|
|
<th class="col-ket">KETERANGAN</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="(row, index) in tableData" :key="index">
|
|
<tr>
|
|
<td class="text-center" x-text="index + 1"></td>
|
|
<td class="text-center" x-text="row.date"></td>
|
|
<template x-for="control in controls" :key="control.controlId">
|
|
<td class="text-center"
|
|
:class="getValueClass(control, row.values[control.controlId])"
|
|
x-text="formatValue(row.values[control.controlId])"></td>
|
|
</template>
|
|
<td class="text-left" x-text="row.comment"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Right: Chart -->
|
|
<div class="chart-section">
|
|
<div class="chart-container">
|
|
<canvas id="traditionalChart"></canvas>
|
|
</div>
|
|
<div class="chart-legend">
|
|
<template x-for="(control, index) in controls" :key="control.controlId">
|
|
<div class="legend-item">
|
|
<span class="legend-dot" :style="'background-color: ' + getControlColor(index)"></span>
|
|
<span x-text="control.controlName"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- QC Performance Section -->
|
|
<div class="qc-performance">
|
|
<div class="qc-header">QC PERFORMANCE</div>
|
|
<div class="qc-grid">
|
|
<template x-for="(control, index) in processedControls" :key="control.controlId">
|
|
<div class="qc-card" :class="index % 2 === 0 ? 'qc-left' : 'qc-right'">
|
|
<div class="qc-title" x-text="control.controlName"></div>
|
|
<table class="qc-table">
|
|
<tr>
|
|
<td>MEAN</td>
|
|
<td x-text="formatNum(control.stats.mean, 3)"></td>
|
|
<td>SD</td>
|
|
<td x-text="formatNum(control.stats.sd, 3)"></td>
|
|
</tr>
|
|
<tr>
|
|
<td>CV %</td>
|
|
<td x-text="formatNum(control.stats.cv, 3)"></td>
|
|
<td>CV<sub>A</sub></td>
|
|
<td x-text="formatNum(control.stats.cvA, 2)"></td>
|
|
</tr>
|
|
<tr>
|
|
<td>BIAS %</td>
|
|
<td x-text="formatNum(control.stats.bias, 3)"></td>
|
|
<td>B<sub>A</sub> %</td>
|
|
<td x-text="formatNum(control.stats.ba, 2)"></td>
|
|
</tr>
|
|
<tr>
|
|
<td>TE %</td>
|
|
<td x-text="formatNum(control.stats.te, 2)"></td>
|
|
<td>TE<sub>A</sub></td>
|
|
<td x-text="formatNum(control.stats.teA, 2)"></td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Signature Section -->
|
|
<div class="signature-section">
|
|
<div class="signature-item">
|
|
<div class="signature-label">Analis</div>
|
|
<div class="signature-line"></div>
|
|
</div>
|
|
<div class="signature-item">
|
|
<div class="signature-label">Verifikator</div>
|
|
<div class="signature-line"></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.</p>
|
|
</div>
|
|
</main>
|
|
|
|
<style>
|
|
.traditional-report {
|
|
font-family: 'Times New Roman', Times, serif;
|
|
max-width: 210mm;
|
|
margin: 0 auto;
|
|
background: white;
|
|
padding: 3mm;
|
|
}
|
|
|
|
/* Header Section */
|
|
.report-header {
|
|
margin-bottom: 8px;
|
|
border: 2px solid #333;
|
|
padding: 5px;
|
|
}
|
|
|
|
.lab-info {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
}
|
|
|
|
.lab-logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.logo-circle {
|
|
width: 50px;
|
|
height: 50px;
|
|
border-radius: 50%;
|
|
background: #c85a9e;
|
|
color: white;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: bold;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.lab-name {
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.lab-title {
|
|
font-size: 10px;
|
|
color: #c85a9e;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.lab-brand {
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
color: #c85a9e;
|
|
}
|
|
|
|
.report-title {
|
|
flex: 1;
|
|
text-align: center;
|
|
}
|
|
|
|
.report-title h1 {
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
margin: 0 0 3px 0;
|
|
}
|
|
|
|
.report-title h2 {
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
margin: 0 0 5px 0;
|
|
}
|
|
|
|
.report-title p {
|
|
font-size: 9px;
|
|
margin: 1px 0;
|
|
color: #333;
|
|
}
|
|
|
|
/* Info Section */
|
|
.info-section {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.info-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.info-table td {
|
|
border: 1px solid #333;
|
|
padding: 2px 4px;
|
|
}
|
|
|
|
.info-table .info-label {
|
|
background: #f5f5f5 !important;
|
|
font-weight: bold;
|
|
width: 15%;
|
|
text-align: center;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.info-table .value {
|
|
width: 35%;
|
|
text-align: center;
|
|
font-size: 10px;
|
|
}
|
|
|
|
/* PC Range Table */
|
|
.pc-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.pc-table th,
|
|
.pc-table td {
|
|
border: 1px solid #333;
|
|
padding: 2px 4px;
|
|
}
|
|
|
|
.pc-table .pc-header {
|
|
background: #e6e6e6;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.pc-table .pc-subheader {
|
|
background: #f5f5f5;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
}
|
|
|
|
.pc-table .pc-subheader:nth-child(1),
|
|
.pc-table td.pc-control-name {
|
|
width: 25%;
|
|
}
|
|
|
|
.pc-table .pc-subheader:nth-child(2),
|
|
.pc-table td.pc-lot {
|
|
width: 20%;
|
|
}
|
|
|
|
.pc-table .pc-subheader:nth-child(3),
|
|
.pc-table .pc-subheader:nth-child(4),
|
|
.pc-table .pc-subheader:nth-child(5),
|
|
.pc-table td.pc-value {
|
|
width: 18.33%;
|
|
}
|
|
|
|
.pc-table .pc-control-name {
|
|
font-weight: bold;
|
|
text-align: left;
|
|
background: #f5f5f5;
|
|
padding-left: 4px;
|
|
}
|
|
|
|
.pc-table .pc-value {
|
|
text-align: center;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.pc-table .pc-lot {
|
|
text-align: center;
|
|
font-family: monospace;
|
|
font-size: 9px;
|
|
}
|
|
|
|
/* Main Grid */
|
|
.main-grid {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.data-table-section {
|
|
flex: 1;
|
|
}
|
|
|
|
.chart-section {
|
|
width: 45%;
|
|
border: 1px solid #333;
|
|
padding: 5px;
|
|
}
|
|
|
|
.chart-container {
|
|
height: 480px;
|
|
position: relative;
|
|
}
|
|
|
|
.chart-legend {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
margin-top: 5px;
|
|
font-size: 9px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
/* Data Table */
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.data-table th,
|
|
.data-table td {
|
|
border: 1px solid #333;
|
|
padding: 2px 4px;
|
|
}
|
|
|
|
.data-table th {
|
|
background: #f5f5f5;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
}
|
|
|
|
.data-table .col-no {
|
|
width: 8%;
|
|
}
|
|
|
|
.data-table .col-date {
|
|
width: 15%;
|
|
}
|
|
|
|
.data-table .col-control {
|
|
width: 20%;
|
|
}
|
|
|
|
.data-table .col-ket {
|
|
width: 30%;
|
|
}
|
|
|
|
.data-table tbody tr:nth-child(even) {
|
|
background: #fafafa;
|
|
}
|
|
|
|
/* QC Performance */
|
|
.qc-performance {
|
|
border: 2px solid #333;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.qc-header {
|
|
background: #e6b800;
|
|
color: black;
|
|
text-align: center;
|
|
font-weight: bold;
|
|
font-size: 11px;
|
|
padding: 3px;
|
|
border-bottom: 2px solid #333;
|
|
}
|
|
|
|
.qc-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 2px;
|
|
background: #333;
|
|
}
|
|
|
|
.qc-card {
|
|
background: white;
|
|
padding: 4px;
|
|
}
|
|
|
|
.qc-left {
|
|
background: white;
|
|
}
|
|
|
|
.qc-right {
|
|
background: white;
|
|
}
|
|
|
|
.qc-title {
|
|
font-weight: bold;
|
|
font-size: 10px;
|
|
text-align: center;
|
|
margin-bottom: 3px;
|
|
border-bottom: 1px solid #333;
|
|
padding-bottom: 2px;
|
|
}
|
|
|
|
.qc-table {
|
|
width: 100%;
|
|
font-size: 9px;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.qc-table td {
|
|
padding: 1px 3px;
|
|
border: 1px solid #ccc;
|
|
}
|
|
|
|
.qc-table td:nth-child(odd) {
|
|
font-weight: bold;
|
|
width: 20%;
|
|
background: #f5f5f5;
|
|
}
|
|
|
|
.qc-table td:nth-child(even) {
|
|
text-align: right;
|
|
font-family: monospace;
|
|
width: 30%;
|
|
}
|
|
|
|
/* Signature Section */
|
|
.signature-section {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 80px;
|
|
margin-top: 30px;
|
|
padding: 10px;
|
|
}
|
|
|
|
.signature-item {
|
|
text-align: center;
|
|
}
|
|
|
|
.signature-label {
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
margin-bottom: 40px;
|
|
}
|
|
|
|
.signature-line {
|
|
width: 150px;
|
|
border-bottom: 1px solid #333;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
@media print {
|
|
.signature-section {
|
|
margin-top: 40px;
|
|
}
|
|
.signature-line {
|
|
border-bottom: 1px solid #000;
|
|
}
|
|
}
|
|
|
|
/* Footer */
|
|
.report-footer {
|
|
border: 1px solid #333;
|
|
padding: 5px;
|
|
}
|
|
|
|
.catatan-section {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.catatan-label {
|
|
font-weight: bold;
|
|
font-size: 10px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.catatan-content {
|
|
flex: 1;
|
|
}
|
|
|
|
.catatan-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 3px;
|
|
font-size: 9px;
|
|
}
|
|
|
|
.catatan-badge {
|
|
width: 15px;
|
|
height: 10px;
|
|
border: 1px solid #333;
|
|
display: inline-block;
|
|
}
|
|
|
|
/* Print Styles */
|
|
@media print {
|
|
@page {
|
|
size: A4 portrait;
|
|
margin: 5mm;
|
|
}
|
|
|
|
html, body {
|
|
background: white !important;
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}
|
|
|
|
.no-print,
|
|
.navbar,
|
|
.footer,
|
|
.drawer-side,
|
|
.drawer-overlay {
|
|
display: none !important;
|
|
}
|
|
|
|
.drawer-content {
|
|
margin-left: 0 !important;
|
|
}
|
|
|
|
main {
|
|
padding: 0 !important;
|
|
margin: 0 !important;
|
|
}
|
|
|
|
.traditional-report {
|
|
background: white !important;
|
|
min-height: 277mm;
|
|
padding: 2mm;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.chart-container {
|
|
height: 450px;
|
|
}
|
|
}
|
|
</style>
|
|
<?= $this->endSection(); ?>
|
|
|
|
<?= $this->section("script"); ?>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1/dist/chartjs-plugin-annotation.min.js"></script>
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data("traditionalReportModule", () => ({
|
|
tests: [],
|
|
selectedTest: '',
|
|
controls: [],
|
|
processedControls: [],
|
|
testData: null,
|
|
loading: false,
|
|
month: '',
|
|
chart: null,
|
|
lastRequestId: 0,
|
|
controlColors: ['#c85a9e', '#d2691e', '#228b22', '#4169e1', '#ff6347', '#9370db'],
|
|
|
|
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') {
|
|
const allControls = json.data.controls || [];
|
|
// Filter only controls that have at least one value
|
|
this.controls = allControls.filter(c => this.hasValues(c.results));
|
|
// Use test data from API response
|
|
this.testData = json.data.test || this.tests.find(t => t.testId == this.selectedTest);
|
|
this.processedControls = this.controls.map(c => {
|
|
const stats = this.calculateStats(c.results, c.mean);
|
|
return { ...c, stats };
|
|
});
|
|
this.$nextTick(() => {
|
|
this.renderChart();
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (requestId === this.lastRequestId) {
|
|
console.error('Failed to fetch data:', e);
|
|
}
|
|
} finally {
|
|
if (requestId === this.lastRequestId) {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
},
|
|
|
|
hasValues(results) {
|
|
if (!results || typeof results !== 'object') return false;
|
|
return Object.values(results).some(r => r !== null && r !== undefined && r.resValue !== null);
|
|
},
|
|
|
|
calculateStats(results, controlMean) {
|
|
if (!results || typeof results !== 'object') return { n: 0, mean: null, sd: null, cv: null, bias: null, te: null, cvA: null, ba: null, teA: 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, bias: null, te: null, cvA: null, ba: null, teA: 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;
|
|
|
|
// BIAS% = ((Observed Mean - Target Mean) / Target Mean) * 100
|
|
const targetMean = parseFloat(controlMean) || mean;
|
|
const bias = targetMean !== 0 ? ((mean - targetMean) / targetMean) * 100 : 0;
|
|
|
|
// TE% = |BIAS%| + 1.65 * CV% (Westgard TE at 95% confidence)
|
|
const te = Math.abs(bias) + (1.65 * cv);
|
|
|
|
// CV_A (Analytical CV) - use test-specific or default based on control
|
|
const cvA = controlMean ? 1.6 : 0; // Default 1.6% for controls with defined mean
|
|
|
|
// B_A% (Analytical Bias) - default value
|
|
const ba = 1.3;
|
|
|
|
// TE_A (Analytical Total Error) - typically CV_A + B_A
|
|
const teA = 3.9;
|
|
|
|
return { n, mean, sd, cv, bias, te, cvA, ba, teA };
|
|
},
|
|
|
|
renderChart() {
|
|
if (this.chart) {
|
|
this.chart.destroy();
|
|
this.chart = null;
|
|
}
|
|
|
|
const canvas = document.getElementById('traditionalChart');
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const days = this.daysInMonth;
|
|
const datasets = this.controls.map((control, index) => {
|
|
const mean = parseFloat(control.mean) || 0;
|
|
const sd = parseFloat(control.sd) || 0;
|
|
const color = this.controlColors[index % this.controlColors.length];
|
|
|
|
const data = [];
|
|
days.forEach(day => {
|
|
const res = control.results[day];
|
|
if (res && res.resValue !== null && sd !== 0) {
|
|
const sdValue = (parseFloat(res.resValue) - mean) / sd;
|
|
data.push({ x: sdValue, y: day });
|
|
}
|
|
});
|
|
|
|
return {
|
|
label: control.controlName,
|
|
data: data,
|
|
borderColor: color,
|
|
backgroundColor: data.map(d => Math.abs(d.x) > 2 ? '#ff0000' : color),
|
|
pointBackgroundColor: data.map(d => Math.abs(d.x) > 2 ? '#ff0000' : color),
|
|
pointRadius: 3,
|
|
borderWidth: 1.5,
|
|
showLine: true,
|
|
tension: 0,
|
|
spanGaps: true
|
|
};
|
|
});
|
|
|
|
this.chart = new Chart(ctx, {
|
|
type: 'scatter',
|
|
data: { datasets: datasets },
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
annotation: {
|
|
annotations: {
|
|
meanLine: {
|
|
type: 'line',
|
|
xMin: 0,
|
|
xMax: 0,
|
|
borderColor: '#666',
|
|
borderWidth: 1,
|
|
borderDash: [5, 5]
|
|
},
|
|
plus1sd: {
|
|
type: 'line',
|
|
xMin: 1,
|
|
xMax: 1,
|
|
borderColor: '#ccc',
|
|
borderWidth: 1
|
|
},
|
|
minus1sd: {
|
|
type: 'line',
|
|
xMin: -1,
|
|
xMax: -1,
|
|
borderColor: '#ccc',
|
|
borderWidth: 1
|
|
},
|
|
plus2sd: {
|
|
type: 'line',
|
|
xMin: 2,
|
|
xMax: 2,
|
|
borderColor: '#999',
|
|
borderWidth: 1
|
|
},
|
|
minus2sd: {
|
|
type: 'line',
|
|
xMin: -2,
|
|
xMax: -2,
|
|
borderColor: '#999',
|
|
borderWidth: 1
|
|
},
|
|
plus3sd: {
|
|
type: 'line',
|
|
xMin: 3,
|
|
xMax: 3,
|
|
borderColor: '#333',
|
|
borderWidth: 2
|
|
},
|
|
minus3sd: {
|
|
type: 'line',
|
|
xMin: -3,
|
|
xMax: -3,
|
|
borderColor: '#333',
|
|
borderWidth: 2
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
min: -4,
|
|
max: 4,
|
|
title: {
|
|
display: true,
|
|
text: 'SD',
|
|
font: { size: 10 }
|
|
},
|
|
ticks: {
|
|
stepSize: 1,
|
|
font: { size: 8 },
|
|
callback: function(value) {
|
|
return value === 0 ? '0' : (value > 0 ? '+' + value : value);
|
|
}
|
|
}
|
|
},
|
|
y: {
|
|
title: {
|
|
display: true,
|
|
text: 'Day',
|
|
font: { size: 10 }
|
|
},
|
|
ticks: {
|
|
font: { size: 8 },
|
|
stepSize: 1,
|
|
precision: 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
get tableData() {
|
|
if (!this.controls.length) return [];
|
|
|
|
const days = this.daysInMonth;
|
|
const data = [];
|
|
|
|
days.forEach(day => {
|
|
const row = {
|
|
date: this.formatDate(day),
|
|
values: {},
|
|
comment: ''
|
|
};
|
|
|
|
let hasData = false;
|
|
this.controls.forEach(control => {
|
|
const res = control.results[day];
|
|
if (res && res.resValue !== null) {
|
|
row.values[control.controlId] = parseFloat(res.resValue);
|
|
hasData = true;
|
|
if (res.resComment) {
|
|
row.comment = res.resComment;
|
|
}
|
|
} else {
|
|
row.values[control.controlId] = null;
|
|
}
|
|
});
|
|
|
|
if (hasData) {
|
|
data.push(row);
|
|
}
|
|
});
|
|
|
|
return data;
|
|
},
|
|
|
|
formatDate(day) {
|
|
const [year, month] = this.month.split('-');
|
|
const date = new Date(year, month - 1, day);
|
|
return date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: '2-digit' });
|
|
},
|
|
|
|
formatValue(value) {
|
|
if (value === null || value === undefined) return '';
|
|
return value.toFixed(2);
|
|
},
|
|
|
|
formatNum(val, dec = 2) {
|
|
if (val === null || val === undefined || isNaN(val)) return '-';
|
|
return parseFloat(val).toFixed(dec);
|
|
},
|
|
|
|
getValueClass(control, value) {
|
|
if (value === null || value === undefined) return '';
|
|
if (control.mean === null || control.sd === null) return '';
|
|
|
|
const target = parseFloat(control.mean);
|
|
const sd = parseFloat(control.sd);
|
|
const dev = Math.abs(value - target);
|
|
|
|
if (dev > 3 * sd) return 'text-error font-bold';
|
|
if (dev > 2 * sd) return 'text-warning';
|
|
return '';
|
|
},
|
|
|
|
getMinus3SD(control) {
|
|
if (!control.mean || !control.sd) return '-';
|
|
return parseFloat(control.mean) - 3 * parseFloat(control.sd);
|
|
},
|
|
|
|
getPlus3SD(control) {
|
|
if (!control.mean || !control.sd) return '-';
|
|
return parseFloat(control.mean) + 3 * parseFloat(control.sd);
|
|
},
|
|
|
|
getControlColor(index) {
|
|
return this.controlColors[index % this.controlColors.length];
|
|
},
|
|
|
|
get testName() {
|
|
return this.testData ? this.testData.testName : '';
|
|
},
|
|
|
|
get institutionName() {
|
|
return this.testData ? (this.testData.institutionName || 'Trisensa Diagnostic Centre') : 'Trisensa Diagnostic Centre';
|
|
},
|
|
|
|
get instrumentName() {
|
|
if (!this.testData) return '-';
|
|
if (this.testData.instrumentName) return this.testData.instrumentName;
|
|
const dept = this.testData.deptName;
|
|
const mapping = {
|
|
'Kimia': 'TMS 50i',
|
|
'Imun': 'Mindray CL900i'
|
|
};
|
|
return mapping[dept] || '-';
|
|
},
|
|
|
|
get controlNames() {
|
|
if (this.controls.length === 0) return '-';
|
|
return this.controls.map(c => c.controlName).join(', ');
|
|
},
|
|
|
|
get testCode() {
|
|
return this.testData ? (this.testData.testCode || this.testData.testName) : '';
|
|
},
|
|
|
|
get testMethod() {
|
|
return this.testData ? (this.testData.testMethod || '-') : '-';
|
|
},
|
|
|
|
get testUnit() {
|
|
return this.testData ? (this.testData.testUnit || 'IU/L') : 'IU/L';
|
|
},
|
|
|
|
get controlLotNumber() {
|
|
if (this.controls.length > 0) {
|
|
return this.controls[0].lot || '-';
|
|
}
|
|
return '-';
|
|
},
|
|
|
|
get expiryDate() {
|
|
if (this.controls.length > 0 && this.controls[0].expDate) {
|
|
const date = new Date(this.controls[0].expDate);
|
|
return date.toLocaleDateString('en-GB', { year: 'numeric', month: '2-digit' });
|
|
}
|
|
return '-';
|
|
},
|
|
|
|
get periodDisplay() {
|
|
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 departmentDisplayName() {
|
|
if (!this.testData || !this.testData.deptName) return 'IMMUNOLOGY';
|
|
const dept = this.testData.deptName;
|
|
const mapping = {
|
|
'Kimia': 'KIMIA',
|
|
'Imun': 'IMUNOLOGY',
|
|
'Hema': 'HEMATOLOGY',
|
|
'Mikro': 'MICROBIOLOGY',
|
|
'Urinal': 'URINALYSIS',
|
|
'Serolo': 'SEROLOGY'
|
|
};
|
|
return mapping[dept] || dept.toUpperCase();
|
|
},
|
|
|
|
get daysInMonth() {
|
|
if (!this.month) return [];
|
|
const [year, month] = this.month.split('-').map(Number);
|
|
const days = new Date(year, month, 0).getDate();
|
|
return Array.from({ length: days }, (_, i) => i + 1);
|
|
},
|
|
|
|
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();
|
|
}
|
|
}));
|
|
});
|
|
</script>
|
|
<?= $this->endSection(); ?>
|