tinyqc/app/Views/report/index.php
mahdahar 0a96b04bdf feat: Implement Monthly Entry interface and consolidate Entry API controller
- Implement Monthly Entry interface with full data entry grid
    - Add batch save with validation and statistics for monthly results
    - Support daily comments per day per test
    - Add result status indicators and validation summaries
  - Consolidate Entry API controller
    - Refactor EntryApiController to handle both daily/monthly operations
    - Add batch save endpoints with comprehensive validation
    - Implement statistics calculation for result entries
  - Add Control Test master data management
    - Create MasterControlsController for CRUD operations
    - Add dialog forms for control test configuration
    - Implement control-test associations with QC parameters
  - Refactor Report API and views
    - Implement new report index with Levey-Jennings charts placeholder
    - Add monthly report functionality with result statistics
    - Include QC summary with mean, SD, and CV calculations
  - UI improvements
    - Overhaul dashboard with improved layout
    - Update daily entry interface with inline editing
    - Enhance master data management with DaisyUI components
    - Add proper modal dialogs and form validation
  - Database and seeding
    - Update migration for control_tests table schema
    - Remove redundant migration and seed files
    - Update seeders with comprehensive test data
  - Documentation
    - Update CLAUDE.md with comprehensive project documentation
    - Add architecture overview and conventions

  BREAKING CHANGES:
  - Refactored Entry API endpoints structure
  - Removed ReportApiController::view() - consolidated into new report index
2026-01-21 13:41:37 +07:00

613 lines
27 KiB
PHP

<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto" x-data="reportModule()">
<div class="flex justify-between items-center mb-6 no-print">
<div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Monthly QC Report</h1>
<p class="text-sm mt-1 opacity-70">View summary and Levey-Jennings charts for laboratory tests</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 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 monthly 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 (Print only) -->
<div class="hidden print:block mb-8 border-b-2 border-base-content/20 pb-4">
<div class="flex justify-between items-end">
<div>
<h2 class="text-3xl font-black uppercase tracking-tighter" x-text="testName"></h2>
<p class="text-sm font-bold opacity-60" x-text="departmentName"></p>
</div>
<div class="text-right">
<p class="text-xl font-bold" x-text="monthDisplay"></p>
<p class="text-xs opacity-50">Report Generated: <?= date('d M Y H:i') ?></p>
</div>
</div>
</div>
<!-- summary stats -->
<div class="flex flex-wrap gap-3 mb-6 no-print-gap">
<template x-for="control in processedControls" :key="control.controlId">
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden flex flex-col w-full sm:w-[calc(50%-0.75rem)] lg:w-[calc(33.333%-0.75rem)] xl:w-[calc(25%-0.75rem)] print:w-[calc(33.333%-0.5rem)] print:text-[11px]">
<div class="bg-base-200/50 p-2 border-b border-base-300">
<div class="flex justify-between items-center gap-2">
<h3 class="font-bold truncate text-xs" x-text="control.controlName"></h3>
<span class="badge badge-xs badge-neutral shrink-0 print:scale-75" x-text="'Lot: ' + (control.lot || 'N/A')"></span>
</div>
</div>
<div class="p-2 flex-1 grid grid-cols-2 gap-2">
<div class="flex flex-col">
<span class="text-[9px] uppercase font-bold opacity-50">N</span>
<span class="text-sm font-mono font-bold" x-text="control.stats.n || 0"></span>
</div>
<div class="flex flex-col">
<span class="text-[9px] uppercase font-bold opacity-50">Mean</span>
<span class="text-sm font-mono font-bold text-primary"
x-text="formatNum(control.stats.mean)"></span>
</div>
<div class="flex flex-col">
<span class="text-[9px] uppercase font-bold opacity-50">SD</span>
<span class="text-sm font-mono font-bold" x-text="formatNum(control.stats.sd)"></span>
</div>
<div class="flex flex-col">
<span class="text-[9px] uppercase font-bold opacity-50">% CV</span>
<span class="text-sm font-mono font-bold"
:class="control.stats.cv > 5 ? 'text-warning' : 'text-success'"
x-text="formatNum(control.stats.cv, 1) + '%'"></span>
</div>
</div>
<div class="px-2 py-1 bg-base-200/30 text-[9px] flex justify-between border-t border-base-300 font-medium">
<span>Tgt: <span x-text="formatNum(control.mean)"></span>±<span x-text="formatNum(2*control.sd)"></span></span>
<span x-show="control.stats.mean" :class="getBiasClass(control)"
x-text="'B: ' + getBias(control) + '%'"></span>
</div>
</div>
</template>
</div>
<div class="flex flex-col xl:flex-row gap-6 items-start">
<!-- Monthly Table -->
<div class="w-full xl:w-80 print:w-72 shrink-0">
<div
class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden print:break-inside-avoid">
<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">
<table class="table table-xs w-full print:table-xs">
<thead class="bg-base-200/50">
<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>
<!-- Charts Section -->
<div class="flex-1 space-y-6 w-full">
<template x-for="control in processedControls" :key="'chart-'+control.controlId">
<div
class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 print:break-inside-avoid print:p-2">
<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]"
x-text="'Levey-Jennings: ' + control.controlName"></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 class="h-64 w-full print:h-48">
<canvas :id="'chart-' + control.controlId"></canvas>
</div>
</div>
</template>
</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: A4;
margin: 10mm;
}
body,
.drawer,
.drawer-content,
main,
.bg-base-100,
.bg-base-200,
.bg-base-300,
.navbar,
footer {
background-color: white !important;
background-image: none !important;
color: black !important;
}
.no-print,
.navbar,
footer,
.drawer-side,
.divider {
display: none !important;
}
main {
padding: 0 !important;
margin: 0 !important;
width: 100% !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;
}
/* Side by side layout for print */
.flex-col.xl\:flex-row {
flex-direction: row !important;
gap: 10px !important;
align-items: flex-start !important;
}
.flex-1 {
flex: 1 1 0% !important;
}
.print\:w-72 {
width: 18rem !important;
}
.shrink-0 {
flex-shrink: 0 !important;
}
/* Condensed summary cards grid */
.grid.print\:grid-cols-3 {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 8px !important;
margin-bottom: 1rem !important;
}
/* Keep some semantic colors for data indicators but make them print-safe */
.text-success {
color: #065f46 !important;
}
.text-error {
color: #991b1b !important;
}
.text-warning {
color: #92400e !important;
}
.text-primary {
color: #570df8 !important;
}
canvas {
max-width: 100% !important;
height: auto !important;
}
.table-xs th, .table-xs td {
padding: 2px 4px !important;
font-size: 9px !important;
}
}
</style>
<?= $this->endSection(); ?>
<?= $this->section("script"); ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("reportModule", () => ({
tests: [],
selectedTest: '',
controls: [],
loading: false,
month: '',
charts: {},
lastRequestId: 0,
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.$nextTick(() => {
this.renderCharts();
});
}
} catch (e) {
if (requestId === this.lastRequestId) {
console.error('Failed to fetch data:', e);
}
} finally {
if (requestId === this.lastRequestId) {
this.loading = false;
}
}
},
get processedControls() {
return this.controls.map(c => {
const stats = this.calculateStats(c.results);
return { ...c, stats };
});
},
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 };
},
renderCharts() {
// Destroy existing charts
Object.values(this.charts).forEach(chart => chart.destroy());
this.charts = {};
this.processedControls.forEach(control => {
const ctx = document.getElementById('chart-' + control.controlId);
if (!ctx) return;
const days = this.daysInMonth;
const data = days.map(day => {
const res = control.results[day];
return res && res.resValue !== null ? parseFloat(res.resValue) : null;
});
const targetMean = control.mean !== null ? parseFloat(control.mean) : null;
const targetSD = control.sd !== null ? parseFloat(control.sd) : null;
const datasets = [
{
label: control.controlName,
data: data,
borderColor: '#570df8',
backgroundColor: '#570df820',
borderWidth: 2,
pointRadius: 4,
pointBackgroundColor: data.map(v => {
if (v === null || targetMean === null || targetSD === null) return '#570df8';
const dev = Math.abs(v - targetMean);
return dev > 2 * targetSD ? '#ff52d9' : '#570df8';
}),
spanGaps: true,
tension: 0.1
}
];
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: days,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (context) {
let label = 'Value: ' + context.parsed.y;
if (targetMean !== null && targetSD !== null) {
const dev = (context.parsed.y - targetMean) / targetSD;
label += ` (${dev.toFixed(2)} SD)`;
}
return label;
}
}
}
},
animation: false,
scales: {
y: {
beginAtZero: false,
grid: {
color: (context) => {
if (targetMean === null || targetSD === null) return '#e5e7eb';
const val = context.tick.value;
if (Math.abs(val - targetMean) < 0.001) return '#10b98180'; // Mean line
if (Math.abs(Math.abs(val - targetMean) - 2 * targetSD) < 0.001) return '#f59e0b40'; // 2SD
if (Math.abs(Math.abs(val - targetMean) - 3 * targetSD) < 0.001) return '#ef444440'; // 3SD
return '#e5e7eb';
},
lineWidth: (context) => {
if (targetMean === null) return 1;
const val = context.tick.value;
if (Math.abs(val - targetMean) < 0.001) return 2;
return 1;
}
},
ticks: {
font: { family: 'monospace', size: 10 }
}
},
x: {
grid: { display: false },
ticks: { font: { family: 'monospace', size: 10 } }
}
}
}
});
if (targetMean !== null && targetSD !== null) {
const min = Math.min(...data.filter(v => v !== null), targetMean - 3.5 * targetSD);
const max = Math.max(...data.filter(v => v !== null), targetMean + 3.5 * targetSD);
chart.options.scales.y.min = min;
chart.options.scales.y.max = max;
// Force ticks at target mean and SD levels if possible,
// or just let Chart.js decide but we highlighted the grid lines.
}
chart.update();
this.charts[control.controlId] = chart;
});
},
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);
// We don't have dept name in the test object from api/master/tests directly maybe?
// Let's assume it might be there or just show "Department QC Report"
return test && test.deptName ? test.deptName : 'Laboratory Department';
},
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();
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();
},
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(); ?>