tinyqc/app/Views/entry/monthly.php
mahdahar dd7a058511 feat: Implement Monthly Entry interface and consolidate Entry API controller
- New EntryApiController (app/Controllers/Api/EntryApiController.php)
    - Centralized API for entry operations (daily/monthly data retrieval and saving)
    - getControls() - Fetch controls with optional date-based expiry filtering
    - getTests() - Get tests associated with a control
    - getDailyData() - Retrieve daily results for a date/control
    - getMonthlyData() - Retrieve monthly results with per-day data and comments
    - saveDaily() - Batch save daily results with validation
    - saveMonthly() - Batch save monthly results with statistics
  - New Monthly Entry View (app/Views/entry/monthly.php)
    - Calendar grid interface for entering monthly QC results
    - Month selector with quick navigation (prev/next/current)
    - Test selector to filter controls
    - 31-day grid per control with inline editing
    - Visual QC range indicators (green for in-range, red for out-of-range)
    - Weekend highlighting
    - Per-control monthly comment field
    - Keyboard shortcut (Ctrl+S) for saving
    - Change tracking with pending save indicator
  - Route Updates (app/Config/Routes.php)
    - Added /entry/monthly page route
    - Added /api/entry/daily GET endpoint
  - Model Updates
    - ResultsModel: Added updateMonthly() for upserting monthly results
    - ResultCommentsModel: Added upsertMonthly() for monthly comments
2026-01-20 16:47:11 +07:00

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(); ?>