415 lines
17 KiB
PHP
415 lines
17 KiB
PHP
|
|
<?= $this->extend("layout/main_layout"); ?>
|
||
|
|
|
||
|
|
<?= $this->section("content"); ?>
|
||
|
|
<main class="flex-1 p-6 overflow-auto"
|
||
|
|
x-data="monthlyEntry()">
|
||
|
|
<div class="flex justify-between items-center mb-6">
|
||
|
|
<div>
|
||
|
|
<h1 class="text-2xl font-bold text-base-content tracking-tight">Monthly Entry</h1>
|
||
|
|
<p class="text-sm mt-1 opacity-70">Record monthly QC results and comments</p>
|
||
|
|
</div>
|
||
|
|
<button @click="saveAll()"
|
||
|
|
:disabled="saving || !canSave"
|
||
|
|
class="btn btn-primary"
|
||
|
|
:class="{ 'loading': saving }">
|
||
|
|
<i class="fa-solid fa-save mr-2"></i>
|
||
|
|
Save All
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Filters -->
|
||
|
|
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6">
|
||
|
|
<div class="flex flex-wrap gap-4 items-end">
|
||
|
|
<div class="form-control">
|
||
|
|
<label class="label">
|
||
|
|
<span class="label-text font-medium">Month</span>
|
||
|
|
</label>
|
||
|
|
<input type="month"
|
||
|
|
x-model="month"
|
||
|
|
@change="fetchControls()"
|
||
|
|
class="input input-bordered w-40">
|
||
|
|
</div>
|
||
|
|
<div class="form-control">
|
||
|
|
<label class="label">
|
||
|
|
<span class="label-text font-medium">Test</span>
|
||
|
|
</label>
|
||
|
|
<select x-model="selectedTest"
|
||
|
|
@change="fetchControls()"
|
||
|
|
class="select select-bordered w-64">
|
||
|
|
<option value="">Select Test</option>
|
||
|
|
<template x-for="test in tests" :key="test.id">
|
||
|
|
<option :value="test.id" x-text="test.testName"></option>
|
||
|
|
</template>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div class="form-control">
|
||
|
|
<label class="label">
|
||
|
|
<span class="label-text font-medium">Quick Month</span>
|
||
|
|
</label>
|
||
|
|
<div class="flex gap-2">
|
||
|
|
<button @click="prevMonth()" class="btn btn-sm btn-outline"><i class="fa-solid fa-chevron-left"></i></button>
|
||
|
|
<button @click="setCurrentMonth()" class="btn btn-sm btn-outline">Current</button>
|
||
|
|
<button @click="nextMonth()" class="btn btn-sm btn-outline"><i class="fa-solid fa-chevron-right"></i></button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Loading State -->
|
||
|
|
<div x-show="loading" class="flex justify-center py-12">
|
||
|
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Empty State -->
|
||
|
|
<div x-show="!loading && selectedTest && controls.length === 0"
|
||
|
|
class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-12 text-center">
|
||
|
|
<i class="fa-solid fa-vial text-4xl text-base-content/20 mb-3"></i>
|
||
|
|
<p class="text-base-content/60">No controls configured for this test</p>
|
||
|
|
<p class="text-sm text-base-content/40 mt-1">Add controls in the Control-Tests setup</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- No Test Selected -->
|
||
|
|
<div x-show="!loading && !selectedTest"
|
||
|
|
class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-12 text-center">
|
||
|
|
<i class="fa-solid fa-list-check text-4xl text-base-content/20 mb-3"></i>
|
||
|
|
<p class="text-base-content/60">Select a test to view controls and calendar</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Calendar Grid -->
|
||
|
|
<div x-show="!loading && selectedTest && controls.length > 0"
|
||
|
|
class="space-y-6">
|
||
|
|
<!-- Calendar Header -->
|
||
|
|
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
|
||
|
|
<div class="p-3 bg-base-200 border-b border-base-300">
|
||
|
|
<div class="flex justify-between items-center">
|
||
|
|
<div>
|
||
|
|
<h3 class="font-medium" x-text="testName + ' - ' + monthDisplay"></h3>
|
||
|
|
<p class="text-xs text-base-content/60" x-text="testUnit || ''"></p>
|
||
|
|
</div>
|
||
|
|
<div class="text-xs text-base-content/70 text-right" x-show="qcParameters">
|
||
|
|
<span x-text="qcParameters"></span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<table class="w-full">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th class="sticky left-0 bg-base-200 z-10 w-48 p-2 text-left border-r border-base-300">
|
||
|
|
Control
|
||
|
|
</th>
|
||
|
|
<template x-for="day in daysInMonth" :key="day">
|
||
|
|
<th class="w-14 p-1 text-center text-xs"
|
||
|
|
:class="{
|
||
|
|
'bg-base-200': isWeekend(day),
|
||
|
|
'text-base-content/50': isWeekend(day)
|
||
|
|
}"
|
||
|
|
x-text="day"></th>
|
||
|
|
</template>
|
||
|
|
<th class="w-48 p-2 text-left border-l border-base-300">Comment</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
<template x-for="control in controls" :key="control.controlId">
|
||
|
|
<tr class="hover">
|
||
|
|
<td class="sticky left-0 bg-base-100 z-10 p-2 border-r border-base-300">
|
||
|
|
<div class="font-medium text-sm" x-text="control.controlName"></div>
|
||
|
|
<div class="text-xs text-base-content/50" x-text="control.lot || ''"></div>
|
||
|
|
</td>
|
||
|
|
<template x-for="day in daysInMonth" :key="day">
|
||
|
|
<td class="p-0.5 text-center border-r border-base-200 last:border-r-0"
|
||
|
|
:class="{
|
||
|
|
'bg-base-200/50': isWeekend(day)
|
||
|
|
}">
|
||
|
|
<input type="text"
|
||
|
|
inputmode="decimal"
|
||
|
|
:placeholder="'/'"
|
||
|
|
class="input input-bordered input-xs w-full text-center font-mono"
|
||
|
|
:class="getCellClass(control, day)"
|
||
|
|
@input="updateResult(control.controlId, day, $event.target.value)"
|
||
|
|
:value="getResultValue(control, day)"
|
||
|
|
@focus="selectCell($event.target)">
|
||
|
|
</td>
|
||
|
|
</template>
|
||
|
|
<td class="p-2 border-l border-base-300">
|
||
|
|
<textarea class="textarea textarea-bordered textarea-xs w-full"
|
||
|
|
:placeholder="'Monthly comment...'"
|
||
|
|
rows="1"
|
||
|
|
@input="updateComment(control.controlId, $event.target.value)"
|
||
|
|
:value="getComment(control.controlId)"></textarea>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</template>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Legend -->
|
||
|
|
<div class="flex flex-wrap gap-4 text-sm text-base-content/70">
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<span class="w-4 h-4 border border-base-300 rounded bg-success/20"></span>
|
||
|
|
<span>In Range</span>
|
||
|
|
</div>
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<span class="w-4 h-4 border border-base-300 rounded bg-error/20"></span>
|
||
|
|
<span>Out of Range</span>
|
||
|
|
</div>
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<span class="w-4 h-4 border border-base-300 rounded bg-base-200"></span>
|
||
|
|
<span>Weekend</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Summary -->
|
||
|
|
<div x-show="hasChanges"
|
||
|
|
class="mt-4 p-3 bg-info/10 rounded-lg border border-info/20 text-sm">
|
||
|
|
<i class="fa-solid fa-info-circle mr-2"></i>
|
||
|
|
<span x-text="changedCount"></span> cell(s) with changes pending save
|
||
|
|
</div>
|
||
|
|
</main>
|
||
|
|
<?= $this->endSection(); ?>
|
||
|
|
|
||
|
|
<?= $this->section("script"); ?>
|
||
|
|
<script>
|
||
|
|
document.addEventListener('alpine:init', () => {
|
||
|
|
Alpine.data("monthlyEntry", () => ({
|
||
|
|
month: new Date().toISOString().slice(0, 7),
|
||
|
|
tests: [],
|
||
|
|
selectedTest: null,
|
||
|
|
controls: [],
|
||
|
|
loading: false,
|
||
|
|
saving: false,
|
||
|
|
resultsData: {},
|
||
|
|
commentsData: {},
|
||
|
|
|
||
|
|
init() {
|
||
|
|
this.fetchTests();
|
||
|
|
this.setupKeyboard();
|
||
|
|
},
|
||
|
|
|
||
|
|
async fetchTests() {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${BASEURL}api/test`);
|
||
|
|
const json = await response.json();
|
||
|
|
this.tests = json.data || [];
|
||
|
|
} catch (e) {
|
||
|
|
console.error('Failed to fetch tests:', e);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
async fetchControls() {
|
||
|
|
if (!this.selectedTest) {
|
||
|
|
this.controls = [];
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 (json.status === 'success') {
|
||
|
|
this.controls = json.data.controls || [];
|
||
|
|
|
||
|
|
// Build results lookup
|
||
|
|
this.resultsData = {};
|
||
|
|
this.commentsData = {};
|
||
|
|
for (const control of this.controls) {
|
||
|
|
for (let day = 1; day <= 31; day++) {
|
||
|
|
const result = control.results[day];
|
||
|
|
if (result && result.resValue !== null) {
|
||
|
|
this.resultsData[`${control.controlId}_${day}`] = result.resValue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (control.comment) {
|
||
|
|
this.commentsData[control.controlId] = control.comment.comText;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
console.error('Failed to fetch controls:', e);
|
||
|
|
} finally {
|
||
|
|
this.loading = false;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
async saveAll() {
|
||
|
|
if (!this.canSave) return;
|
||
|
|
|
||
|
|
this.saving = true;
|
||
|
|
try {
|
||
|
|
const controls = [];
|
||
|
|
for (const control of this.controls) {
|
||
|
|
const results = [];
|
||
|
|
for (let day = 1; day <= 31; day++) {
|
||
|
|
const key = `${control.controlId}_${day}`;
|
||
|
|
const value = this.resultsData[key];
|
||
|
|
if (value !== undefined && value !== '') {
|
||
|
|
results[day] = value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
controls.push({
|
||
|
|
controlId: control.controlId,
|
||
|
|
results: results,
|
||
|
|
comment: this.commentsData[control.controlId] || null
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await fetch(`${BASEURL}api/entry/monthly`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
testId: this.selectedTest,
|
||
|
|
month: this.month,
|
||
|
|
controls: controls
|
||
|
|
})
|
||
|
|
});
|
||
|
|
|
||
|
|
const json = await response.json();
|
||
|
|
if (json.status === 'success') {
|
||
|
|
this.$dispatch('notify', { type: 'success', message: json.message });
|
||
|
|
// Refresh to get updated data
|
||
|
|
await this.fetchControls();
|
||
|
|
} else {
|
||
|
|
this.$dispatch('notify', { type: 'error', message: json.message || 'Failed to save' });
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
console.error('Failed to save:', e);
|
||
|
|
this.$dispatch('notify', { type: 'error', message: 'Failed to save results' });
|
||
|
|
} finally {
|
||
|
|
this.saving = false;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
updateResult(controlId, day, value) {
|
||
|
|
const key = `${controlId}_${day}`;
|
||
|
|
if (value === '') {
|
||
|
|
delete this.resultsData[key];
|
||
|
|
} else {
|
||
|
|
this.resultsData[key] = value;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
updateComment(controlId, value) {
|
||
|
|
this.commentsData[controlId] = value;
|
||
|
|
},
|
||
|
|
|
||
|
|
getResultValue(control, day) {
|
||
|
|
const key = `${control.controlId}_${day}`;
|
||
|
|
return this.resultsData[key] !== undefined ? this.resultsData[key] : '';
|
||
|
|
},
|
||
|
|
|
||
|
|
getComment(controlId) {
|
||
|
|
return this.commentsData[controlId] || '';
|
||
|
|
},
|
||
|
|
|
||
|
|
getCellClass(control, day) {
|
||
|
|
const key = `${control.controlId}_${day}`;
|
||
|
|
const value = this.resultsData[key];
|
||
|
|
if (value === undefined || value === '') return '';
|
||
|
|
|
||
|
|
if (control.mean === null || control.sd === null) return '';
|
||
|
|
|
||
|
|
const num = parseFloat(value);
|
||
|
|
const lower = control.mean - 2 * control.sd;
|
||
|
|
const upper = control.mean + 2 * control.sd;
|
||
|
|
|
||
|
|
if (num >= lower && num <= upper) return 'bg-success/20';
|
||
|
|
return 'bg-error/20';
|
||
|
|
},
|
||
|
|
|
||
|
|
get daysInMonth() {
|
||
|
|
const [year, month] = this.month.split('-').map(Number);
|
||
|
|
const days = new Date(year, month, 0).getDate();
|
||
|
|
return Array.from({ length: days }, (_, i) => i + 1);
|
||
|
|
},
|
||
|
|
|
||
|
|
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 testName() {
|
||
|
|
const test = this.tests.find(t => t.id == this.selectedTest);
|
||
|
|
return test ? test.testName : '';
|
||
|
|
},
|
||
|
|
|
||
|
|
get testUnit() {
|
||
|
|
const test = this.tests.find(t => t.id == this.selectedTest);
|
||
|
|
return test ? test.testUnit : '';
|
||
|
|
},
|
||
|
|
|
||
|
|
get qcParameters() {
|
||
|
|
if (!this.controls || this.controls.length === 0) return '';
|
||
|
|
const first = this.controls[0];
|
||
|
|
if (first.mean !== null && first.sd !== null) {
|
||
|
|
return 'Mean: ' + first.mean.toFixed(2) + ' ± ' + (2 * first.sd).toFixed(2);
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
},
|
||
|
|
|
||
|
|
prevMonth() {
|
||
|
|
const [year, month] = this.month.split('-').map(Number);
|
||
|
|
const date = new Date(year, month - 2, 1);
|
||
|
|
this.month = date.toISOString().slice(0, 7);
|
||
|
|
this.fetchControls();
|
||
|
|
},
|
||
|
|
|
||
|
|
nextMonth() {
|
||
|
|
const [year, month] = this.month.split('-').map(Number);
|
||
|
|
const date = new Date(year, month, 1);
|
||
|
|
this.month = date.toISOString().slice(0, 7);
|
||
|
|
this.fetchControls();
|
||
|
|
},
|
||
|
|
|
||
|
|
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;
|
||
|
|
},
|
||
|
|
|
||
|
|
get hasChanges() {
|
||
|
|
return Object.keys(this.resultsData).length > 0 ||
|
||
|
|
Object.keys(this.commentsData).length > 0;
|
||
|
|
},
|
||
|
|
|
||
|
|
get changedCount() {
|
||
|
|
return Object.keys(this.resultsData).length;
|
||
|
|
},
|
||
|
|
|
||
|
|
get canSave() {
|
||
|
|
return this.selectedTest && this.hasChanges && !this.saving;
|
||
|
|
},
|
||
|
|
|
||
|
|
setCurrentMonth() {
|
||
|
|
this.month = new Date().toISOString().slice(0, 7);
|
||
|
|
},
|
||
|
|
|
||
|
|
selectCell(element) {
|
||
|
|
element.select();
|
||
|
|
},
|
||
|
|
|
||
|
|
setupKeyboard() {
|
||
|
|
document.addEventListener('keydown', (e) => {
|
||
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||
|
|
e.preventDefault();
|
||
|
|
if (this.canSave) {
|
||
|
|
this.saveAll();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
<?= $this->endSection(); ?>
|