2026-01-20 16:47:11 +07:00
|
|
|
<?= $this->extend("layout/main_layout"); ?>
|
|
|
|
|
|
|
|
|
|
<?= $this->section("content"); ?>
|
2026-01-21 13:41:37 +07:00
|
|
|
<style>
|
|
|
|
|
/* Hide number input spinners */
|
|
|
|
|
.no-spinner::-webkit-outer-spin-button,
|
|
|
|
|
.no-spinner::-webkit-inner-spin-button {
|
|
|
|
|
-webkit-appearance: none;
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
.no-spinner {
|
|
|
|
|
-moz-appearance: textfield;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
<main class="flex-1 p-6 overflow-auto" x-data="monthlyEntry()">
|
2026-01-20 16:47:11 +07:00
|
|
|
<div class="flex justify-between items-center mb-6">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 class="text-2xl font-bold text-base-content tracking-tight">Monthly Entry</h1>
|
2026-01-21 13:41:37 +07:00
|
|
|
<p class="text-sm mt-1 opacity-70">Batch enter monthly results for all control levels</p>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
2026-01-21 13:41:37 +07:00
|
|
|
<div class="flex gap-2">
|
|
|
|
|
<button @click="resetData()" class="btn btn-ghost btn-sm" x-show="hasChanges">
|
|
|
|
|
Discard Changes
|
|
|
|
|
</button>
|
|
|
|
|
<button @click="saveAll()" :disabled="saving || !canSave" class="btn btn-primary"
|
2026-01-20 16:47:11 +07:00
|
|
|
:class="{ 'loading': saving }">
|
2026-01-21 13:41:37 +07:00
|
|
|
<i class="fa-solid fa-save mr-2"></i>
|
|
|
|
|
Save Results
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Filters -->
|
|
|
|
|
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6">
|
2026-01-21 13:41:37 +07:00
|
|
|
<div class="flex flex-wrap gap-6 items-center">
|
2026-01-20 16:47:11 +07:00
|
|
|
<div class="form-control">
|
2026-01-21 13:41:37 +07:00
|
|
|
<label class="label pt-0">
|
|
|
|
|
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Reporting Month</span>
|
2026-01-20 16:47:11 +07:00
|
|
|
</label>
|
2026-01-21 13:41:37 +07:00
|
|
|
<div class="join">
|
|
|
|
|
<button @click="prevMonth()" class="join-item btn btn-sm btn-outline border-base-300"><i
|
|
|
|
|
class="fa-solid fa-chevron-left"></i></button>
|
|
|
|
|
<input type="month" x-model="month" @change="onMonthChange()"
|
|
|
|
|
class="join-item input input-bordered input-sm w-36 text-center font-medium bg-base-200/30">
|
|
|
|
|
<button @click="nextMonth()" class="join-item btn btn-sm btn-outline border-base-300"><i
|
|
|
|
|
class="fa-solid fa-chevron-right"></i></button>
|
|
|
|
|
</div>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
2026-01-21 13:41:37 +07:00
|
|
|
|
|
|
|
|
<div class="divider divider-horizontal mx-0 hidden sm:flex"></div>
|
|
|
|
|
|
2026-02-03 16:55:13 +07:00
|
|
|
<div class="form-control">
|
|
|
|
|
<label class="label pt-0">
|
|
|
|
|
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Department</span>
|
|
|
|
|
</label>
|
2026-02-05 20:06:51 +07:00
|
|
|
<select x-model="deptId" @change="setDeptId(deptId)" class="select select-bordered select-sm w-48">
|
|
|
|
|
<option value="">All Departments</option>
|
|
|
|
|
<template x-if="departments">
|
|
|
|
|
<template x-for="dept in departments" :key="dept.deptId">
|
|
|
|
|
<option :value="dept.deptId" x-text="dept.deptName"></option>
|
2026-02-03 16:55:13 +07:00
|
|
|
</template>
|
2026-02-05 20:06:51 +07:00
|
|
|
</template>
|
|
|
|
|
<template x-if="!departments">
|
|
|
|
|
<option disabled>Loading...</option>
|
|
|
|
|
</template>
|
|
|
|
|
</select>
|
2026-02-03 16:55:13 +07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="divider divider-horizontal mx-0 hidden sm:flex"></div>
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
<div class="form-control flex-1 max-w-xs">
|
|
|
|
|
<label class="label pt-0">
|
|
|
|
|
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Select Test</span>
|
2026-01-20 16:47:11 +07:00
|
|
|
</label>
|
2026-01-21 13:41:37 +07:00
|
|
|
<select x-model="selectedTest" @change="fetchMonthlyData()"
|
|
|
|
|
class="select select-bordered select-sm w-full font-medium">
|
|
|
|
|
<option value="">Choose a test...</option>
|
|
|
|
|
<template x-for="test in tests" :key="test.testId">
|
|
|
|
|
<option :value="String(test.testId)" x-text="test.testName"></option>
|
2026-01-20 16:47:11 +07:00
|
|
|
</template>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2026-01-21 13:41:37 +07:00
|
|
|
|
|
|
|
|
<template x-if="selectedTest">
|
|
|
|
|
<div class="flex flex-col">
|
|
|
|
|
<span class="text-[10px] font-semibold opacity-60 uppercase mb-1">Unit</span>
|
|
|
|
|
<span class="badge badge-outline badge-sm" x-text="testUnit || 'N/A'"></span>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
2026-01-21 13:41:37 +07:00
|
|
|
</template>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Loading State -->
|
2026-01-21 13:41:37 +07:00
|
|
|
<div x-show="loading" class="flex flex-col items-center justify-center py-24 gap-4">
|
2026-01-20 16:47:11 +07:00
|
|
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
2026-01-21 13:41:37 +07:00
|
|
|
<p class="text-sm opacity-50 animate-pulse">Fetching records...</p>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
<!-- Empty/No Selection State -->
|
|
|
|
|
<div x-show="!loading && (!selectedTest || (selectedTest && controls.length === 0))"
|
|
|
|
|
class="bg-base-100 rounded-2xl border-2 border-dashed border-base-300 p-16 text-center">
|
|
|
|
|
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
|
|
|
<i class="fa-solid"
|
|
|
|
|
:class="!selectedTest ? 'fa-flask-vial text-2xl opacity-20' : 'fa-triangle-exclamation text-2xl text-warning'"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<template x-if="!selectedTest">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 class="font-bold text-lg">No Test Selected</h3>
|
|
|
|
|
<p class="text-base-content/60 max-w-xs mx-auto">Please select a laboratory test from the dropdown above
|
|
|
|
|
to begin data entry.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
<template x-if="selectedTest && controls.length === 0">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 class="font-bold text-lg">No Controls Found</h3>
|
|
|
|
|
<p class="text-base-content/60 max-w-xs mx-auto">This test doesn't have any controls configured for the
|
|
|
|
|
selected period.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Calendar Grid -->
|
2026-01-21 13:41:37 +07:00
|
|
|
<div x-show="!loading && selectedTest && controls.length > 0" class="space-y-6">
|
|
|
|
|
|
|
|
|
|
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden overflow-x-auto relative">
|
|
|
|
|
<table class="table-auto w-full border-collapse">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr class="bg-base-200 border-b border-base-300">
|
|
|
|
|
<th
|
|
|
|
|
class="sticky left-0 bg-base-200 z-20 w-16 p-3 text-center font-bold text-xs uppercase tracking-wider border-r border-base-300">
|
|
|
|
|
Day
|
|
|
|
|
</th>
|
|
|
|
|
<template x-for="(control, cIdx) in controls" :key="control.controlId">
|
|
|
|
|
<th class="p-3 text-center border-r border-base-300 min-w-32 bg-base-200/50">
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<span class="text-sm font-bold truncate" x-text="control.controlName"></span>
|
|
|
|
|
<span class="text-[10px] opacity-60 font-mono"
|
|
|
|
|
x-text="'LOT: ' + (control.lot || 'N/A')"></span>
|
|
|
|
|
<div class="flex items-center justify-center gap-1 mt-1">
|
|
|
|
|
<span class="badge badge-xs badge-neutral px-1.5"
|
|
|
|
|
x-text="formatParam(control)"></span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-20 16:47:11 +07:00
|
|
|
</th>
|
2026-01-21 13:41:37 +07:00
|
|
|
</template>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody @keydown="handleKeydown($event)">
|
|
|
|
|
<template x-for="day in daysInMonth" :key="day">
|
|
|
|
|
<tr class="hover:bg-primary/5 transition-colors group">
|
|
|
|
|
<td class="sticky left-0 z-10 p-2 text-center font-mono font-bold text-sm bg-base-100 border-r border-base-300 group-hover:bg-primary/10"
|
|
|
|
|
:class="{
|
|
|
|
|
'bg-base-200/50 text-base-content/40': isWeekend(day),
|
|
|
|
|
'text-primary bg-primary/5': isToday(day)
|
|
|
|
|
}">
|
|
|
|
|
<span x-text="day"></span>
|
|
|
|
|
<span class="block text-[8px] opacity-40 leading-none mt-0.5"
|
|
|
|
|
x-text="getDayName(day)"></span>
|
|
|
|
|
</td>
|
|
|
|
|
<template x-for="(control, cIdx) in controls" :key="control.controlId">
|
|
|
|
|
<td class="p-1 border-r border-base-200 last:border-r-0"
|
|
|
|
|
:class="{ 'bg-base-200/20': isWeekend(day) }">
|
|
|
|
|
<div class="relative group/cell px-0.5">
|
|
|
|
|
<input type="number" step="any"
|
|
|
|
|
class="input input-sm input-ghost no-spinner w-full text-center font-mono text-sm rounded focus:bg-base-100 focus:shadow-sm focus:ring-1 focus:ring-primary/20 placeholder:text-base-content/20 transition-all"
|
|
|
|
|
:class="getCellClass(control, day)" :data-day="day" :data-cidx="cIdx"
|
|
|
|
|
x-model="resultsData[control.controlId + '_' + day]"
|
|
|
|
|
@focus="onCellFocus(control, day, $event)" placeholder="-">
|
|
|
|
|
|
|
|
|
|
<!-- Floating Action Buttons for cell -->
|
|
|
|
|
<div
|
|
|
|
|
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover/cell:opacity-100 transition-opacity pointer-events-none">
|
|
|
|
|
<button @click="showComment(control.controlId, day)"
|
|
|
|
|
class="btn btn-circle btn-ghost btn-xs h-5 w-5 min-h-0 pointer-events-auto"
|
|
|
|
|
:class="hasComment(control.controlId, day) ? 'text-info' : 'text-base-content/30'">
|
|
|
|
|
<i class="fa-solid fa-comment text-[10px]"></i>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Highlight indicator for edited cells -->
|
|
|
|
|
<div x-show="isChanged(control.controlId, day)"
|
|
|
|
|
class="absolute left-1 top-1.5 w-1.5 h-1.5 rounded-full bg-info ring-1 ring-base-100 shadow-sm pointer-events-none">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
2026-01-20 16:47:11 +07:00
|
|
|
</template>
|
|
|
|
|
</tr>
|
2026-01-21 13:41:37 +07:00
|
|
|
</template>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
<!-- Legend & Footer Info -->
|
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-4 px-2">
|
|
|
|
|
<div class="flex flex-wrap gap-4 text-[10px] items-center">
|
|
|
|
|
<div class="flex items-center gap-1.5 opacity-70">
|
|
|
|
|
<span class="w-3 h-3 rounded bg-success/20 border border-success/30"></span>
|
|
|
|
|
<span class="font-medium">In Range (± 2SD)</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-1.5 opacity-70">
|
|
|
|
|
<span class="w-3 h-3 rounded bg-error/20 border border-error/30"></span>
|
|
|
|
|
<span class="font-medium">Out of Range</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-1.5 opacity-70">
|
|
|
|
|
<span class="w-3 h-3 rounded bg-base-200 border border-base-300"></span>
|
|
|
|
|
<span class="font-medium">Weekend</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-1.5 opacity-70">
|
|
|
|
|
<div class="w-1.5 h-1.5 rounded-full bg-info"></div>
|
|
|
|
|
<span class="font-medium">Unsaved Change</span>
|
|
|
|
|
</div>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
2026-01-21 13:41:37 +07:00
|
|
|
|
|
|
|
|
<div class="flex items-center gap-3 text-xs opacity-60 italic">
|
|
|
|
|
<i class="fa-solid fa-lightbulb"></i>
|
|
|
|
|
<span>Tip: Use arrow keys to navigate between cells.</span>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
2026-01-21 13:41:37 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Summary Float -->
|
|
|
|
|
<div x-show="hasChanges" x-transition:enter="transition ease-out duration-300"
|
|
|
|
|
x-transition:enter-start="translate-y-full opacity-0" x-transition:enter-end="translate-y-0 opacity-100"
|
|
|
|
|
class="fixed bottom-6 right-6 z-40">
|
|
|
|
|
<div
|
|
|
|
|
class="bg-info text-info-content px-4 py-3 rounded-2xl shadow-2xl flex items-center gap-4 border border-info-content/20">
|
2026-01-20 16:47:11 +07:00
|
|
|
<div class="flex items-center gap-2">
|
2026-01-21 13:41:37 +07:00
|
|
|
<i class="fa-solid fa-circle-exclamation animate-pulse"></i>
|
|
|
|
|
<span class="font-bold"><span x-text="changedCount"></span> unsaved entries</span>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
2026-01-21 13:41:37 +07:00
|
|
|
<div class="divider divider-horizontal mx-0 bg-info-content/20 w-[1px] h-4"></div>
|
|
|
|
|
<button @click="saveAll()"
|
|
|
|
|
class="btn btn-sm btn-ghost bg-white/10 hover:bg-white/20 border-none text-info-content">
|
|
|
|
|
Save Now (Ctrl+S)
|
|
|
|
|
</button>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
<!-- Comment Modal -->
|
|
|
|
|
<div x-show="commentModal.show" x-transition.opacity
|
|
|
|
|
class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[100]"
|
|
|
|
|
@click.self="commentModal.show = false">
|
|
|
|
|
<div class="bg-base-100 rounded-2xl shadow-2xl w-full max-w-md p-6 m-4 overflow-hidden border border-base-300"
|
|
|
|
|
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="scale-95 opacity-0"
|
|
|
|
|
x-transition:enter-end="scale-100 opacity-100">
|
|
|
|
|
<div class="flex items-center gap-3 mb-4">
|
|
|
|
|
<div class="w-10 h-10 rounded-xl bg-info/10 text-info flex items-center justify-center">
|
|
|
|
|
<i class="fa-solid fa-comment-dots text-lg"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h3 class="font-bold text-lg">Daily Comment</h3>
|
|
|
|
|
<p class="text-xs opacity-50 uppercase font-semibold tracking-wider"
|
|
|
|
|
x-text="commentModal.dateDisplay"></p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<textarea x-model="commentModal.text"
|
|
|
|
|
class="textarea textarea-bordered w-full min-h-32 text-sm focus:ring-1 focus:ring-primary"
|
|
|
|
|
placeholder="Enter remark or observation code..."></textarea>
|
|
|
|
|
|
|
|
|
|
<div class="flex justify-between items-center mt-6">
|
|
|
|
|
<button @click="clearComment()" class="btn btn-sm btn-ghost text-error hover:bg-error/5">
|
|
|
|
|
Clear Remark
|
|
|
|
|
</button>
|
|
|
|
|
<div class="flex gap-2">
|
|
|
|
|
<button @click="commentModal.show = false" class="btn btn-sm btn-ghost font-bold">Discard</button>
|
|
|
|
|
<button @click="saveComment()" class="btn btn-sm btn-primary px-6">Apply</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-20 16:47:11 +07:00
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
<?= $this->endSection(); ?>
|
|
|
|
|
|
|
|
|
|
<?= $this->section("script"); ?>
|
|
|
|
|
<script>
|
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
|
|
|
Alpine.data("monthlyEntry", () => ({
|
|
|
|
|
tests: [],
|
|
|
|
|
selectedTest: null,
|
|
|
|
|
controls: [],
|
|
|
|
|
loading: false,
|
|
|
|
|
saving: false,
|
|
|
|
|
resultsData: {},
|
2026-01-21 13:41:37 +07:00
|
|
|
originalResults: {}, // To track changes
|
2026-01-20 16:47:11 +07:00
|
|
|
commentsData: {},
|
2026-01-21 13:41:37 +07:00
|
|
|
originalComments: {},
|
|
|
|
|
month: '',
|
2026-02-03 16:55:13 +07:00
|
|
|
deptId: null,
|
|
|
|
|
departments: null,
|
2026-01-21 13:41:37 +07:00
|
|
|
commentModal: {
|
|
|
|
|
show: false,
|
|
|
|
|
controlId: null,
|
|
|
|
|
day: null,
|
|
|
|
|
dateDisplay: '',
|
|
|
|
|
text: ''
|
|
|
|
|
},
|
2026-01-20 16:47:11 +07:00
|
|
|
|
|
|
|
|
init() {
|
2026-01-21 13:41:37 +07:00
|
|
|
const now = new Date();
|
|
|
|
|
this.month = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
2026-02-03 16:55:13 +07:00
|
|
|
this.fetchDepartments();
|
2026-01-20 16:47:11 +07:00
|
|
|
this.fetchTests();
|
|
|
|
|
this.setupKeyboard();
|
|
|
|
|
},
|
|
|
|
|
|
2026-02-03 16:55:13 +07:00
|
|
|
async fetchDepartments() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${BASEURL}api/master/depts`, {
|
|
|
|
|
method: "GET",
|
|
|
|
|
headers: { "Content-Type": "application/json" }
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new Error("Failed to load departments");
|
|
|
|
|
const json = await response.json();
|
|
|
|
|
this.departments = json.data || [];
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to fetch departments:', e);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-20 16:47:11 +07:00
|
|
|
async fetchTests() {
|
|
|
|
|
try {
|
2026-02-03 16:55:13 +07:00
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
if (this.deptId) {
|
|
|
|
|
params.set('dept_id', this.deptId);
|
|
|
|
|
}
|
|
|
|
|
const url = `${BASEURL}api/master/tests${this.deptId ? '?' + params.toString() : ''}`;
|
|
|
|
|
const response = await fetch(url);
|
2026-01-20 16:47:11 +07:00
|
|
|
const json = await response.json();
|
|
|
|
|
this.tests = json.data || [];
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to fetch tests:', e);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-02-05 20:06:51 +07:00
|
|
|
|
2026-02-03 16:55:13 +07:00
|
|
|
setDeptId(id) {
|
|
|
|
|
this.deptId = id;
|
|
|
|
|
this.selectedTest = null;
|
|
|
|
|
this.controls = [];
|
|
|
|
|
this.fetchTests();
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
onMonthChange() {
|
|
|
|
|
if (this.selectedTest) {
|
|
|
|
|
this.fetchMonthlyData();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async fetchMonthlyData() {
|
2026-01-20 16:47:11 +07:00
|
|
|
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 || [];
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
// Build results and comments lookup
|
2026-01-20 16:47:11 +07:00
|
|
|
this.resultsData = {};
|
2026-01-21 13:41:37 +07:00
|
|
|
this.originalResults = {};
|
2026-01-20 16:47:11 +07:00
|
|
|
this.commentsData = {};
|
2026-01-21 13:41:37 +07:00
|
|
|
this.originalComments = {};
|
|
|
|
|
|
2026-01-20 16:47:11 +07:00
|
|
|
for (const control of this.controls) {
|
|
|
|
|
for (let day = 1; day <= 31; day++) {
|
|
|
|
|
const result = control.results[day];
|
2026-01-21 13:41:37 +07:00
|
|
|
const key = `${control.controlId}_${day}`;
|
|
|
|
|
if (result) {
|
|
|
|
|
if (result.resValue !== null) {
|
|
|
|
|
this.resultsData[key] = result.resValue;
|
|
|
|
|
this.originalResults[key] = result.resValue;
|
|
|
|
|
}
|
|
|
|
|
if (result.resComment) {
|
|
|
|
|
this.commentsData[key] = result.resComment;
|
|
|
|
|
this.originalComments[key] = result.resComment;
|
|
|
|
|
}
|
2026-01-20 16:47:11 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-01-21 13:41:37 +07:00
|
|
|
console.error('Failed to fetch monthly data:', e);
|
|
|
|
|
this.$dispatch('notify', { type: 'error', message: 'Network error while fetching data' });
|
2026-01-20 16:47:11 +07:00
|
|
|
} finally {
|
|
|
|
|
this.loading = false;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async saveAll() {
|
|
|
|
|
if (!this.canSave) return;
|
|
|
|
|
|
|
|
|
|
this.saving = true;
|
|
|
|
|
try {
|
2026-01-21 13:41:37 +07:00
|
|
|
const controlsToSave = [];
|
2026-01-20 16:47:11 +07:00
|
|
|
for (const control of this.controls) {
|
2026-01-21 13:41:37 +07:00
|
|
|
const results = {};
|
|
|
|
|
let hasControlData = false;
|
|
|
|
|
|
2026-01-20 16:47:11 +07:00
|
|
|
for (let day = 1; day <= 31; day++) {
|
|
|
|
|
const key = `${control.controlId}_${day}`;
|
|
|
|
|
const value = this.resultsData[key];
|
2026-01-21 13:41:37 +07:00
|
|
|
const comment = this.commentsData[key];
|
|
|
|
|
|
|
|
|
|
// Only save if it's different from original OR it's a new non-empty value
|
|
|
|
|
if (this.isChanged(control.controlId, day)) {
|
|
|
|
|
results[day] = {
|
|
|
|
|
value: value === '' ? null : value,
|
|
|
|
|
comment: comment || null
|
|
|
|
|
};
|
|
|
|
|
hasControlData = true;
|
2026-01-20 16:47:11 +07:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-21 13:41:37 +07:00
|
|
|
|
|
|
|
|
if (hasControlData) {
|
|
|
|
|
controlsToSave.push({
|
|
|
|
|
controlId: control.controlId,
|
|
|
|
|
results: results
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (controlsToSave.length === 0) {
|
|
|
|
|
this.saving = false;
|
|
|
|
|
return;
|
2026-01-20 16:47:11 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`${BASEURL}api/entry/monthly`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
testId: this.selectedTest,
|
|
|
|
|
month: this.month,
|
2026-01-21 13:41:37 +07:00
|
|
|
controls: controlsToSave
|
2026-01-20 16:47:11 +07:00
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const json = await response.json();
|
|
|
|
|
if (json.status === 'success') {
|
2026-01-21 13:41:37 +07:00
|
|
|
// Save comments using the returned result ID map
|
|
|
|
|
const resultIdMap = json.data.resultIdMap || {};
|
|
|
|
|
for (const key in this.commentsData) {
|
|
|
|
|
if (this.originalComments[key] !== this.commentsData[key]) {
|
|
|
|
|
const resultId = resultIdMap[key];
|
|
|
|
|
if (resultId) {
|
|
|
|
|
await this.saveComment(resultId, this.commentsData[key]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 16:47:11 +07:00
|
|
|
this.$dispatch('notify', { type: 'success', message: json.message });
|
2026-01-21 13:41:37 +07:00
|
|
|
await this.fetchMonthlyData();
|
2026-01-20 16:47:11 +07:00
|
|
|
} 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;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
async saveComment(resultId, commentText) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${BASEURL}api/entry/comment`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
resultId: resultId,
|
|
|
|
|
comment: commentText
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
return response.json();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to save comment:', e);
|
|
|
|
|
return { status: 'error', message: e.message };
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
isChanged(controlId, day) {
|
2026-01-20 16:47:11 +07:00
|
|
|
const key = `${controlId}_${day}`;
|
2026-01-21 13:41:37 +07:00
|
|
|
const currentVal = this.resultsData[key] === undefined ? '' : String(this.resultsData[key]);
|
|
|
|
|
const originalVal = this.originalResults[key] === undefined ? '' : String(this.originalResults[key]);
|
|
|
|
|
|
|
|
|
|
const currentCom = this.commentsData[key] || '';
|
|
|
|
|
const originalCom = this.originalComments[key] || '';
|
|
|
|
|
|
|
|
|
|
return currentVal !== originalVal || currentCom !== originalCom;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
resetData() {
|
|
|
|
|
if (confirm('Discard all unsaved changes for this month?')) {
|
|
|
|
|
this.resultsData = JSON.parse(JSON.stringify(this.originalResults));
|
|
|
|
|
this.commentsData = JSON.parse(JSON.stringify(this.originalComments));
|
2026-01-20 16:47:11 +07:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
// Comment modal methods
|
|
|
|
|
showComment(controlId, day) {
|
|
|
|
|
const [year, month] = this.month.split('-').map(Number);
|
|
|
|
|
const date = new Date(year, month - 1, day);
|
|
|
|
|
const dateStr = date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
|
|
|
|
|
|
|
|
|
this.commentModal = {
|
|
|
|
|
show: true,
|
|
|
|
|
controlId: controlId,
|
|
|
|
|
day: day,
|
|
|
|
|
dateDisplay: dateStr,
|
|
|
|
|
text: this.commentsData[`${controlId}_${day}`] || ''
|
|
|
|
|
};
|
2026-01-20 16:47:11 +07:00
|
|
|
},
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
saveComment() {
|
|
|
|
|
const key = `${this.commentModal.controlId}_${this.commentModal.day}`;
|
|
|
|
|
if (this.commentModal.text.trim() === '') {
|
|
|
|
|
delete this.commentsData[key];
|
|
|
|
|
} else {
|
|
|
|
|
this.commentsData[key] = this.commentModal.text.trim();
|
|
|
|
|
}
|
|
|
|
|
this.commentModal.show = false;
|
2026-01-20 16:47:11 +07:00
|
|
|
},
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
clearComment() {
|
|
|
|
|
this.commentModal.text = '';
|
|
|
|
|
this.saveComment();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
hasComment(controlId, day) {
|
|
|
|
|
return !!this.commentsData[`${controlId}_${day}`];
|
2026-01-20 16:47:11 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
if (num >= lower && num <= upper) return 'text-success bg-success/5 font-bold';
|
|
|
|
|
return 'text-error bg-error/5 font-bold';
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
formatParam(control) {
|
|
|
|
|
if (control.mean === null || control.sd === null) return 'No target';
|
|
|
|
|
return parseFloat(control.mean).toFixed(2) + ' ± ' + (2 * control.sd).toFixed(2);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getDayName(day) {
|
|
|
|
|
const [year, month] = this.month.split('-').map(Number);
|
|
|
|
|
const date = new Date(year, month - 1, day);
|
|
|
|
|
return date.toLocaleDateString('en-US', { weekday: 'short' });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
isToday(day) {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const [year, month] = this.month.split('-').map(Number);
|
|
|
|
|
return now.getDate() === day && (now.getMonth() + 1) === month && now.getFullYear() === year;
|
2026-01-20 16:47:11 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
get daysInMonth() {
|
2026-01-21 13:41:37 +07:00
|
|
|
if (!this.month) return [];
|
2026-01-20 16:47:11 +07:00
|
|
|
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 testUnit() {
|
|
|
|
|
const test = this.tests.find(t => t.id == this.selectedTest);
|
|
|
|
|
return test ? test.testUnit : '';
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
prevMonth() {
|
|
|
|
|
const [year, month] = this.month.split('-').map(Number);
|
|
|
|
|
const date = new Date(year, month - 2, 1);
|
2026-01-21 13:41:37 +07:00
|
|
|
this.month = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
this.onMonthChange();
|
2026-01-20 16:47:11 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
nextMonth() {
|
|
|
|
|
const [year, month] = this.month.split('-').map(Number);
|
|
|
|
|
const date = new Date(year, month, 1);
|
2026-01-21 13:41:37 +07:00
|
|
|
this.month = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
this.onMonthChange();
|
2026-01-20 16:47:11 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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() {
|
2026-01-21 13:41:37 +07:00
|
|
|
// Check resultsData vs originalResults
|
|
|
|
|
for (const key in this.resultsData) {
|
|
|
|
|
const day = parseInt(key.split('_')[1]);
|
|
|
|
|
const controlId = parseInt(key.split('_')[0]);
|
|
|
|
|
if (this.isChanged(controlId, day)) return true;
|
|
|
|
|
}
|
|
|
|
|
// Check if any in original are now empty
|
|
|
|
|
for (const key in this.originalResults) {
|
|
|
|
|
if (this.resultsData[key] === undefined || this.resultsData[key] === '') return true;
|
|
|
|
|
}
|
|
|
|
|
// Check comments
|
|
|
|
|
for (const key in this.commentsData) {
|
|
|
|
|
const day = parseInt(key.split('_')[1]);
|
|
|
|
|
const controlId = parseInt(key.split('_')[0]);
|
|
|
|
|
if (this.isChanged(controlId, day)) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
2026-01-20 16:47:11 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
get changedCount() {
|
2026-01-21 13:41:37 +07:00
|
|
|
let count = 0;
|
|
|
|
|
// Collect all unique keys from both current and original
|
|
|
|
|
const keys = new Set([...Object.keys(this.resultsData), ...Object.keys(this.originalResults), ...Object.keys(this.commentsData)]);
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
const parts = key.split('_');
|
|
|
|
|
if (this.isChanged(parts[0], parts[1])) count++;
|
|
|
|
|
}
|
|
|
|
|
return count;
|
2026-01-20 16:47:11 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
get canSave() {
|
|
|
|
|
return this.selectedTest && this.hasChanges && !this.saving;
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
onCellFocus(control, day, event) {
|
|
|
|
|
event.target.select();
|
2026-01-20 16:47:11 +07:00
|
|
|
},
|
|
|
|
|
|
2026-01-21 13:41:37 +07:00
|
|
|
handleKeydown(e) {
|
|
|
|
|
const target = e.target;
|
|
|
|
|
if (target.tagName !== 'INPUT') return;
|
|
|
|
|
|
|
|
|
|
const day = parseInt(target.dataset.day);
|
|
|
|
|
const cIdx = parseInt(target.dataset.cidx);
|
|
|
|
|
let nextDay = day;
|
|
|
|
|
let nextCIdx = cIdx;
|
|
|
|
|
|
|
|
|
|
switch(e.key) {
|
|
|
|
|
case 'ArrowUp':
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (day > 1) nextDay = day - 1;
|
|
|
|
|
break;
|
|
|
|
|
case 'ArrowDown':
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (day < this.daysInMonth.length) nextDay = day + 1;
|
|
|
|
|
break;
|
|
|
|
|
case 'ArrowLeft':
|
|
|
|
|
if (target.selectionStart === 0) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (cIdx > 0) nextCIdx = cIdx - 1;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case 'ArrowRight':
|
|
|
|
|
if (target.selectionEnd === target.value.length) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (cIdx < this.controls.length - 1) nextCIdx = cIdx + 1;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case 'Enter':
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (day < this.daysInMonth.length) nextDay = day + 1;
|
|
|
|
|
else if (cIdx < this.controls.length - 1) {
|
|
|
|
|
nextDay = 1;
|
|
|
|
|
nextCIdx = cIdx + 1;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (nextDay !== day || nextCIdx !== cIdx) {
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
const nextInput = document.querySelector(`input[data-day="${nextDay}"][data-cidx="${nextCIdx}"]`);
|
|
|
|
|
if (nextInput) nextInput.focus();
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-20 16:47:11 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setupKeyboard() {
|
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (this.canSave) {
|
|
|
|
|
this.saveAll();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
</script>
|
2026-01-21 13:41:37 +07:00
|
|
|
<?= $this->endSection(); ?>
|