- 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
307 lines
14 KiB
PHP
307 lines
14 KiB
PHP
<?= $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(); ?>
|