tinyqc/app/Views/entry/monthly.php
mahdahar 2e23fc38c3 US-017: Implement Daily Comments per day per test
- Add per-day comment input in monthly entry grid (comment icon per cell)
- Update saveMonthly() to save rescomment field for each result
- Update getMonthlyData() to return per-day comments
- Store comments in results.rescomment field with soft deletes support
2026-01-16 17:22:40 +07:00

499 lines
24 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<main x-data="monthlyEntry()" @keydown.window.ctrl.s.prevent="saveData()">
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Monthly Entry</h1>
<p class="text-sm text-slate-500 mt-1">Enter monthly QC results</p>
</div>
<div class="flex items-center gap-2 text-sm text-slate-500">
<i class="fa-solid fa-clock"></i>
<span>Press Ctrl+S to save</span>
</div>
</div>
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
<div class="flex items-center gap-2">
<i class="fa-solid fa-circle-exclamation"></i>
<span x-text="error"></span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Department <span class="text-red-500">*</span></label>
<select x-model="dept" @change="loadControls()" :class="errors.dept ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">Select Department</option>
<?php foreach ($depts as $d): ?>
<option value="<?= $d['dept_id'] ?>"><?= $d['name'] ?></option>
<?php endforeach; ?>
</select>
<p x-show="errors.dept" x-text="errors.dept" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Month <span class="text-red-500">*</span></label>
<input type="month" x-model="date" @change="loadControls(); loadMonthData()" :class="errors.date ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<p x-show="errors.date" x-text="errors.date" class="text-red-500 text-xs mt-1"></p>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">Control <span class="text-red-500">*</span></label>
<select x-model="control" @change="loadTests(); loadMonthData()" :class="errors.control ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">Select Control</option>
<template x-for="c in controls" :key="c.control_id">
<option :value="c.control_id" x-text="c.name + ' (' + (c.lot || 'N/A') + ')'"></option>
</template>
</select>
<p x-show="errors.control" x-text="errors.control" class="text-red-500 text-xs mt-1"></p>
</div>
</div>
<div x-show="control && tests.length > 0" x-transition>
<div class="mb-4">
<label class="block text-sm font-medium text-slate-700 mb-2">Select Tests <span class="text-red-500">*</span></label>
<div class="flex flex-wrap gap-2">
<template x-for="t in tests" :key="t.test_ref_id">
<label class="inline-flex items-center px-3 py-1.5 rounded-lg border cursor-pointer transition-all"
:class="selectedTests.includes(t.test_ref_id) ? 'bg-blue-50 border-blue-500 text-blue-700' : 'bg-slate-50 border-slate-200 hover:border-slate-300'">
<input type="checkbox" :value="t.test_ref_id" x-model="selectedTests" @change="loadMonthData()" class="hidden">
<span class="text-sm font-medium" x-text="t.test_name"></span>
</label>
</template>
</div>
</div>
</div>
<div x-show="control && selectedTests.length > 0" x-transition>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-slate-800">
<span x-text="monthName"></span> <span x-text="year"></span>
</h3>
<div class="flex items-center gap-4 text-sm">
<div class="flex items-center gap-2">
<span class="w-4 h-4 rounded bg-slate-100 border border-slate-200"></span>
<span class="text-slate-600">Weekday</span>
</div>
<div class="flex items-center gap-2">
<span class="w-4 h-4 rounded bg-red-50 border border-red-200"></span>
<span class="text-slate-600">Weekend</span>
</div>
</div>
</div>
<template x-if="loading">
<div class="flex items-center justify-center py-12">
<i class="fa-solid fa-spinner fa-spin text-blue-500 text-3xl"></i>
</div>
</template>
<template x-if="!loading">
<div class="border border-slate-200 rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-slate-50 border-b border-slate-200">
<th class="py-3 px-3 text-left font-semibold text-slate-600 w-32 sticky left-0 bg-slate-50">Test</th>
<template x-for="day in daysInMonth" :key="day">
<th class="py-3 px-2 text-center font-medium text-slate-600 min-w-[60px]"
:class="isWeekend(day) ? 'bg-red-50 text-red-600' : ''">
<span x-text="day"></span>
</th>
</template>
</tr>
</thead>
<tbody>
<template x-for="testId in selectedTests" :key="testId">
<tr class="border-b border-slate-100 hover:bg-slate-50/50">
<td class="py-2 px-3 font-medium text-slate-700 sticky left-0 bg-white border-r border-slate-100">
<span x-text="getTestName(testId)"></span>
</td>
<template x-for="day in daysInMonth" :key="day">
<td class="py-1 px-1 relative" :class="isWeekend(day) ? 'bg-red-50' : ''">
<div class="flex items-center gap-1">
<input
type="number"
step="0.01"
class="w-full px-2 py-1.5 text-center text-sm bg-white border border-slate-200 rounded focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
:placeholder="'-'"
x-model="monthlyData[testId + '_' + day]"
@change="markDirty(testId, day)"
>
<button
class="p-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
:title="monthlyData[testId + '_' + day + '_comment'] || 'Add comment'"
@click.stop="toggleComment(testId, day)"
>
<i class="fa-solid fa-comment text-xs" :class="monthlyData[testId + '_' + day + '_comment'] ? 'text-blue-500' : ''"></i>
</button>
</div>
<div
x-show="showCommentId === testId + '_' + day"
x-transition
class="absolute z-10 top-full left-0 mt-1 w-48"
@click.outside="showCommentId = null"
>
<textarea
class="w-full px-2 py-1.5 text-xs bg-white border border-slate-200 rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 resize-none"
rows="2"
placeholder="Add comment..."
x-model="monthlyData[testId + '_' + day + '_comment']"
@keydown.enter.prevent
></textarea>
</div>
</td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<div class="mt-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Monthly Comment</label>
<textarea x-model="comment" rows="2" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Optional monthly comment"></textarea>
</div>
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-sm font-medium text-slate-700 mb-1">Operation Mode</label>
<select x-model="operation" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="replace">Replace All</option>
<option value="add">Add Only (Skip Existing)</option>
</select>
</div>
<button @click="saveData()" data-save-btn :disabled="loading || selectedTests.length === 0" class="btn btn-primary flex-shrink-0">
<template x-if="!loading">
<span class="flex items-center justify-center">
<i class="fa-solid fa-check mr-2"></i>
Save All Data
</span>
</template>
<template x-if="loading">
<span class="flex items-center justify-center">
<i class="fa-solid fa-spinner fa-spin mr-2"></i>
Saving...
</span>
</template>
</button>
</div>
</div>
</div>
</div>
<div x-show="control && (!selectedTests || selectedTests.length === 0)" x-transition class="text-center py-12 text-slate-500">
<i class="fa-solid fa-clipboard-list text-4xl mb-3 text-slate-300"></i>
<p>Select one or more tests above to view and enter monthly data</p>
</div>
<div x-show="!control" x-transition class="text-center py-12 text-slate-500">
<i class="fa-solid fa-list-check text-4xl mb-3 text-slate-300"></i>
<p>Select a control to view available tests</p>
</div>
</div>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script") ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("monthlyEntry", () => ({
dept: '',
date: new Date().toISOString().slice(0, 7),
control: '',
tests: [],
selectedTests: [],
controls: [],
loading: false,
errors: {},
error: '',
monthlyData: {},
comment: '',
dirtyCells: new Set(),
operation: 'replace',
saveResult: null,
showCommentId: null,
init() {
this.loadDraft();
},
get year() {
return this.date ? this.date.split('-')[0] : '';
},
get monthName() {
if (!this.date) return '';
const month = parseInt(this.date.split('-')[1]);
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return months[month - 1] || '';
},
get daysInMonth() {
if (!this.date) return 31;
const [year, month] = this.date.split('-').map(Number);
return new Date(year, month, 0).getDate();
},
isWeekend(day) {
if (!this.date) return false;
const [year, month] = this.date.split('-').map(Number);
const date = new Date(year, month - 1, day);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6;
},
getTestName(testId) {
const test = this.tests.find(t => t.test_ref_id === testId);
return test ? test.test_name : '';
},
markDirty(testId, day) {
this.dirtyCells.add(testId + '_' + day);
},
toggleComment(testId, day) {
const id = testId + '_' + day;
if (this.showCommentId === id) {
this.showCommentId = null;
} else {
this.showCommentId = id;
}
},
async loadControls() {
this.errors = {};
this.controls = [];
this.control = '';
this.tests = [];
this.selectedTests = [];
this.monthlyData = {};
this.comment = '';
if (!this.dept || !this.date) {
return;
}
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}/api/entry/controls?date=${this.date}&deptid=${this.dept}`);
const data = await response.json();
if (data.status === 'success') {
this.controls = data.data || [];
if (this.controls.length === 0) {
App.showToast('No controls found for selected criteria', 'info');
}
} else {
this.error = data.message || 'Failed to load controls';
}
} catch (error) {
console.error(error);
this.error = 'Failed to load controls';
} finally {
this.loading = false;
}
},
async loadTests() {
this.errors = {};
this.tests = [];
this.selectedTests = [];
this.monthlyData = {};
this.comment = '';
if (!this.control) {
return;
}
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}/api/entry/tests?controlid=${this.control}`);
const data = await response.json();
if (data.status === 'success') {
this.tests = data.data || [];
if (this.tests.length === 0) {
App.showToast('No tests found for this control', 'info');
} else {
this.selectedTests = this.tests.map(t => t.test_ref_id);
}
} else {
this.error = data.message || 'Failed to load tests';
}
} catch (error) {
console.error(error);
this.error = 'Failed to load tests';
} finally {
this.loading = false;
}
},
async loadMonthData() {
if (!this.control || this.selectedTests.length === 0 || !this.date) {
return;
}
this.loading = true;
this.monthlyData = {};
this.comment = '';
try {
for (const testId of this.selectedTests) {
const response = await fetch(`${window.BASEURL}/api/entry/monthly?controlid=${this.control}&testid=${testId}&yearmonth=${this.date}`);
const data = await response.json();
if (data.status === 'success') {
const formValues = data.data.formValues || {};
const comments = data.data.comments || {};
for (const [day, value] of Object.entries(formValues)) {
this.monthlyData[testId + '_' + day] = value;
}
for (const [day, comment] of Object.entries(comments)) {
this.monthlyData[testId + '_' + day + '_comment'] = comment;
}
if (!this.comment && data.data.comment) {
this.comment = data.data.comment;
}
}
}
} catch (error) {
console.error(error);
this.error = 'Failed to load monthly data';
} finally {
this.loading = false;
}
},
validate() {
this.errors = {};
if (!this.dept) this.errors.dept = 'Department is required';
if (!this.date) this.errors.date = 'Date is required';
if (!this.control) this.errors.control = 'Control is required';
if (this.selectedTests.length === 0) {
App.showToast('Please select at least one test', 'error');
return false;
}
return true;
},
async saveData() {
if (!this.validate()) {
return;
}
if (!App.confirmSave('Save monthly entry data?')) return;
this.loading = true;
this.error = '';
this.saveResult = null;
try {
const tests = [];
for (const testId of this.selectedTests) {
const resvalue = {};
const rescomment = {};
let hasData = false;
for (let day = 1; day <= this.daysInMonth; day++) {
const key = testId + '_' + day;
const value = this.monthlyData[key];
const commentKey = testId + '_' + day + '_comment';
const comment = this.monthlyData[commentKey];
if (value !== undefined && value !== null && value !== '') {
resvalue[day] = value;
hasData = true;
}
if (comment !== undefined && comment !== null && comment !== '') {
rescomment[day] = comment;
}
}
if (hasData) {
tests.push({ testId, resvalue, rescomment });
}
}
const response = await fetch(`${window.BASEURL}/api/entry/monthly`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
controlId: this.control,
yearMonth: this.date,
operation: this.operation,
tests: tests
})
});
const data = await response.json();
if (data.status === 'success') {
this.saveResult = data.data;
let message = 'Data saved successfully!';
if (data.data.statistics && data.data.statistics.length > 0) {
message += `\n\nStatistics:\n`;
for (const stat of data.data.statistics) {
const testName = this.getTestName(stat.testId);
message += `${testName}: n=${stat.n}, Mean=${stat.mean}, SD=${stat.sd}, CV=${stat.cv}%\n`;
}
}
if (data.data.validations && data.data.validations.length > 0) {
message += `\n⚠ ${data.data.validations.length} value(s) out of control limits!`;
}
App.showToast(message, 'success');
this.dirtyCells.clear();
this.saveDraft();
} else {
throw new Error(data.message || 'Failed to save data');
}
} catch (error) {
console.error(error);
this.error = error.message || 'Network error. Please try again.';
} finally {
this.loading = false;
}
},
async saveComment() {
const formData = new FormData();
formData.append('controlid', this.control);
formData.append('testid', this.selectedTests[0]);
formData.append('commonth', this.date);
formData.append('comtext', this.comment);
try {
await fetch(`${window.BASEURL}/api/entry/comment`, {
method: 'POST',
body: formData
});
} catch (error) {
console.error('Failed to save comment:', error);
}
},
saveDraft() {
localStorage.setItem('monthlyEntry', JSON.stringify({
dept: this.dept,
date: this.date,
control: this.control,
test: this.test
}));
},
loadDraft() {
const draft = localStorage.getItem('monthlyEntry');
if (draft) {
const data = JSON.parse(draft);
this.dept = data.dept || '';
this.date = data.date || new Date().toISOString().slice(0, 7);
this.control = data.control || '';
this.test = data.test || '';
if (this.dept && this.date) this.loadControls();
if (this.control) this.loadTests();
}
}
}));
});
</script>
<?= $this->endSection(); ?>