307 lines
14 KiB
PHP
Raw Normal View History

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
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-4 lg:p-6 overflow-auto" x-data="controlTests()">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-xl font-bold text-base-content tracking-tight">Control Tests</h1>
<p class="text-xs mt-1 opacity-60">Manage QC parameters for control-test associations</p>
</div>
<button class="btn btn-sm btn-primary gap-2" @click="showForm()">
<i class="fa-solid fa-plus"></i> New Association
</button>
</div>
<!-- Tools & Actions -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div class="flex items-center gap-2">
<div class="relative w-full sm:w-64">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 opacity-40 text-xs"></i>
<input type="text" placeholder="Search associations..."
class="input input-bordered input-sm w-full pl-9" x-model="keyword"
@input.debounce.300ms="fetchList()" />
</div>
<div class="flex border border-base-300 rounded-lg overflow-hidden bg-base-100 p-0.5">
<button class="btn btn-xs btn-ghost hover:bg-base-200 px-2" @click="expandAll()">Expand All</button>
<div class="w-px bg-base-300 mx-0.5"></div>
<button class="btn btn-xs btn-ghost hover:bg-base-200 px-2" @click="collapseAll()">Collapse All</button>
</div>
</div>
</div>
<!-- Grouped List -->
<div class="space-y-4">
<template x-if="loading && list.length === 0">
<div class="py-12 text-center">
<span class="loading loading-spinner loading-md text-primary"></span>
</div>
</template>
<template x-if="!loading && list.length === 0">
<div class="py-12 text-center opacity-40">
<i class="fa-solid fa-inbox text-4xl mb-2"></i>
<p>No records found</p>
</div>
</template>
<!-- Product Groups -->
<template x-for="(lots, productName) in nestedList" :key="productName">
<div
class="bg-base-100 rounded-2xl border border-base-300 shadow-sm overflow-hidden border-l-4 border-l-primary">
<!-- Product Header -->
<div class="px-6 py-4 bg-base-200/30 flex items-center justify-between border-b border-base-300">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-primary text-primary-content flex items-center justify-center shadow-md">
<i class="fa-solid fa-boxes-stacked"></i>
</div>
<div>
<h3 class="font-bold text-base text-base-content" x-text="productName"></h3>
<p class="text-[10px] opacity-60 uppercase font-semibold tracking-wider"
x-text="Object.keys(lots).length + ' lot(s) active'"></p>
</div>
</div>
</div>
<!-- Lots Container -->
<div class="p-4 bg-base-100 space-y-4">
<template x-for="(lotData, lotName) in lots" :key="lotName">
<div class="border border-base-300 rounded-xl overflow-hidden bg-base-200/20">
<!-- Lot Header -->
<div class="px-4 py-2 bg-base-200/50 flex items-center justify-between cursor-pointer hover:bg-base-200/80 transition-colors"
@click="toggleLot(productName, lotName)">
<div class="flex items-center gap-3">
<i class="fa-solid fa-chevron-right text-[10px] opacity-40 transition-transform"
:class="isLotOpen(productName, lotName) ? 'rotate-90' : ''"></i>
<span class="text-xs font-bold text-base-content/80">
Lot: <span class="badge badge-sm badge-outline border-base-300"
x-text="lotName"></span>
</span>
</div>
<button class="btn btn-xs btn-ghost text-primary gap-1"
@click.stop="showForm(null, productName, lotName)">
<i class="fa-solid fa-plus text-[10px]"></i> Add Parameter
</button>
</div>
<!-- Table Section -->
<div x-show="isLotOpen(productName, lotName)" x-collapse>
<div class="overflow-x-auto">
<table class="table table-sm w-full divide-y divide-base-300">
<thead>
<tr class="bg-base-200/30 text-[10px] opacity-50 uppercase">
<th class="pl-12">Test Name</th>
<th class="text-right">Target Mean</th>
<th class="text-right">Target SD</th>
<th class="text-right pr-4">Actions</th>
</tr>
</thead>
<tbody class="bg-base-100 divide-y divide-base-200">
<template x-for="item in lotData.tests" :key="item.controlTestId">
<tr class="hover:bg-primary/5 group/row">
<td class="pl-12 font-medium text-xs py-2" x-text="item.testName">
</td>
<td class="text-right font-mono text-xs"
x-text="formatNumber(item.mean)"></td>
<td class="text-right font-mono text-xs"
x-text="formatNumber(item.sd)"></td>
<td class="text-right pr-4">
<div
class="flex justify-end gap-1 opacity-0 group-hover/row:opacity-100 transition-opacity">
<button
class="btn btn-xs btn-square btn-ghost text-amber-600"
@click="showForm(item.controlTestId)">
<i class="fa-solid fa-pencil text-[10px]"></i>
</button>
<button class="btn btn-xs btn-square btn-ghost text-error"
@click="deleteData(item.controlTestId)">
<i class="fa-solid fa-trash text-[10px]"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
<?= $this->include('master/control_test/dialog_control_test_form'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script"); ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("controlTests", () => ({
loading: false,
showModal: false,
errors: {},
error: null,
keyword: "",
list: [],
controls: [],
tests: [],
openLots: [], // Store as "ProductName:LotName"
form: {
controlTestId: null,
controlId: "",
testId: "",
mean: "",
sd: "",
},
get nestedList() {
return this.list.reduce((acc, item) => {
const prod = item.controlName || 'Unassigned';
const lot = item.lot || 'No Lot';
if (!acc[prod]) acc[prod] = {};
if (!acc[prod][lot]) acc[prod][lot] = { tests: [], controlId: item.controlId };
acc[prod][lot].tests.push(item);
return acc;
}, {});
},
formatNumber(value) {
return value ? parseFloat(value).toFixed(4) : '-';
},
async init() {
await Promise.all([this.fetchDropdowns(), this.fetchList()]);
},
isLotOpen(prod, lot) {
return this.openLots.includes(`${prod}:${lot}`);
},
toggleLot(prod, lot) {
const key = `${prod}:${lot}`;
if (this.isLotOpen(prod, lot)) {
this.openLots = this.openLots.filter(k => k !== key);
} else {
this.openLots.push(key);
}
},
expandAll() {
const keys = [];
for (const prod in this.nestedList) {
for (const lot in this.nestedList[prod]) {
keys.push(`${prod}:${lot}`);
}
}
this.openLots = keys;
},
collapseAll() {
this.openLots = [];
},
async fetchDropdowns() {
try {
const [cRes, tRes] = await Promise.all([
fetch(`${BASEURL}api/master/controls`).then(r => r.json()),
fetch(`${BASEURL}api/master/tests`).then(r => r.json())
]);
this.controls = cRes.data || [];
this.tests = tRes.data || [];
} catch (err) {
console.error("Failed to load dropdowns", err);
}
},
async fetchList() {
this.loading = true;
this.error = null;
try {
const params = new URLSearchParams({ keyword: this.keyword });
const res = await fetch(`${BASEURL}api/qc/control-tests?${params}`);
if (!res.ok) throw new Error("Load failed");
const data = await res.json();
this.list = data.data || [];
if (this.keyword.length > 0) this.expandAll();
else if (this.list.length > 0) {
// Open first lot of first product by default
const firstProd = Object.keys(this.nestedList)[0];
const firstLot = Object.keys(this.nestedList[firstProd])[0];
this.openLots = [`${firstProd}:${firstLot}`];
}
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
async showForm(id = null, prodName = null, lotName = null) {
this.errors = {};
if (id) {
const item = this.list.find(i => i.controlTestId === id);
this.form = { ...item };
} else {
this.form = { controlTestId: null, controlId: "", testId: "", mean: "", sd: "" };
if (prodName && lotName) {
const ctrl = this.controls.find(c => c.controlName === prodName && c.lot === lotName);
if (ctrl) this.form.controlId = ctrl.controlId;
}
}
this.showModal = true;
},
closeModal() {
this.showModal = false;
},
async save() {
this.errors = {};
if (!this.form.controlId) this.errors.controlId = "Required";
if (!this.form.testId) this.errors.testId = "Required";
if (!this.form.mean) this.errors.mean = "Required";
if (!this.form.sd) this.errors.sd = "Required";
if (Object.keys(this.errors).length > 0) return;
this.loading = true;
const isEdit = !!this.form.controlTestId;
const url = `${BASEURL}api/qc/control-tests${isEdit ? '/' + this.form.controlTestId : ''}`;
try {
const res = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const data = await res.json();
if (data.status === 'success') {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Save failed");
}
} catch (err) {
alert("Save failed");
} finally {
this.loading = false;
}
},
async deleteData(id) {
if (!confirm("Delete association?")) return;
try {
const res = await fetch(`${BASEURL}api/qc/control-tests/${id}`, { method: "DELETE" });
const data = await res.json();
if (data.status === 'success') this.fetchList();
} catch (err) {
alert("Delete failed");
}
}
}));
});
</script>
<?= $this->endSection(); ?>