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
This commit is contained in:
mahdahar 2026-01-20 16:47:11 +07:00
parent 14baa6b758
commit dd7a058511
12 changed files with 1653 additions and 42 deletions

View File

@ -15,6 +15,7 @@ $routes->get('/test', 'PageController::test');
$routes->get('/control', 'PageController::control'); $routes->get('/control', 'PageController::control');
$routes->get('/entry', 'PageController::entry'); $routes->get('/entry', 'PageController::entry');
$routes->get('/entry/daily', 'PageController::entryDaily'); $routes->get('/entry/daily', 'PageController::entryDaily');
$routes->get('/entry/monthly', 'PageController::entryMonthly');
$routes->get('/report', 'PageController::report'); $routes->get('/report', 'PageController::report');
$routes->get('/report/view', 'PageController::reportView'); $routes->get('/report/view', 'PageController::reportView');
@ -41,6 +42,7 @@ $routes->group('api', function ($routes) {
$routes->get('entry/controls', 'Api\EntryApiController::getControls'); $routes->get('entry/controls', 'Api\EntryApiController::getControls');
$routes->get('entry/tests', 'Api\EntryApiController::getTests'); $routes->get('entry/tests', 'Api\EntryApiController::getTests');
$routes->get('entry/daily', 'Api\EntryApiController::getDailyData');
$routes->get('entry/monthly', 'Api\EntryApiController::getMonthlyData'); $routes->get('entry/monthly', 'Api\EntryApiController::getMonthlyData');
$routes->post('entry/daily', 'Api\EntryApiController::saveDaily'); $routes->post('entry/daily', 'Api\EntryApiController::saveDaily');
$routes->post('entry/monthly', 'Api\EntryApiController::saveMonthly'); $routes->post('entry/monthly', 'Api\EntryApiController::saveMonthly');

View File

@ -0,0 +1,425 @@
<?php
namespace App\Controllers\Api;
use App\Controllers\BaseController;
use CodeIgniter\API\ResponseTrait;
use App\Models\Master\MasterControlsModel;
use App\Models\Master\MasterTestsModel;
use App\Models\Qc\ResultsModel;
use App\Models\Qc\ControlTestsModel;
use App\Models\Qc\ResultCommentsModel;
class EntryApiController extends BaseController
{
use ResponseTrait;
protected $controlModel;
protected $testModel;
protected $resultModel;
protected $controlTestModel;
protected $commentModel;
public function __construct()
{
$this->controlModel = new MasterControlsModel();
$this->testModel = new MasterTestsModel();
$this->resultModel = new ResultsModel();
$this->controlTestModel = new ControlTestsModel();
$this->commentModel = new ResultCommentsModel();
}
/**
* GET /api/entry/controls
* Get controls by dept (optional dept param)
* Optionally filter by date: only non-expired controls
*/
public function getControls()
{
try {
$deptId = $this->request->getGet('dept_id');
$date = $this->request->getGet('date');
if ($deptId) {
$controls = $this->controlModel->where('dept_id', $deptId)->where('deleted_at', null)->findAll();
} else {
$controls = $this->controlModel->where('deleted_at', null)->findAll();
}
// Filter expired controls if date provided
if ($date) {
$controls = array_filter($controls, function ($c) use ($date) {
return $c['expDate'] === null || $c['expDate'] >= $date;
});
}
// Convert to camelCase (BaseModel already returns camelCase)
$data = array_map(function ($c) {
return [
'id' => $c['controlId'],
'controlId' => $c['controlId'],
'controlName' => $c['controlName'],
'lot' => $c['lot'],
'producer' => $c['producer'],
'expDate' => $c['expDate']
];
}, $controls);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $data
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* GET /api/entry/tests
* Get tests for a control (by control_id)
*/
public function getTests()
{
try {
$controlId = $this->request->getGet('control_id');
if (!$controlId) {
return $this->failValidationErrors(['control_id' => 'Required']);
}
$tests = $this->controlTestModel->getByControl((int) $controlId);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $tests
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* GET /api/entry/daily
* Get existing results for date+control
*/
public function getDailyData()
{
try {
$date = $this->request->getGet('date');
$controlId = $this->request->getGet('control_id');
if (!$date || !$controlId) {
return $this->failValidationErrors(['date' => 'Required', 'control_id' => 'Required']);
}
// Get tests for this control
$tests = $this->controlTestModel->getByControl((int) $controlId);
// Get existing results for this date
$existingResults = $this->resultModel->getByDateAndControl($date, (int) $controlId);
// Map existing results by test_id
$resultsByTest = [];
foreach ($existingResults as $r) {
$resultsByTest[$r['testId']] = [
'resultId' => $r['id'],
'resValue' => $r['resValue'],
'resComment' => $r['resComment']
];
}
// Merge tests with existing values
$data = [];
foreach ($tests as $t) {
$existing = $resultsByTest[$t['testId']] ?? null;
$data[] = [
'controlTestId' => $t['id'],
'controlId' => $t['controlId'],
'testId' => $t['testId'],
'testName' => $t['testName'],
'testUnit' => $t['testUnit'],
'mean' => $t['mean'],
'sd' => $t['sd'],
'existingResult' => $existing
];
}
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $data
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* POST /api/entry/daily
* Save/update daily results (batch)
*/
public function saveDaily()
{
try {
$input = $this->request->getJSON(true);
if (!isset($input['date']) || !isset($input['results']) || !is_array($input['results'])) {
return $this->failValidationErrors(['Invalid input']);
}
$date = $input['date'];
$results = $input['results'];
$savedIds = [];
// Start transaction
$this->resultModel->db->transBegin();
foreach ($results as $r) {
if (!isset($r['controlId']) || !isset($r['testId']) || !isset($r['value'])) {
continue;
}
$data = [
'control_id' => $r['controlId'],
'test_id' => $r['testId'],
'res_date' => $date,
'res_value' => $r['value'] !== '' ? (float) $r['value'] : null,
'res_comment' => $r['comment'] ?? null
];
if ($data['res_value'] === null) {
continue; // Skip empty values
}
$savedIds[] = $this->resultModel->upsertResult($data);
}
// Commit transaction
$this->resultModel->db->transCommit();
return $this->respond([
'status' => 'success',
'message' => 'Saved ' . count($savedIds) . ' results',
'data' => ['savedIds' => $savedIds]
], 200);
} catch (\Exception $e) {
// Rollback transaction on error
$this->resultModel->db->transRollback();
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* GET /api/entry/monthly
* Get monthly data by test
*/
public function getMonthlyData()
{
try {
$testId = $this->request->getGet('test_id');
$month = $this->request->getGet('month'); // YYYY-MM
if (!$testId || !$month) {
return $this->failValidationErrors(['test_id' => 'Required', 'month' => 'Required']);
}
// Get test details
$test = $this->testModel->find($testId);
if (!$test) {
return $this->failNotFound('Test not found');
}
// Get controls for this test with QC parameters
$controls = $this->controlTestModel->getByTest((int) $testId);
// Get existing results for this month
$results = $this->resultModel->getByMonth((int) $testId, $month);
// Get comments for this month
$comments = $this->commentModel->getByTestMonth((int) $testId, $month);
// Map results by control_id and day
$resultsByControl = [];
foreach ($results as $r) {
$day = (int) date('j', strtotime($r['resDate']));
$resultsByControl[$r['controlId']][$day] = [
'resultId' => $r['id'],
'resValue' => $r['resValue'],
'resComment' => $r['resComment']
];
}
// Map comments by control_id (BaseModel returns camelCase)
$commentsByControl = [];
foreach ($comments as $c) {
$commentsByControl[$c['controlId']] = [
'commentId' => $c['resultCommentId'],
'comText' => $c['comText']
];
}
// Build controls with results array[31]
$controlsWithData = [];
foreach ($controls as $c) {
$resultsByDay = $resultsByControl[$c['controlId']] ?? [];
$resultsArray = array_fill(1, 31, null);
foreach ($resultsByDay as $day => $val) {
$resultsArray[$day] = $val;
}
$comment = $commentsByControl[$c['controlId']] ?? null;
$controlsWithData[] = [
'controlTestId' => $c['id'],
'controlId' => $c['controlId'],
'controlName' => $c['controlName'],
'lot' => $c['lot'],
'producer' => $c['producer'],
'mean' => $c['mean'],
'sd' => $c['sd'],
'results' => $resultsArray,
'comment' => $comment
];
}
$data = [
'test' => [
'testId' => $test['testId'],
'testName' => $test['testName'],
'testUnit' => $test['testUnit']
],
'month' => $month,
'controls' => $controlsWithData
];
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $data
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* POST /api/entry/monthly
* Save monthly batch (results + comments)
*/
public function saveMonthly()
{
try {
$input = $this->request->getJSON(true);
if (!isset($input['testId']) || !isset($input['month']) || !isset($input['controls'])) {
return $this->failValidationErrors(['Invalid input']);
}
$testId = $input['testId'];
$month = $input['month'];
$controls = $input['controls'];
// Validate month has valid days
$daysInMonth = (int) date('t', strtotime($month . '-01'));
$savedCount = 0;
$commentCount = 0;
// Start transaction
$this->resultModel->db->transBegin();
foreach ($controls as $c) {
$controlId = $c['controlId'];
$results = $c['results'] ?? [];
$commentText = $c['comment'] ?? null;
// Save results
foreach ($results as $day => $value) {
if ($value === null || $value === '') {
continue;
}
// Validate day exists in month
if ($day < 1 || $day > $daysInMonth) {
continue;
}
$date = $month . '-' . str_pad($day, 2, '0', STR_PAD_LEFT);
$data = [
'control_id' => $controlId,
'test_id' => $testId,
'res_date' => $date,
'res_value' => (float) $value
];
$this->resultModel->upsertResult($data);
$savedCount++;
}
// Save comment
if ($commentText !== null) {
$commentData = [
'control_id' => $controlId,
'test_id' => $testId,
'comment_month' => $month,
'com_text' => trim($commentText)
];
$this->commentModel->upsertComment($commentData);
$commentCount++;
}
}
// Commit transaction
$this->resultModel->db->transCommit();
return $this->respond([
'status' => 'success',
'message' => "Saved {$savedCount} results and {$commentCount} comments",
'data' => ['savedCount' => $savedCount, 'commentCount' => $commentCount]
], 200);
} catch (\Exception $e) {
// Rollback transaction on error
$this->resultModel->db->transRollback();
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* POST /api/entry/comment
* Save monthly comment (single)
*/
public function saveComment()
{
try {
$input = $this->request->getJSON(true);
$required = ['controlId', 'testId', 'month', 'comment'];
foreach ($required as $field) {
if (!isset($input[$field])) {
return $this->failValidationErrors([$field => 'Required']);
}
}
$commentData = [
'control_id' => $input['controlId'],
'test_id' => $input['testId'],
'comment_month' => $input['month'],
'com_text' => trim($input['comment'])
];
$id = $this->commentModel->upsertComment($commentData);
return $this->respond([
'status' => 'success',
'message' => 'Comment saved',
'data' => ['id' => $id]
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}

View File

@ -31,6 +31,10 @@ class PageController extends BaseController {
return view('entry/daily'); return view('entry/daily');
} }
public function entryMonthly() {
return view('entry/monthly');
}
public function report() { public function report() {
return view('report/index'); return view('report/index');
} }

View File

@ -0,0 +1,189 @@
<?php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
class LongExpiryQcSeeder extends Seeder
{
public function run()
{
// 1. Insert Departments (4 entries)
$depts = [
['dept_name' => 'Chemistry'],
['dept_name' => 'Hematology'],
['dept_name' => 'Immunology'],
['dept_name' => 'Urinalysis'],
];
$this->db->table('master_depts')->insertBatch($depts);
$deptIds = $this->db->table('master_depts')->select('dept_id')->get()->getResultArray();
$deptIdMap = array_column($deptIds, 'dept_id');
// 2. Insert Controls with long expiry dates (2027-12-31)
$controls = [
['dept_id' => $deptIdMap[0], 'control_name' => 'QC Normal Chemistry', 'lot' => 'QC2026001', 'producer' => 'BioRad', 'exp_date' => '2027-12-31'],
['dept_id' => $deptIdMap[0], 'control_name' => 'QC High Chemistry', 'lot' => 'QC2026002', 'producer' => 'BioRad', 'exp_date' => '2027-12-31'],
['dept_id' => $deptIdMap[1], 'control_name' => 'QC Normal Hema', 'lot' => 'QC2026003', 'producer' => 'Streck', 'exp_date' => '2027-11-30'],
['dept_id' => $deptIdMap[1], 'control_name' => 'QC Low Hema', 'lot' => 'QC2026004', 'producer' => 'Streck', 'exp_date' => '2027-11-30'],
['dept_id' => $deptIdMap[2], 'control_name' => 'QC Normal Immuno', 'lot' => 'QC2026005', 'producer' => 'Roche', 'exp_date' => '2027-10-31'],
['dept_id' => $deptIdMap[3], 'control_name' => 'QC Normal Urine', 'lot' => 'QC2026006', 'producer' => 'Siemens', 'exp_date' => '2027-09-30'],
];
$this->db->table('master_controls')->insertBatch($controls);
$controlIds = $this->db->table('master_controls')->select('control_id')->get()->getResultArray();
$controlIdMap = array_column($controlIds, 'control_id');
// 3. Insert Tests (10 entries)
$tests = [
['dept_id' => $deptIdMap[0], 'test_name' => 'Glucose', 'test_unit' => 'mg/dL', 'test_method' => 'GOD-PAP', 'cva' => 5, 'ba' => 3, 'tea' => 10],
['dept_id' => $deptIdMap[0], 'test_name' => 'Creatinine', 'test_unit' => 'mg/dL', 'test_method' => 'Jaffe', 'cva' => 4, 'ba' => 2, 'tea' => 8],
['dept_id' => $deptIdMap[0], 'test_name' => 'Urea Nitrogen', 'test_unit' => 'mg/dL', 'test_method' => 'UREASE', 'cva' => 5, 'ba' => 3, 'tea' => 12],
['dept_id' => $deptIdMap[0], 'test_name' => 'Cholesterol', 'test_unit' => 'mg/dL', 'test_method' => 'CHOD-PAP', 'cva' => 6, 'ba' => 4, 'tea' => 15],
['dept_id' => $deptIdMap[1], 'test_name' => 'WBC', 'test_unit' => 'x10^3/uL', 'test_method' => 'Impedance', 'cva' => 8, 'ba' => 5, 'tea' => 20],
['dept_id' => $deptIdMap[1], 'test_name' => 'RBC', 'test_unit' => 'x10^6/uL', 'test_method' => 'Impedance', 'cva' => 3, 'ba' => 2, 'tea' => 8],
['dept_id' => $deptIdMap[1], 'test_name' => 'Hemoglobin', 'test_unit' => 'g/dL', 'test_method' => 'Cyanmethemoglobin', 'cva' => 2, 'ba' => 1, 'tea' => 5],
['dept_id' => $deptIdMap[2], 'test_name' => 'TSH', 'test_unit' => 'mIU/L', 'test_method' => 'ECLIA', 'cva' => 10, 'ba' => 6, 'tea' => 25],
['dept_id' => $deptIdMap[2], 'test_name' => 'Free T4', 'test_unit' => 'ng/dL', 'test_method' => 'ECLIA', 'cva' => 8, 'ba' => 5, 'tea' => 20],
['dept_id' => $deptIdMap[3], 'test_name' => 'Urine Protein', 'test_unit' => 'mg/dL', 'test_method' => 'Dipstick', 'cva' => 10, 'ba' => 8, 'tea' => 30],
];
$this->db->table('master_tests')->insertBatch($tests);
$testIds = $this->db->table('master_tests')->select('test_id')->get()->getResultArray();
$testIdMap = array_column($testIds, 'test_id');
// 4. Insert Control-Tests (15 entries - 3 per control for first 5 controls)
$controlTests = [
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[0], 'mean' => 95, 'sd' => 5],
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[1], 'mean' => 1.0, 'sd' => 0.05],
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[2], 'mean' => 15, 'sd' => 1.2],
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[0], 'mean' => 180, 'sd' => 12],
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[1], 'mean' => 2.5, 'sd' => 0.15],
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[3], 'mean' => 200, 'sd' => 15],
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[4], 'mean' => 7.5, 'sd' => 0.6],
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[5], 'mean' => 4.8, 'sd' => 0.2],
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[6], 'mean' => 14.5, 'sd' => 0.5],
['control_id' => $controlIdMap[3], 'test_id' => $testIdMap[4], 'mean' => 3.5, 'sd' => 0.3],
['control_id' => $controlIdMap[3], 'test_id' => $testIdMap[5], 'mean' => 2.5, 'sd' => 0.15],
['control_id' => $controlIdMap[4], 'test_id' => $testIdMap[7], 'mean' => 2.5, 'sd' => 0.3],
['control_id' => $controlIdMap[4], 'test_id' => $testIdMap[8], 'mean' => 1.2, 'sd' => 0.1],
['control_id' => $controlIdMap[5], 'test_id' => $testIdMap[9], 'mean' => 10, 'sd' => 1.5],
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[3], 'mean' => 150, 'sd' => 10],
];
$this->db->table('control_tests')->insertBatch($controlTests);
$ctRows = $this->db->table('control_tests')->select('control_test_id, control_id, test_id, mean, sd')->get()->getResultArray();
// 5. Insert Results (90 entries - 6 per control-test, daily data spanning ~3 months)
$results = [];
$faker = \Faker\Factory::create();
// Start date: 3 months ago, generate daily entries
$startDate = date('2025-10-01');
$daysToGenerate = 90; // ~3 months of daily data
foreach ($ctRows as $ct) {
// Generate 6 results per control-test, spread across the date range
for ($i = 0; $i < 6; $i++) {
// Distribute results across the 90-day period
$dayOffset = floor(($i * $daysToGenerate) / 6) + $faker->numberBetween(0, 5);
$resDate = date('Y-m-d', strtotime($startDate . ' +' . $dayOffset . ' days'));
// Generate value within +/- 2.5 SD
$value = $ct['mean'] + ($faker->randomFloat(2, -2.5, 2.5) * $ct['sd']);
$results[] = [
'control_id' => $ct['control_id'],
'test_id' => $ct['test_id'],
'res_date' => $resDate,
'res_value' => round($value, 2),
'res_comment' => null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
}
}
$this->db->table('results')->insertBatch($results);
// 6. Insert Result Comments (60 entries - monthly comments for all control-test combos)
$resultComments = [];
$months = ['2025-10', '2025-11', '2025-12', '2026-01'];
$commentTemplates = [
'2025-10' => [
['control_id' => 0, 'test_id' => 0, 'text' => 'QC performance stable, all parameters within range for October'],
['control_id' => 0, 'test_id' => 1, 'text' => 'Creatinine controls stable, no issues observed'],
['control_id' => 0, 'test_id' => 2, 'text' => 'BUN QC within acceptable limits'],
['control_id' => 1, 'test_id' => 0, 'text' => 'High glucose QC showed slight elevation, monitoring continued'],
['control_id' => 1, 'test_id' => 3, 'text' => 'Cholesterol lot QC2026002 performs within specifications'],
['control_id' => 2, 'test_id' => 4, 'text' => 'WBC counts consistent throughout the month'],
['control_id' => 2, 'test_id' => 5, 'text' => 'RBC QC stable, no drift detected'],
['control_id' => 2, 'test_id' => 6, 'text' => 'Hemoglobin controls within expected range'],
['control_id' => 3, 'test_id' => 4, 'text' => 'Low WBC QC verified, precision acceptable'],
['control_id' => 3, 'test_id' => 5, 'text' => 'Low RBC controls passed QC checks'],
['control_id' => 4, 'test_id' => 7, 'text' => 'TSH assay calibration verified on 10/15'],
['control_id' => 4, 'test_id' => 8, 'text' => 'Free T4 controls stable, no maintenance required'],
['control_id' => 5, 'test_id' => 9, 'text' => 'Urine protein dipstick QC performing well'],
['control_id' => 0, 'test_id' => 3, 'text' => 'Normal cholesterol QC within target range'],
],
'2025-11' => [
['control_id' => 0, 'test_id' => 0, 'text' => 'Glucose QC showed minor drift, recalibration performed 11/10'],
['control_id' => 0, 'test_id' => 1, 'text' => 'November creatinine QC results acceptable'],
['control_id' => 0, 'test_id' => 2, 'text' => 'BUN controls stable after reagent lot change'],
['control_id' => 1, 'test_id' => 0, 'text' => 'High glucose QC consistent after recalibration'],
['control_id' => 1, 'test_id' => 3, 'text' => 'Cholesterol QC performance improved after maintenance'],
['control_id' => 2, 'test_id' => 4, 'text' => 'WBC QC acceptable, precision within specifications'],
['control_id' => 2, 'test_id' => 5, 'text' => 'RBC controls verified, no issues'],
['control_id' => 2, 'test_id' => 6, 'text' => 'Hemoglobin QC stable, Levey-Jenkins chart within limits'],
['control_id' => 3, 'test_id' => 4, 'text' => 'Low WBC QC passed all QC checks for November'],
['control_id' => 3, 'test_id' => 5, 'text' => 'Low RBC controls stable throughout month'],
['control_id' => 4, 'test_id' => 7, 'text' => 'TSH controls within range, no action required'],
['control_id' => 4, 'test_id' => 8, 'text' => 'Free T4 assay verification complete'],
['control_id' => 5, 'test_id' => 9, 'text' => 'Urine protein QC lot QC2026006 performing well'],
['control_id' => 0, 'test_id' => 3, 'text' => 'Normal cholesterol lot QC2026001 verified'],
],
'2025-12' => [
['control_id' => 0, 'test_id' => 0, 'text' => 'December glucose QC stable, no issues'],
['control_id' => 0, 'test_id' => 1, 'text' => 'Creatinine QC performance acceptable for year-end'],
['control_id' => 0, 'test_id' => 2, 'text' => 'BUN controls within range, holiday period monitoring'],
['control_id' => 1, 'test_id' => 0, 'text' => 'High glucose QC stable after lot stabilization'],
['control_id' => 1, 'test_id' => 3, 'text' => 'Cholesterol QC trending well, within 2SD'],
['control_id' => 2, 'test_id' => 4, 'text' => 'WBC QC stable, year-end verification complete'],
['control_id' => 2, 'test_id' => 5, 'text' => 'RBC controls verified, all parameters acceptable'],
['control_id' => 2, 'test_id' => 6, 'text' => 'Hemoglobin QC within expected range'],
['control_id' => 3, 'test_id' => 4, 'text' => 'Low WBC QC passed December checks'],
['control_id' => 3, 'test_id' => 5, 'text' => 'Low RBC controls stable'],
['control_id' => 4, 'test_id' => 7, 'text' => 'TSH controls within specifications'],
['control_id' => 4, 'test_id' => 8, 'text' => 'Free T4 QC acceptable for December'],
['control_id' => 5, 'test_id' => 9, 'text' => 'Urine protein QC no issues reported'],
['control_id' => 0, 'test_id' => 3, 'text' => 'Cholesterol QC lot QC2026001 verified'],
],
'2026-01' => [
['control_id' => 0, 'test_id' => 0, 'text' => 'January glucose QC started well, new year verification'],
['control_id' => 0, 'test_id' => 1, 'text' => 'Creatinine QC performance consistent'],
['control_id' => 0, 'test_id' => 2, 'text' => 'BUN controls within expected parameters'],
['control_id' => 1, 'test_id' => 0, 'text' => 'High glucose QC stable start to new year'],
['control_id' => 1, 'test_id' => 3, 'text' => 'Cholesterol QC lot QC2026002 verified'],
['control_id' => 2, 'test_id' => 4, 'text' => 'WBC QC started new year within specifications'],
['control_id' => 2, 'test_id' => 5, 'text' => 'RBC controls performing as expected'],
['control_id' => 2, 'test_id' => 6, 'text' => 'Hemoglobin QC acceptable'],
['control_id' => 3, 'test_id' => 4, 'text' => 'Low WBC QC passed January checks'],
['control_id' => 3, 'test_id' => 5, 'text' => 'Low RBC controls verified'],
['control_id' => 4, 'test_id' => 7, 'text' => 'TSH controls within range'],
['control_id' => 4, 'test_id' => 8, 'text' => 'Free T4 QC stable'],
['control_id' => 5, 'test_id' => 9, 'text' => 'Urine protein QC performing well'],
['control_id' => 0, 'test_id' => 3, 'text' => 'Cholesterol QC lot QC2026001 verified'],
],
];
foreach ($months as $month) {
if (isset($commentTemplates[$month])) {
foreach ($commentTemplates[$month] as $comment) {
$resultComments[] = [
'control_id' => $controlIdMap[$comment['control_id']],
'test_id' => $testIdMap[$comment['test_id']],
'comment_month' => $month,
'com_text' => $comment['text'],
];
}
}
}
$this->db->table('result_comments')->insertBatch($resultComments);
}
}

View File

@ -27,4 +27,94 @@ class ControlTestsModel extends BaseModel {
} }
return $this->findAll(); return $this->findAll();
} }
/**
* Get control-test with control and test details
*/
public function getWithDetails(int $controlTestId): ?array {
$builder = $this->db->table('control_tests ct');
$builder->select('
ct.control_test_id as id,
ct.control_id as controlId,
ct.test_id as testId,
ct.mean,
ct.sd,
c.control_name as controlName,
c.lot,
t.test_name as testName,
t.test_unit as testUnit
');
$builder->join('master_controls c', 'c.control_id = ct.control_id');
$builder->join('master_tests t', 't.test_id = ct.test_id');
$builder->where('ct.control_test_id', $controlTestId);
$builder->where('ct.deleted_at', null);
return $builder->get()->getRowArray() ?: null;
}
/**
* Get tests for a control with QC parameters
*/
public function getByControl(int $controlId): array {
$builder = $this->db->table('control_tests ct');
$builder->select('
ct.control_test_id as id,
ct.control_id as controlId,
ct.test_id as testId,
ct.mean,
ct.sd,
t.test_name as testName,
t.test_unit as testUnit
');
$builder->join('master_tests t', 't.test_id = ct.test_id');
$builder->where('ct.control_id', $controlId);
$builder->where('ct.deleted_at', null);
$builder->where('t.deleted_at', null);
$builder->orderBy('t.test_name', 'ASC');
return $builder->get()->getResultArray();
}
/**
* Get controls for a test with QC parameters
*/
public function getByTest(int $testId): array {
$builder = $this->db->table('control_tests ct');
$builder->select('
ct.control_test_id as id,
ct.control_id as controlId,
ct.test_id as testId,
ct.mean,
ct.sd,
c.control_name as controlName,
c.lot,
c.producer
');
$builder->join('master_controls c', 'c.control_id = ct.control_id');
$builder->where('ct.test_id', $testId);
$builder->where('ct.deleted_at', null);
$builder->where('c.deleted_at', null);
$builder->orderBy('c.control_name', 'ASC');
return $builder->get()->getResultArray();
}
/**
* Get by control and test
*/
public function getByControlAndTest(int $controlId, int $testId): ?array {
$builder = $this->db->table('control_tests ct');
$builder->select('
ct.control_test_id as id,
ct.control_id as controlId,
ct.test_id as testId,
ct.mean,
ct.sd
');
$builder->where('ct.control_id', $controlId);
$builder->where('ct.test_id', $testId);
$builder->where('ct.deleted_at', null);
return $builder->get()->getRowArray() ?: null;
}
} }

View File

@ -28,4 +28,51 @@ class ResultCommentsModel extends BaseModel {
} }
return $this->findAll(); return $this->findAll();
} }
/**
* Get comments by control, test and month
*/
public function getByControlTestMonth(int $controlId, int $testId, string $month): ?array {
return $this->where('control_id', $controlId)
->where('test_id', $testId)
->where('comment_month', $month)
->where('deleted_at', null)
->first();
}
/**
* Get all comments for a test and month
*/
public function getByTestMonth(int $testId, string $month): array {
return $this->where('test_id', $testId)
->where('comment_month', $month)
->where('deleted_at', null)
->findAll();
}
/**
* Upsert comment (insert or update based on control/test/month)
*/
public function upsertComment(array $data): int {
$existing = $this->where('control_id', $data['control_id'])
->where('test_id', $data['test_id'])
->where('comment_month', $data['comment_month'])
->where('deleted_at', null)
->first();
if ($existing) {
if (empty($data['com_text'])) {
// If text is empty, soft delete
$this->update($existing['result_comment_id'], ['deleted_at' => date('Y-m-d H:i:s')]);
return $existing['result_comment_id'];
}
$this->update($existing['result_comment_id'], $data);
return $existing['result_comment_id'];
} else {
if (empty($data['com_text'])) {
return 0; // Don't insert empty comments
}
return $this->insert($data, true);
}
}
} }

View File

@ -28,4 +28,96 @@ class ResultsModel extends BaseModel {
} }
return $this->findAll(); return $this->findAll();
} }
/**
* Get results by date and control
*/
public function getByDateAndControl(string $date, int $controlId): array {
$builder = $this->db->table('results r');
$builder->select('
r.result_id as id,
r.control_id as controlId,
r.test_id as testId,
r.res_date as resDate,
r.res_value as resValue,
r.res_comment as resComment
');
$builder->where('r.res_date', $date);
$builder->where('r.control_id', $controlId);
$builder->where('r.deleted_at', null);
return $builder->get()->getResultArray();
}
/**
* Get results by month for a specific test (for monthly entry)
*/
public function getByMonth(int $testId, string $month): array {
$builder = $this->db->table('results r');
$builder->select('
r.result_id as id,
r.control_id as controlId,
r.test_id as testId,
r.res_date as resDate,
r.res_value as resValue,
r.res_comment as resComment
');
$builder->where('r.test_id', $testId);
$builder->where('r.res_date >=', $month . '-01');
$builder->where('r.res_date <=', $month . '-31');
$builder->where('r.deleted_at', null);
$builder->orderBy('r.res_date', 'ASC');
return $builder->get()->getResultArray();
}
/**
* Get results by control and month (for monthly entry calendar grid)
*/
public function getByControlAndMonth(int $controlId, int $testId, string $month): array {
$builder = $this->db->table('results r');
$builder->select('
r.result_id as id,
r.res_date as resDate,
r.res_value as resValue
');
$builder->where('r.control_id', $controlId);
$builder->where('r.test_id', $testId);
$builder->where('r.res_date >=', $month . '-01');
$builder->where('r.res_date <=', $month . '-31');
$builder->where('r.deleted_at', null);
$builder->orderBy('r.res_date', 'ASC');
return $builder->get()->getResultArray();
}
/**
* Upsert results (insert or update based on date/control/test)
*/
public function upsertResult(array $data): int {
// Check if record exists
$existing = $this->where('control_id', $data['control_id'])
->where('test_id', $data['test_id'])
->where('res_date', $data['res_date'])
->where('deleted_at', null)
->first();
if ($existing) {
$this->update($existing['resultId'], $data);
return $existing['resultId'];
} else {
return $this->insert($data, true);
}
}
/**
* Batch upsert results
*/
public function batchUpsertResults(array $results): array {
$ids = [];
foreach ($results as $result) {
$ids[] = $this->upsertResult($result);
}
return $ids;
}
} }

View File

@ -8,32 +8,44 @@
</div> </div>
<!-- Quick Action Cards --> <!-- Quick Action Cards -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6"> <div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
<a href="<?= base_url('entry') ?>" class="card bg-primary text-primary-content hover:bg-primary/90 cursor-pointer no-underline hover:scale-[1.02] transition"> <a href="<?= base_url('entry/daily') ?>"
class="card bg-primary text-primary-content hover:bg-primary/90 cursor-pointer no-underline hover:scale-[1.02] transition">
<div class="card-body items-center text-center py-4"> <div class="card-body items-center text-center py-4">
<i class="fa-solid fa-pen-to-square text-2xl mb-2"></i> <i class="fa-solid fa-calendar-day text-2xl mb-2"></i>
<span class="font-medium">QC Entry</span> <span class="font-medium">Daily Entry</span>
</div> </div>
</a> </a>
<a href="<?= base_url('master/dept') ?>" class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition"> <a href="<?= base_url('entry/monthly') ?>"
class="card bg-secondary text-secondary-content hover:bg-secondary/90 cursor-pointer no-underline hover:scale-[1.02] transition">
<div class="card-body items-center text-center py-4">
<i class="fa-solid fa-calendar-days text-2xl mb-2"></i>
<span class="font-medium">Monthly Entry</span>
</div>
</a>
<a href="<?= base_url('master/dept') ?>"
class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
<div class="card-body items-center text-center py-4"> <div class="card-body items-center text-center py-4">
<i class="fa-solid fa-building text-2xl mb-2"></i> <i class="fa-solid fa-building text-2xl mb-2"></i>
<span class="font-medium">Departments</span> <span class="font-medium">Departments</span>
</div> </div>
</a> </a>
<a href="<?= base_url('master/test') ?>" class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition"> <a href="<?= base_url('master/test') ?>"
class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
<div class="card-body items-center text-center py-4"> <div class="card-body items-center text-center py-4">
<i class="fa-solid fa-flask-vial text-2xl mb-2"></i> <i class="fa-solid fa-flask-vial text-2xl mb-2"></i>
<span class="font-medium">Tests</span> <span class="font-medium">Tests</span>
</div> </div>
</a> </a>
<a href="<?= base_url('master/control') ?>" class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition"> <a href="<?= base_url('master/control') ?>"
class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
<div class="card-body items-center text-center py-4"> <div class="card-body items-center text-center py-4">
<i class="fa-solid fa-vial text-2xl mb-2"></i> <i class="fa-solid fa-vial text-2xl mb-2"></i>
<span class="font-medium">Controls</span> <span class="font-medium">Controls</span>
</div> </div>
</a> </a>
<a href="<?= base_url('report') ?>" class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition"> <a href="<?= base_url('report') ?>"
class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
<div class="card-body items-center text-center py-4"> <div class="card-body items-center text-center py-4">
<i class="fa-solid fa-chart-bar text-2xl mb-2"></i> <i class="fa-solid fa-chart-bar text-2xl mb-2"></i>
<span class="font-medium">Reports</span> <span class="font-medium">Reports</span>
@ -41,8 +53,7 @@
</a> </a>
</div> </div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6" <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6" x-data="dashboardRecentResults()">
x-data="dashboardRecentResults()">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-base-content">Recent Results</h2> <h2 class="text-lg font-semibold text-base-content">Recent Results</h2>
<button @click="fetchResults()" class="btn btn-ghost btn-sm"> <button @click="fetchResults()" class="btn btn-ghost btn-sm">
@ -83,9 +94,8 @@
<td class="text-right font-mono" x-text="row.resValue ?? '-'"></td> <td class="text-right font-mono" x-text="row.resValue ?? '-'"></td>
<td class="font-mono text-sm" x-text="row.rangeDisplay"></td> <td class="font-mono text-sm" x-text="row.rangeDisplay"></td>
<td class="text-center"> <td class="text-center">
<span class="badge" <span class="badge" :class="row.inRange ? 'badge-success' : 'badge-error'"
:class="row.inRange ? 'badge-success' : 'badge-error'" x-text="row.inRange ? 'Pass' : 'Fail'">
x-text="row.inRange ? 'Pass' : 'Fail'">
</span> </span>
</td> </td>
</tr> </tr>
@ -99,28 +109,28 @@
<?= $this->section("script"); ?> <?= $this->section("script"); ?>
<script> <script>
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
Alpine.data("dashboardRecentResults", () => ({ Alpine.data("dashboardRecentResults", () => ({
results: [], results: [],
loading: true, loading: true,
init() { init() {
this.fetchResults(); this.fetchResults();
}, },
async fetchResults() { async fetchResults() {
this.loading = true; this.loading = true;
try { try {
const response = await fetch(`${BASEURL}api/dashboard/recent?limit=10`); const response = await fetch(`${BASEURL}api/dashboard/recent?limit=10`);
const json = await response.json(); const json = await response.json();
this.results = json.data || []; this.results = json.data || [];
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
this.loading = false; this.loading = false;
}
} }
} }));
})); });
});
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -1,16 +1,340 @@
<?= $this->extend("layout/main_layout"); ?> <?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?> <?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto"> <main class="flex-1 p-6 overflow-auto"
x-data="dailyEntry()">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<div> <div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Daily Entry</h1> <h1 class="text-2xl font-bold text-base-content tracking-tight">Daily Entry</h1>
<p class="text-sm mt-1 opacity-70">Record daily QC test results</p> <p class="text-sm mt-1 opacity-70">Record daily QC test results</p>
</div> </div>
<button @click="saveResults()"
:disabled="saving || !canSave"
class="btn btn-primary"
:class="{ 'loading': saving }">
<i class="fa-solid fa-save mr-2"></i>
Save Results
</button>
</div> </div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6"> <!-- Filters -->
<p class="text-base-content/60 text-center py-8">Daily entry form coming soon...</p> <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">Date</span>
</label>
<input type="date"
x-model="date"
@change="fetchControls()"
:max="today"
class="input input-bordered w-40">
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Quick Date</span>
</label>
<div class="flex gap-2">
<button @click="setToday()" class="btn btn-sm btn-outline">Today</button>
<button @click="setYesterday()" class="btn btn-sm btn-outline">Yesterday</button>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Control</span>
</label>
<select x-model="selectedControl"
@change="fetchTests()"
class="select select-bordered w-64">
<option value="">Select Control</option>
<template x-for="control in controls" :key="control.id">
<option :value="control.id" x-text="control.controlName + ' (Lot: ' + control.lot + ')'"></option>
</template>
</select>
</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 && selectedControl && tests.length === 0"
class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-12 text-center">
<i class="fa-solid fa-flask text-4xl text-base-content/20 mb-3"></i>
<p class="text-base-content/60">No tests configured for this control</p>
<p class="text-sm text-base-content/40 mt-1">Add tests in the Control-Tests setup</p>
</div>
<!-- No Control Selected -->
<div x-show="!loading && !selectedControl"
class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-12 text-center">
<i class="fa-solid fa-clipboard-list text-4xl text-base-content/20 mb-3"></i>
<p class="text-base-content/60">Select a control to view tests</p>
</div>
<!-- Tests Grid -->
<div x-show="!loading && selectedControl && tests.length > 0"
class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="table">
<thead class="bg-base-200">
<tr>
<th>Test</th>
<th class="text-center">Mean ± 2SD</th>
<th class="w-32">Result</th>
<th class="w-56">Comment</th>
</tr>
</thead>
<tbody>
<template x-for="test in tests" :key="test.testId">
<tr class="hover">
<td>
<div class="font-medium" x-text="test.testName"></div>
<div class="text-xs text-base-content/50" x-text="test.testUnit || ''"></div>
</td>
<td class="text-center">
<div class="font-mono text-sm">
<span x-text="test.mean !== null ? test.mean : '-'"></span>
<span class="text-base-content/40">±</span>
<span x-text="test.sd !== null ? (2 * test.sd).toFixed(2) : '-'"></span>
</div>
</td>
<td>
<input type="number"
step="0.01"
:placeholder="'...'"
class="input input-bordered input-sm w-full font-mono"
:class="getInputClass(test, $el.value)"
@input.debounce.300ms="updateResult(test.testId, $el.value)"
:value="test.existingResult ? test.existingResult.resValue : ''">
</td>
<td>
<textarea
:placeholder="'Optional comment...'"
rows="1"
class="textarea textarea-bordered textarea-xs w-full"
@input.debounce.300ms="updateComment(test.testId, $el.value)"
:value="getComment(test.testId)"></textarea>
</td>
</tr>
</template>
</tbody>
</table>
</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> test(s) with changes pending save
</div> </div>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section("script"); ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("dailyEntry", () => ({
date: new Date().toISOString().split('T')[0],
controls: [],
selectedControl: null,
tests: [],
loading: false,
saving: false,
resultsData: {},
commentsData: {},
init() {
this.fetchControls();
this.setupKeyboard();
},
get today() {
return new Date().toISOString().split('T')[0];
},
async fetchControls() {
try {
const params = new URLSearchParams({ date: this.date });
const response = await fetch(`${BASEURL}api/entry/controls?${params}`);
const json = await response.json();
this.controls = json.data || [];
// Reset selected control if it's no longer in the list
if (this.selectedControl && !this.controls.find(c => c.id === this.selectedControl)) {
this.selectedControl = null;
this.tests = [];
}
} catch (e) {
console.error('Failed to fetch controls:', e);
}
},
async fetchTests() {
if (!this.selectedControl) {
this.tests = [];
this.resultsData = {};
this.commentsData = {};
return;
}
this.loading = true;
try {
const params = new URLSearchParams({
date: this.date,
control_id: this.selectedControl
});
const response = await fetch(`${BASEURL}api/entry/daily?${params}`);
const json = await response.json();
this.tests = json.data || [];
// Initialize resultsData and commentsData with existing values
this.resultsData = {};
this.commentsData = {};
for (const test of this.tests) {
if (test.existingResult && test.existingResult.resValue !== null) {
this.resultsData[test.testId] = test.existingResult.resValue;
}
if (test.existingResult && test.existingResult.resComment) {
this.commentsData[test.testId] = test.existingResult.resComment;
}
}
} catch (e) {
console.error('Failed to fetch tests:', e);
this.$dispatch('notify', { type: 'error', message: 'Failed to load tests' });
} finally {
this.loading = false;
}
},
async saveResults() {
if (!this.canSave) return;
this.saving = true;
try {
const results = [];
for (const test of this.tests) {
const value = this.resultsData[test.testId];
if (value !== undefined && value !== '') {
results.push({
controlId: test.controlId,
testId: test.testId,
value: parseFloat(value),
comment: this.commentsData[test.testId] || null
});
}
}
const response = await fetch(`${BASEURL}api/entry/daily`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: this.date,
results: results
})
});
const json = await response.json();
if (json.status === 'success') {
// Show success notification
this.$dispatch('notify', { type: 'success', message: json.message });
// Refresh data
this.resultsData = {};
this.commentsData = {};
await this.fetchTests();
} 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(testId, value) {
if (value === '') {
delete this.resultsData[testId];
} else {
this.resultsData[testId] = value;
}
},
updateComment(testId, value) {
if (value.trim() === '') {
delete this.commentsData[testId];
} else {
this.commentsData[testId] = value;
}
},
getComment(testId) {
return this.commentsData[testId] || '';
},
getExpectedRange(mean, sd) {
if (mean === null || sd === null) return 'N/A';
const min = (mean - 2 * sd).toFixed(2);
const max = (mean + 2 * sd).toFixed(2);
return `${min} - ${max}`;
},
getValueClass(test, value) {
if (test.mean === null || test.sd === null) return '';
const num = parseFloat(value);
const lower = test.mean - 2 * test.sd;
const upper = test.mean + 2 * test.sd;
if (num >= lower && num <= upper) return 'text-success';
return 'text-error';
},
getInputClass(test, value) {
if (value === '' || test.mean === null || test.sd === null) return '';
const num = parseFloat(value);
const lower = test.mean - 2 * test.sd;
const upper = test.mean + 2 * test.sd;
if (num >= lower && num <= upper) return 'input-success';
return 'input-error';
},
get hasChanges() {
return Object.keys(this.resultsData).length > 0 ||
Object.keys(this.commentsData).length > 0;
},
get changedCount() {
return Object.keys(this.resultsData).length + Object.keys(this.commentsData).length;
},
get canSave() {
return this.selectedControl && (Object.keys(this.resultsData).length > 0 || Object.keys(this.commentsData).length > 0) && !this.saving;
},
setToday() {
this.date = new Date().toISOString().split('T')[0];
},
setYesterday() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
this.date = yesterday.toISOString().split('T')[0];
},
setupKeyboard() {
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (this.canSave) {
this.saveResults();
}
}
});
}
}));
});
</script>
<?= $this->endSection(); ?>

View File

@ -23,17 +23,17 @@
</div> </div>
</a> </a>
<div class="block p-6 rounded-xl border-2 border-dashed border-base-300 hover:border-green-500 hover:bg-green-50 transition-all group cursor-not-allowed opacity-60"> <a href="<?= base_url('/entry/monthly') ?>" class="block p-6 rounded-xl border-2 border-dashed border-base-300 hover:border-green-500 hover:bg-green-50 transition-all group">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-xl bg-green-100 flex items-center justify-center group-hover:bg-green-200 transition-colors"> <div class="w-14 h-14 rounded-xl bg-green-100 flex items-center justify-center group-hover:bg-green-200 transition-colors">
<i class="fa-solid fa-chart-column text-2xl text-green-600"></i> <i class="fa-solid fa-calendar-range text-2xl text-green-600"></i>
</div> </div>
<div> <div>
<h3 class="font-semibold text-base-content">Monthly Review</h3> <h3 class="font-semibold text-base-content">Monthly Entry</h3>
<p class="text-sm text-base-content/60 mt-1">Monthly QC analysis & comments</p> <p class="text-sm text-base-content/60 mt-1">Monthly QC results & comments</p>
</div> </div>
</div> </div>
</div> </a>
</div> </div>
</div> </div>
</main> </main>

414
app/Views/entry/monthly.php Normal file
View File

@ -0,0 +1,414 @@
<?= $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(); ?>

View File

@ -126,6 +126,20 @@
QC Entry QC Entry
</a> </a>
</li> </li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= str_contains(uri_string(), 'entry/daily') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full"
href="<?= base_url('/entry/daily') ?>">
<i class="fa-solid fa-calendar-day w-5"></i>
Daily Entry
</a>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= str_contains(uri_string(), 'entry/monthly') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full"
href="<?= base_url('/entry/monthly') ?>">
<i class="fa-solid fa-calendar w-5"></i>
Monthly Entry
</a>
</li>
<li class="mb-1 min-h-0"> <li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= str_contains(uri_string(), 'report') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full" <a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= str_contains(uri_string(), 'report') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full"
href="<?= base_url('/report') ?>"> href="<?= base_url('/report') ?>">