From dd7a0585113f6a1fbdbd01d279cdb49fb9b2d165 Mon Sep 17 00:00:00 2001 From: mahdahar <89adham@gmail.com> Date: Tue, 20 Jan 2026 16:47:11 +0700 Subject: [PATCH] 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 --- app/Config/Routes.php | 2 + app/Controllers/Api/EntryApiController.php | 425 +++++++++++++++++++++ app/Controllers/PageController.php | 4 + app/Database/Seeds/LongExpiryQcSeeder.php | 189 +++++++++ app/Models/Qc/ControlTestsModel.php | 90 +++++ app/Models/Qc/ResultCommentsModel.php | 47 +++ app/Models/Qc/ResultsModel.php | 92 +++++ app/Views/dashboard.php | 78 ++-- app/Views/entry/daily.php | 330 +++++++++++++++- app/Views/entry/index.php | 10 +- app/Views/entry/monthly.php | 414 ++++++++++++++++++++ app/Views/layout/main_layout.php | 14 + 12 files changed, 1653 insertions(+), 42 deletions(-) create mode 100644 app/Controllers/Api/EntryApiController.php create mode 100644 app/Database/Seeds/LongExpiryQcSeeder.php create mode 100644 app/Views/entry/monthly.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index dd39897..8fac304 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -15,6 +15,7 @@ $routes->get('/test', 'PageController::test'); $routes->get('/control', 'PageController::control'); $routes->get('/entry', 'PageController::entry'); $routes->get('/entry/daily', 'PageController::entryDaily'); +$routes->get('/entry/monthly', 'PageController::entryMonthly'); $routes->get('/report', 'PageController::report'); $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/tests', 'Api\EntryApiController::getTests'); + $routes->get('entry/daily', 'Api\EntryApiController::getDailyData'); $routes->get('entry/monthly', 'Api\EntryApiController::getMonthlyData'); $routes->post('entry/daily', 'Api\EntryApiController::saveDaily'); $routes->post('entry/monthly', 'Api\EntryApiController::saveMonthly'); diff --git a/app/Controllers/Api/EntryApiController.php b/app/Controllers/Api/EntryApiController.php new file mode 100644 index 0000000..6648dce --- /dev/null +++ b/app/Controllers/Api/EntryApiController.php @@ -0,0 +1,425 @@ +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()); + } + } +} diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php index 7991861..5891b3a 100644 --- a/app/Controllers/PageController.php +++ b/app/Controllers/PageController.php @@ -31,6 +31,10 @@ class PageController extends BaseController { return view('entry/daily'); } + public function entryMonthly() { + return view('entry/monthly'); + } + public function report() { return view('report/index'); } diff --git a/app/Database/Seeds/LongExpiryQcSeeder.php b/app/Database/Seeds/LongExpiryQcSeeder.php new file mode 100644 index 0000000..67e16d3 --- /dev/null +++ b/app/Database/Seeds/LongExpiryQcSeeder.php @@ -0,0 +1,189 @@ + '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); + } +} diff --git a/app/Models/Qc/ControlTestsModel.php b/app/Models/Qc/ControlTestsModel.php index e15abdc..c9f884b 100644 --- a/app/Models/Qc/ControlTestsModel.php +++ b/app/Models/Qc/ControlTestsModel.php @@ -27,4 +27,94 @@ class ControlTestsModel extends BaseModel { } 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; + } } diff --git a/app/Models/Qc/ResultCommentsModel.php b/app/Models/Qc/ResultCommentsModel.php index d602de0..50fae4f 100644 --- a/app/Models/Qc/ResultCommentsModel.php +++ b/app/Models/Qc/ResultCommentsModel.php @@ -28,4 +28,51 @@ class ResultCommentsModel extends BaseModel { } 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); + } + } } diff --git a/app/Models/Qc/ResultsModel.php b/app/Models/Qc/ResultsModel.php index 50943aa..64ce0f1 100644 --- a/app/Models/Qc/ResultsModel.php +++ b/app/Models/Qc/ResultsModel.php @@ -28,4 +28,96 @@ class ResultsModel extends BaseModel { } 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; + } } diff --git a/app/Views/dashboard.php b/app/Views/dashboard.php index aeb1074..5c3a890 100644 --- a/app/Views/dashboard.php +++ b/app/Views/dashboard.php @@ -8,32 +8,44 @@ -
- +
+
- - QC Entry + + Daily Entry
- + +
+ + Monthly Entry +
+
+
Departments
- +
Tests
- +
Controls
- + -
+

Recent Results

-
-

Daily entry form coming soon...

+ +
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+
+ + +
+ +
+ + +
+ +

No tests configured for this control

+

Add tests in the Control-Tests setup

+
+ + +
+ +

Select a control to view tests

+
+ + +
+
+ + + + + + + + + + + + +
TestMean ± 2SDResultComment
+
+
+ + +
+ + test(s) with changes pending save
endSection(); ?> + +section("script"); ?> + +endSection(); ?> diff --git a/app/Views/entry/index.php b/app/Views/entry/index.php index 3e71483..81ace3d 100644 --- a/app/Views/entry/index.php +++ b/app/Views/entry/index.php @@ -23,17 +23,17 @@
- +
diff --git a/app/Views/entry/monthly.php b/app/Views/entry/monthly.php new file mode 100644 index 0000000..a392a7a --- /dev/null +++ b/app/Views/entry/monthly.php @@ -0,0 +1,414 @@ +extend("layout/main_layout"); ?> + +section("content"); ?> +
+
+
+

Monthly Entry

+

Record monthly QC results and comments

+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+
+ + +
+ +
+ + +
+ +

No controls configured for this test

+

Add controls in the Control-Tests setup

+
+ + +
+ +

Select a test to view controls and calendar

+
+ + +
+ +
+
+
+
+

+

+
+
+ +
+
+
+
+ + + + + + + + + + + +
+ Control + Comment
+
+
+ + +
+
+ + In Range +
+
+ + Out of Range +
+
+ + Weekend +
+
+
+ + +
+ + cell(s) with changes pending save +
+
+endSection(); ?> + +section("script"); ?> + +endSection(); ?> diff --git a/app/Views/layout/main_layout.php b/app/Views/layout/main_layout.php index 2e3a36b..75be69a 100644 --- a/app/Views/layout/main_layout.php +++ b/app/Views/layout/main_layout.php @@ -126,6 +126,20 @@ QC Entry +
  • + + + Daily Entry + +
  • +
  • + + + Monthly Entry + +