tinyqc/app/Views/report/custom1.php

1008 lines
33 KiB
PHP
Raw Normal View History

2026-03-03 15:49:56 +07:00
<?= $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">TEST NAME</td>
<td class="value" x-text="testName"></td>
<td class="info-label">NO. LOT</td>
<td class="value" x-text="controlLotNumber"></td>
</tr>
<tr>
<td class="info-label">METHOD</td>
<td class="value" x-text="testMethod"></td>
<td class="info-label">EXP</td>
<td class="value" x-text="expiryDate"></td>
</tr>
<tr>
<td class="info-label">PERIOD</td>
<td class="value" x-text="periodDisplay"></td>
<td class="info-label">Unit</td>
<td class="value" x-text="testUnit"></td>
</tr>
</table>
<!-- PC Range Table -->
<table class="pc-table">
<thead>
<tr>
<th class="pc-header" colspan="4" x-text="testCode"></th>
</tr>
<tr>
<th class="pc-subheader">Control</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-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, 2)"></td>
</tr>
<tr>
<td>SD</td>
<td x-text="formatNum(control.stats.sd, 2)"></td>
</tr>
<tr>
<td>CV %</td>
<td x-text="formatNum(control.stats.cv, 2) + '%'"></td>
</tr>
</table>
</div>
</template>
</div>
</div>
<!-- Footer Notes -->
<div class="report-footer">
<div class="catatan-section">
<div class="catatan-label">CATATAN :</div>
<div class="catatan-content">
<div class="catatan-item">
<span class="catatan-badge"></span>
<span>Kalibrasi</span>
</div>
<div class="catatan-item">
<span class="catatan-badge"></span>
<span>Preparation control</span>
</div>
<div class="catatan-item">
<span class="catatan-badge"></span>
<span>Ganti Reagen</span>
</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.</p>
</div>
</main>
<style>
.traditional-report {
font-family: 'Times New Roman', Times, serif;
max-width: 210mm;
margin: 0 auto;
background: white;
padding: 10mm;
}
/* Header Section */
.report-header {
margin-bottom: 15px;
border: 2px solid #333;
padding: 10px;
}
.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: 15px;
}
.info-table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
}
.info-table td {
border: 1px solid #333;
padding: 3px 6px;
}
.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: 4px 8px;
}
.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;
width: 25%;
}
.pc-table .pc-control-name {
font-weight: bold;
text-align: left;
background: #f5f5f5;
padding-left: 10px;
}
.pc-table .pc-value {
text-align: center;
font-family: monospace;
}
/* Main Grid */
.main-grid {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.data-table-section {
flex: 1;
}
.chart-section {
width: 45%;
border: 1px solid #333;
padding: 10px;
}
.chart-container {
height: 350px;
position: relative;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 10px;
font-size: 10px;
}
.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: 4px 6px;
}
.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: 15px;
}
.qc-header {
background: #e6b800;
color: black;
text-align: center;
font-weight: bold;
font-size: 12px;
padding: 5px;
border-bottom: 2px solid #333;
}
.qc-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
background: #333;
}
.qc-card {
background: white;
padding: 8px;
}
.qc-left {
background: #fff8dc;
}
.qc-right {
background: #ffe4e1;
}
.qc-title {
font-weight: bold;
font-size: 11px;
text-align: center;
margin-bottom: 5px;
border-bottom: 1px solid #333;
padding-bottom: 3px;
}
.qc-table {
width: 100%;
font-size: 10px;
border-collapse: collapse;
}
.qc-table td {
padding: 2px 5px;
}
.qc-table td:first-child {
font-weight: bold;
width: 30%;
}
.qc-table td:last-child {
text-align: right;
font-family: monospace;
}
/* Footer */
.report-footer {
border: 1px solid #333;
padding: 10px;
}
.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: 5mm;
box-shadow: none;
}
.chart-container {
height: 300px;
}
}
</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);
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) {
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 };
},
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.map(day => {
const res = control.results[day];
if (res && res.resValue !== null && sd !== 0) {
return (parseFloat(res.resValue) - mean) / sd;
}
return null;
});
return {
label: control.controlName,
data: data,
borderColor: color,
backgroundColor: color,
pointBackgroundColor: data.map(v => v !== null && Math.abs(v) > 2 ? '#ff0000' : color),
pointRadius: 4,
borderWidth: 1,
showLine: false,
tension: 0
};
});
this.chart = new Chart(ctx, {
type: 'line',
data: {
labels: days,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
annotation: {
annotations: {
meanLine: {
type: 'line',
yMin: 0,
yMax: 0,
borderColor: '#666',
borderWidth: 1,
borderDash: [5, 5]
},
plus1sd: {
type: 'line',
yMin: 1,
yMax: 1,
borderColor: '#ccc',
borderWidth: 1
},
minus1sd: {
type: 'line',
yMin: -1,
yMax: -1,
borderColor: '#ccc',
borderWidth: 1
},
plus2sd: {
type: 'line',
yMin: 2,
yMax: 2,
borderColor: '#999',
borderWidth: 1
},
minus2sd: {
type: 'line',
yMin: -2,
yMax: -2,
borderColor: '#999',
borderWidth: 1
},
plus3sd: {
type: 'line',
yMin: 3,
yMax: 3,
borderColor: '#333',
borderWidth: 2
},
minus3sd: {
type: 'line',
yMin: -3,
yMax: -3,
borderColor: '#333',
borderWidth: 2
}
}
}
},
scales: {
x: {
title: {
display: true,
text: 'Day',
font: { size: 10 }
},
ticks: {
font: { size: 8 },
maxRotation: 0
}
},
y: {
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);
}
}
}
}
}
});
},
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 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(); ?>