diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 92b55f8..2c288ef 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1,26 +1,31 @@ get('/', 'PageController::dashboard'); +$routes->get('login', 'Auth\AuthController::login'); +$routes->post('login', 'Auth\AuthController::processLogin'); +$routes->get('logout', 'Auth\AuthController::logout'); -$routes->get('/master/dept', 'PageController::masterDept'); -$routes->get('/master/test', 'PageController::masterTest'); -$routes->get('/master/control', 'PageController::masterControl'); -$routes->get('/master/control-tests', 'PageController::controlTests'); -$routes->get('/dept', 'PageController::dept'); -$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/merged', 'PageController::reportMerged'); +$routes->get('/', 'PageController::dashboard', ['filter' => AuthFilter::class]); -$routes->group('api', function ($routes) { +$routes->get('/master/dept', 'PageController::masterDept', ['filter' => AuthFilter::class]); +$routes->get('/master/test', 'PageController::masterTest', ['filter' => AuthFilter::class]); +$routes->get('/master/control', 'PageController::masterControl', ['filter' => AuthFilter::class]); +$routes->get('/master/control-tests', 'PageController::controlTests', ['filter' => AuthFilter::class]); +$routes->get('/dept', 'PageController::dept', ['filter' => AuthFilter::class]); +$routes->get('/test', 'PageController::test', ['filter' => AuthFilter::class]); +$routes->get('/control', 'PageController::control', ['filter' => AuthFilter::class]); +$routes->get('/entry', 'PageController::entry', ['filter' => AuthFilter::class]); +$routes->get('/entry/daily', 'PageController::entryDaily', ['filter' => AuthFilter::class]); +$routes->get('/entry/monthly', 'PageController::entryMonthly', ['filter' => AuthFilter::class]); +$routes->get('/report', 'PageController::report', ['filter' => AuthFilter::class]); +$routes->get('/report/merged', 'PageController::reportMerged', ['filter' => AuthFilter::class]); + +$routes->group('api', ['filter' => AuthFilter::class], function ($routes) { $routes->get('dashboard/recent', 'Api\DashboardApiController::getRecent'); $routes->get('dept', 'Api\DeptApiController::index'); $routes->get('dept/(:num)', 'Api\DeptApiController::show/$1'); @@ -50,7 +55,7 @@ $routes->group('api', function ($routes) { $routes->post('entry/comment', 'Api\EntryApiController::saveComment'); }); - $routes->group('api/master', function ($routes) { + $routes->group('api/master', ['filter' => AuthFilter::class], function ($routes) { $routes->get('depts', 'Master\MasterDeptsController::index'); $routes->get('depts/(:num)', 'Master\MasterDeptsController::show/$1'); $routes->post('depts', 'Master\MasterDeptsController::create'); @@ -70,7 +75,7 @@ $routes->group('api', function ($routes) { $routes->delete('tests/(:num)', 'Master\MasterTestsController::delete/$1'); }); -$routes->group('api/qc', function ($routes) { +$routes->group('api/qc', ['filter' => AuthFilter::class], function ($routes) { $routes->get('control-tests', 'Qc\ControlTestsController::index'); $routes->get('control-tests/(:num)', 'Qc\ControlTestsController::show/$1'); $routes->post('control-tests', 'Qc\ControlTestsController::create'); @@ -83,9 +88,9 @@ $routes->group('api/qc', function ($routes) { $routes->patch('results/(:num)', 'Qc\ResultsController::update/$1'); $routes->delete('results/(:num)', 'Qc\ResultsController::delete/$1'); - $routes->get('result-comments', 'Qc\ResultCommentsController::index'); - $routes->get('result-comments/(:num)', 'Qc\ResultCommentsController::show/$1'); - $routes->post('result-comments', 'Qc\ResultCommentsController::create'); - $routes->patch('result-comments/(:num)', 'Qc\ResultCommentsController::update/$1'); - $routes->delete('result-comments/(:num)', 'Qc\ResultCommentsController::delete/$1'); + $routes->get('test-comments', 'Qc\TestCommentsController::index'); + $routes->get('test-comments/(:num)', 'Qc\TestCommentsController::show/$1'); + $routes->post('test-comments', 'Qc\TestCommentsController::create'); + $routes->patch('test-comments/(:num)', 'Qc\TestCommentsController::update/$1'); + $routes->delete('test-comments/(:num)', 'Qc\TestCommentsController::delete/$1'); }); diff --git a/app/Controllers/Api/EntryApiController.php b/app/Controllers/Api/EntryApiController.php index eda5b2b..d00e418 100644 --- a/app/Controllers/Api/EntryApiController.php +++ b/app/Controllers/Api/EntryApiController.php @@ -8,7 +8,7 @@ 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; +use App\Models\Qc\TestCommentsModel; class EntryApiController extends BaseController { @@ -26,7 +26,7 @@ class EntryApiController extends BaseController $this->testModel = new MasterTestsModel(); $this->resultModel = new ResultsModel(); $this->controlTestModel = new ControlTestsModel(); - $this->commentModel = new ResultCommentsModel(); + $this->commentModel = new TestCommentsModel(); } /** @@ -134,10 +134,29 @@ class EntryApiController extends BaseController ]; } - // Merge tests with existing values + // Get all test IDs for this control + $testIds = array_column($tests, 'testId'); + + // Fetch comments separately from test_comments table (comments are per test + date) + $commentsData = []; + if (!empty($testIds)) { + $comments = $this->commentModel->getByTestAndDateRange($testIds, $date, $date); + foreach ($comments as $c) { + $commentsData[$c['testId']] = $c['commentText']; + } + } + + // Merge tests with existing values and comments $data = []; foreach ($tests as $t) { $existing = $resultsByTest[$t['testId']] ?? null; + + // Get comment from either the test_comments table or existing result + $comment = $commentsData[$t['testId']] ?? null; + if ($existing && isset($existing['resComment']) && $existing['resComment']) { + $comment = $existing['resComment']; // Existing result's comment takes precedence + } + $data[] = [ 'controlTestId' => $t['id'], 'controlId' => $t['controlId'], @@ -146,7 +165,8 @@ class EntryApiController extends BaseController 'testUnit' => $t['testUnit'], 'mean' => $t['mean'], 'sd' => $t['sd'], - 'existingResult' => $existing + 'existingResult' => $existing, + 'comment' => $comment ]; } @@ -244,8 +264,8 @@ class EntryApiController extends BaseController // Get existing results for this month $results = $this->resultModel->getByMonth((int) $testId, $month); - // Get comments for this test (via results) - $comments = $this->commentModel->getByTest((int) $testId); + // Get comments for this test (test_id + date based) + $comments = $this->commentModel->getByTestAndMonth((int) $testId, $month); // Map results by control_id and day $resultsByControl = []; @@ -253,15 +273,16 @@ class EntryApiController extends BaseController $day = (int) date('j', strtotime($r['resDate'])); $resultsByControl[$r['controlId']][$day] = [ 'resultId' => $r['id'], - 'resValue' => $r['resValue'] + 'resValue' => $r['resValue'], + 'resDate' => $r['resDate'] ]; } - // Map comments by result_id - $commentsByResultId = []; + // Map comments by date (comments are now per test + date) + $commentsByDate = []; foreach ($comments as $c) { - $commentsByResultId[$c['resultId']] = [ - 'commentId' => $c['resultCommentId'], + $commentsByDate[$c['commentDate']] = [ + 'commentId' => $c['testCommentId'], 'commentText' => $c['commentText'] ]; } @@ -274,9 +295,10 @@ class EntryApiController extends BaseController foreach ($resultsByDay as $day => $val) { $resultWithComment = $val; - // Add comment if exists for this result - if (isset($commentsByResultId[$val['resultId']])) { - $resultWithComment['resComment'] = $commentsByResultId[$val['resultId']]['commentText']; + // Add comment if exists for this date (comments are per test + date) + $resultDate = date('Y-m-d', strtotime($val['resDate'])); + if (isset($commentsByDate[$resultDate])) { + $resultWithComment['resComment'] = $commentsByDate[$resultDate]['commentText']; } else { $resultWithComment['resComment'] = null; } @@ -406,7 +428,7 @@ class EntryApiController extends BaseController try { $input = $this->request->getJSON(true); - $required = ['resultId', 'comment']; + $required = ['testId', 'date', 'comment']; foreach ($required as $field) { if (!isset($input[$field])) { return $this->failValidationErrors([$field => 'Required']); @@ -414,7 +436,8 @@ class EntryApiController extends BaseController } $commentData = [ - 'result_id' => $input['resultId'], + 'test_id' => $input['testId'], + 'comment_date' => $input['date'], 'comment_text' => trim($input['comment']) ]; diff --git a/app/Controllers/Api/ReportApiController.php b/app/Controllers/Api/ReportApiController.php index 19c3ae1..596e7f3 100644 --- a/app/Controllers/Api/ReportApiController.php +++ b/app/Controllers/Api/ReportApiController.php @@ -8,7 +8,7 @@ use App\Models\Master\MasterControlsModel; use App\Models\Master\MasterTestsModel; use App\Models\Qc\ControlTestsModel; use App\Models\Qc\ResultsModel; -use App\Models\Qc\ResultCommentsModel; +use App\Models\Qc\TestCommentsModel; class ReportApiController extends BaseController { @@ -26,7 +26,7 @@ class ReportApiController extends BaseController $this->dictTestModel = new MasterTestsModel(); $this->controlTestModel = new ControlTestsModel(); $this->resultModel = new ResultsModel(); - $this->commentModel = new ResultCommentsModel(); + $this->commentModel = new TestCommentsModel(); } public function getReport() @@ -47,7 +47,13 @@ class ReportApiController extends BaseController $controlTest = $this->controlTestModel->getByControlAndTest($control['controlId'], $test); $results = $this->resultModel->getByControlAndMonth($control['controlId'], $test, $dates); - $comment = $this->commentModel->getByControlTestMonth($control['controlId'], $test, $dates); + // Get all comments for this test in the month (comments are now per test + date) + $allComments = $this->commentModel->getByTestAndMonth($test, $dates); + // Index comments by date for easy lookup + $commentsByDate = []; + foreach ($allComments as $c) { + $commentsByDate[$c['commentDate']] = $c['commentText']; + } $testInfo = $this->dictTestModel->find($test); $outOfRangeCount = 0; @@ -97,7 +103,7 @@ class ReportApiController extends BaseController 'results' => $processedResults, 'values' => $values, 'test' => $testInfo, - 'comment' => $comment, + 'comments' => $commentsByDate, 'outOfRange' => $outOfRangeCount ]; } diff --git a/app/Controllers/Auth/AuthController.php b/app/Controllers/Auth/AuthController.php new file mode 100644 index 0000000..94b1de8 --- /dev/null +++ b/app/Controllers/Auth/AuthController.php @@ -0,0 +1,99 @@ +model = new UsersModel(); + } + + public function login() + { + if ($this->session->get('isLoggedIn')) { + return redirect()->to('/'); + } + + return view('auth/login'); + } + + public function processLogin() + { + $input = $this->request->getJSON(true); + + if (!$input) { + return $this->respond([ + 'status' => 'error', + 'message' => 'Invalid request' + ], 400); + } + + $username = $input['username'] ?? ''; + $password = $input['password'] ?? ''; + $remember = $input['remember'] ?? false; + + if (empty($username) || empty($password)) { + return $this->respond([ + 'status' => 'error', + 'message' => 'Username and password are required' + ], 400); + } + + $user = $this->model->findByUsername($username); + + if (!$user) { + return $this->respond([ + 'status' => 'error', + 'message' => 'Invalid username or password' + ], 401); + } + + if (!password_verify($password, $user['password'])) { + return $this->respond([ + 'status' => 'error', + 'message' => 'Invalid username or password' + ], 401); + } + + $this->session->set([ + 'isLoggedIn' => true, + 'userId' => $user['userId'], + 'username' => $user['username'] + ]); + + if ($remember) { + $token = bin2hex(random_bytes(32)); + $this->model->setRememberToken($user['userId'], $token); + set_cookie('remember_token', $token, 60 * 60 * 24 * 30); + } + + return $this->respond([ + 'status' => 'success', + 'message' => 'Login successful', + 'redirect' => base_url('/') + ], 200); + } + + public function logout() + { + $userId = $this->session->get('userId'); + + if ($userId) { + $this->model->setRememberToken($userId, null); + } + + delete_cookie('remember_token'); + $this->session->destroy(); + + return redirect()->to('/login'); + } +} diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index 3194091..ad8ff16 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -13,11 +13,11 @@ abstract class BaseController extends Controller use ResponseTrait; protected $session; + protected $helpers = ['form', 'url', 'cookie', 'json', 'stringcase']; public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger) { parent::initController($request, $response, $logger); $this->session = \Config\Services::session(); - $this->helpers = ['form', 'url', 'json', 'stringcase']; } } diff --git a/app/Controllers/Qc/ResultCommentsController.php b/app/Controllers/Qc/TestCommentsController.php similarity index 95% rename from app/Controllers/Qc/ResultCommentsController.php rename to app/Controllers/Qc/TestCommentsController.php index 1896213..f5ac0fa 100644 --- a/app/Controllers/Qc/ResultCommentsController.php +++ b/app/Controllers/Qc/TestCommentsController.php @@ -3,16 +3,16 @@ namespace App\Controllers\Qc; use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; -use App\Models\Qc\ResultCommentsModel; +use App\Models\Qc\TestCommentsModel; -class ResultCommentsController extends BaseController { +class TestCommentsController extends BaseController { use ResponseTrait; protected $model; protected $rules; public function __construct() { - $this->model = new ResultCommentsModel(); + $this->model = new TestCommentsModel(); $this->rules = []; } diff --git a/app/Database/Migrations/2026-01-17-000001_QualityControlSystem.php b/app/Database/Migrations/2026-01-17-000001_QualityControlSystem.php index 70a8c01..a63a83e 100644 --- a/app/Database/Migrations/2026-01-17-000001_QualityControlSystem.php +++ b/app/Database/Migrations/2026-01-17-000001_QualityControlSystem.php @@ -88,23 +88,25 @@ class QualityControlSystem extends Migration $this->forge->addForeignKey('test_id', 'master_tests', 'test_id', 'SET NULL', 'CASCADE'); $this->forge->createTable('results'); - // result_comments + // test_comments $this->forge->addField([ - 'result_comment_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], - 'result_id' => ['type' => 'INT', 'unsigned' => true], + 'test_comment_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'test_id' => ['type' => 'INT', 'unsigned' => true], + 'comment_date' => ['type' => 'DATE', 'null' => true], 'comment_text' => ['type' => 'TEXT', 'null' => true], 'created_at' => ['type' => 'DATETIME', 'null' => true], 'updated_at' => ['type' => 'DATETIME', 'null' => true], 'deleted_at' => ['type' => 'DATETIME', 'null' => true], ]); - $this->forge->addKey('result_comment_id', true); - $this->forge->addForeignKey('result_id', 'results', 'result_id', 'CASCADE', 'CASCADE'); - $this->forge->createTable('result_comments'); + $this->forge->addKey('test_comment_id', true); + $this->forge->addUniqueKey(['test_id', 'comment_date']); + $this->forge->addForeignKey('test_id', 'master_tests', 'test_id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('test_comments'); } public function down() { - $this->forge->dropTable('result_comments', true); + $this->forge->dropTable('test_comments', true); $this->forge->dropTable('results', true); $this->forge->dropTable('control_tests', true); $this->forge->dropTable('master_tests', true); diff --git a/app/Database/Migrations/2026-02-09-000001_Users.php b/app/Database/Migrations/2026-02-09-000001_Users.php new file mode 100644 index 0000000..a7c8cf9 --- /dev/null +++ b/app/Database/Migrations/2026-02-09-000001_Users.php @@ -0,0 +1,33 @@ +forge->addField([ + 'user_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'username' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false], + 'password' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false], + 'remember_token' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + 'deleted_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('user_id', true); + $this->forge->addUniqueKey('username'); + $this->forge->createTable('master_users'); + + // Insert default admin user + $password = password_hash('admin123', PASSWORD_DEFAULT); + $this->db->query("INSERT INTO master_users (username, password, created_at, updated_at) VALUES ('admin', '{$password}', NOW(), NOW())"); + } + + public function down() + { + $this->forge->dropTable('master_users', true); + } +} diff --git a/app/Database/Seeds/CmodQcSeeder.php b/app/Database/Seeds/CmodQcSeeder.php index bdc4668..575726e 100644 --- a/app/Database/Seeds/CmodQcSeeder.php +++ b/app/Database/Seeds/CmodQcSeeder.php @@ -13,7 +13,7 @@ class CmodQcSeeder extends Seeder $this->seedTests(); $this->seedControlTests(); $this->seedResults(); - $this->seedResultComments(); + $this->seedTestComments(); } protected function seedDepts() @@ -36,10 +36,14 @@ class CmodQcSeeder extends Seeder ['dept_id' => 2, 'control_name' => 'QC Low Hema', 'lot' => 'QC2024004', 'producer' => 'Streck', 'exp_date' => '2025-11-30'], ['dept_id' => 3, 'control_name' => 'QC Normal Immuno', 'lot' => 'QC2024005', 'producer' => 'Roche', 'exp_date' => '2025-10-31'], ['dept_id' => 4, 'control_name' => 'QC Normal Urine', 'lot' => 'QC2024006', 'producer' => 'Siemens', 'exp_date' => '2025-09-30'], - // New controls for January 2026 + // January 2026 controls ['dept_id' => 1, 'control_name' => 'Trulab N', 'lot' => 'TN2026001', 'producer' => 'Trinity', 'exp_date' => '2026-12-31'], ['dept_id' => 1, 'control_name' => 'Trulab P', 'lot' => 'TP2026001', 'producer' => 'Trinity', 'exp_date' => '2026-12-31'], ['dept_id' => 1, 'control_name' => 'Cholestest', 'lot' => 'CT2026001', 'producer' => 'Roche', 'exp_date' => '2026-12-31'], + // February 2026 controls (new lots) + ['dept_id' => 2, 'control_name' => 'QC Normal Hema', 'lot' => 'H202602', 'producer' => 'Streck', 'exp_date' => '2026-12-31'], + ['dept_id' => 2, 'control_name' => 'QC Low Hema', 'lot' => 'HL202602', 'producer' => 'Streck', 'exp_date' => '2026-12-31'], + ['dept_id' => 1, 'control_name' => 'Trulab N', 'lot' => 'TN202602', 'producer' => 'Trinity', 'exp_date' => '2026-12-31'], ]; $this->db->table('master_controls')->insertBatch($controls); } @@ -87,6 +91,15 @@ class CmodQcSeeder extends Seeder ['control_id' => 8, 'test_id' => 2, 'mean' => 2.4, 'sd' => 0.12], // Trulab P - Creatinine ['control_id' => 8, 'test_id' => 4, 'mean' => 195, 'sd' => 12], // Trulab P - Cholesterol ['control_id' => 9, 'test_id' => 4, 'mean' => 180, 'sd' => 10], // Cholestest - Cholesterol + // February 2026 control-tests + ['control_id' => 10, 'test_id' => 5, 'mean' => 7.6, 'sd' => 0.5], // QC Normal Hema (Feb) - WBC + ['control_id' => 10, 'test_id' => 6, 'mean' => 4.7, 'sd' => 0.2], // QC Normal Hema (Feb) - RBC + ['control_id' => 10, 'test_id' => 7, 'mean' => 14.8, 'sd' => 0.4], // QC Normal Hema (Feb) - HGB + ['control_id' => 11, 'test_id' => 5, 'mean' => 3.4, 'sd' => 0.25], // QC Low Hema (Feb) - WBC + ['control_id' => 11, 'test_id' => 6, 'mean' => 2.4, 'sd' => 0.14], // QC Low Hema (Feb) - RBC + ['control_id' => 12, 'test_id' => 1, 'mean' => 92, 'sd' => 4.2], // Trulab N (Feb) - Glucose + ['control_id' => 12, 'test_id' => 2, 'mean' => 0.95, 'sd' => 0.05], // Trulab N (Feb) - Creatinine + ['control_id' => 12, 'test_id' => 4, 'mean' => 148, 'sd' => 9], // Trulab N (Feb) - Cholesterol ]; $this->db->table('control_tests')->insertBatch($controlTests); } @@ -94,17 +107,19 @@ class CmodQcSeeder extends Seeder protected function seedResults() { $faker = \Faker\Factory::create(); - $resultDate = '2026-01-01'; $results = []; $controlTests = $this->db->table('control_tests')->get()->getResultArray(); $resultCount = 0; + $maxResults = 150; // Increased for more test data + // January 2026 results (days 1-31) foreach ($controlTests as $ct) { - $numResults = $faker->numberBetween(3, 4); + $numResults = $faker->numberBetween(3, 5); - for ($i = 0; $i < $numResults && $resultCount < 50; $i++) { - $resDate = date('Y-m-d', strtotime($resultDate . ' +' . $faker->numberBetween(0, 20) . ' days')); + for ($i = 0; $i < $numResults && $resultCount < $maxResults; $i++) { + $day = $faker->numberBetween(1, 31); + $resDate = "2026-01-" . str_pad($day, 2, '0', STR_PAD_LEFT); $value = $ct['mean'] + ($faker->randomFloat(2, -2.5, 2.5) * $ct['sd']); $results[] = [ @@ -118,60 +133,101 @@ class CmodQcSeeder extends Seeder $resultCount++; } } + + // February 2026 results (days 1-28) + foreach ($controlTests as $ct) { + $numResults = $faker->numberBetween(3, 5); + + for ($i = 0; $i < $numResults && $resultCount < $maxResults; $i++) { + $day = $faker->numberBetween(1, 28); + $resDate = "2026-02-" . str_pad($day, 2, '0', STR_PAD_LEFT); + $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), + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + $resultCount++; + } + } + $this->db->table('results')->insertBatch($results); } - protected function seedResultComments() + protected function seedTestComments() { - // Get all results to associate comments with specific results - $results = $this->db->table('results')->get()->getResultArray(); + $faker = \Faker\Factory::create(); + $comments = []; - if (empty($results)) { - return; - } - - // Map control_id + test_id to result_ids - $resultMap = []; - foreach ($results as $result) { - $key = $result['control_id'] . '_' . $result['test_id']; - $resultMap[$key][] = $result['result_id']; - } - - // Comments data with control_id + test_id for mapping - $commentsData = [ - ['control_id' => 1, 'test_id' => 1, 'comment_text' => 'Slight drift observed, instrument recalibrated on 01/15'], - ['control_id' => 2, 'test_id' => 4, 'comment_text' => 'High cholesterol values noted, lot change recommended'], - ['control_id' => 3, 'test_id' => 5, 'comment_text' => 'WBC controls stable throughout the month'], - ['control_id' => 4, 'test_id' => 6, 'comment_text' => 'RBC QC intermittent shift, probe cleaned'], - ['control_id' => 5, 'test_id' => 8, 'comment_text' => 'TSH assay maintenance performed on 01/10'], - ['control_id' => 6, 'test_id' => 10, 'comment_text' => 'Urine protein controls within range'], - ['control_id' => 1, 'test_id' => 2, 'comment_text' => 'Creatinine QC stable, no issues'], - ['control_id' => 2, 'test_id' => 1, 'comment_text' => 'Glucose high QC showed consistent elevation, reagent lot changed'], - ['control_id' => 3, 'test_id' => 7, 'comment_text' => 'Hemoglobin QC performance acceptable'], - ['control_id' => 5, 'test_id' => 9, 'comment_text' => 'Free T4 calibration curve verified'], - // New control comments for January 2026 - ['control_id' => 7, 'test_id' => 1, 'comment_text' => 'Trulab N Glucose stable throughout January'], - ['control_id' => 7, 'test_id' => 2, 'comment_text' => 'Trulab N Creatinine within acceptable range'], - ['control_id' => 7, 'test_id' => 4, 'comment_text' => 'Trulab N Cholesterol performance satisfactory'], - ['control_id' => 8, 'test_id' => 1, 'comment_text' => 'Trulab P Glucose elevated, monitoring continued'], - ['control_id' => 8, 'test_id' => 2, 'comment_text' => 'Trulab P Creatinine QC stable'], - ['control_id' => 8, 'test_id' => 4, 'comment_text' => 'Trulab P Cholesterol consistent with expected values'], - ['control_id' => 9, 'test_id' => 4, 'comment_text' => 'Cholestest performance verified, no issues'], + // Get all tests + $tests = $this->db->table('master_tests')->get()->getResultArray(); + + // Comment templates for different test types + $commentTemplates = [ + 'QC stable throughout the period', + 'Slight drift observed, monitoring continued', + 'Calibration verified, values within range', + 'Reagent lot changed, new QC run initiated', + 'Instrument maintenance performed', + 'Shift detected, corrective action taken', + 'Control values consistent with previous lot', + 'Temperature check completed, within specs', + 'New lot validated successfully', + 'Periodic check satisfactory', ]; - $comments = []; - foreach ($commentsData as $data) { - $key = $data['control_id'] . '_' . $data['test_id']; - if (isset($resultMap[$key]) && !empty($resultMap[$key])) { - // Attach comment to the first matching result + // Generate comments for Jan 2026 (days 1-31) + foreach ($tests as $test) { + $numComments = $faker->numberBetween(3, 6); + $usedDates = []; + + for ($i = 0; $i < $numComments; $i++) { + // Pick a random date in January + do { + $day = $faker->numberBetween(1, 31); + $dateKey = "2026-01-" . str_pad($day, 2, '0', STR_PAD_LEFT); + } while (in_array($dateKey, $usedDates)); + + $usedDates[] = $dateKey; + $comments[] = [ - 'result_id' => $resultMap[$key][0], - 'comment_text' => $data['comment_text'], + 'test_id' => $test['test_id'], + 'comment_date' => $dateKey, + 'comment_text' => $faker->randomElement($commentTemplates), 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]; } } - $this->db->table('result_comments')->insertBatch($comments); + + // Generate comments for Feb 2026 (days 1-28) + foreach ($tests as $test) { + $numComments = $faker->numberBetween(2, 5); + $usedDates = []; + + for ($i = 0; $i < $numComments; $i++) { + // Pick a random date in February + do { + $day = $faker->numberBetween(1, 28); + $dateKey = "2026-02-" . str_pad($day, 2, '0', STR_PAD_LEFT); + } while (in_array($dateKey, $usedDates)); + + $usedDates[] = $dateKey; + + $comments[] = [ + 'test_id' => $test['test_id'], + 'comment_date' => $dateKey, + 'comment_text' => $faker->randomElement($commentTemplates) . ' (Feb)', + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + } + } + + $this->db->table('test_comments')->insertBatch($comments); } } diff --git a/app/Filters/AuthFilter.php b/app/Filters/AuthFilter.php new file mode 100644 index 0000000..18376a6 --- /dev/null +++ b/app/Filters/AuthFilter.php @@ -0,0 +1,52 @@ +getPath(); + + // Skip auth filter for login/logout routes + $excludedPaths = ['login', 'logout']; + if (in_array($currentPath, $excludedPaths)) { + return; + } + + // Check if user is logged in + if (!$session->get('isLoggedIn')) { + // Check for remember token + $rememberToken = $_COOKIE['remember_token'] ?? null; + if ($rememberToken) { + $usersModel = new UsersModel(); + $user = $usersModel->findByRememberToken($rememberToken); + + if ($user) { + // Auto-login with remember token + $session->set([ + 'isLoggedIn' => true, + 'userId' => $user['user_id'], + 'username' => $user['username'] + ]); + return; + } + } + + return redirect()->to('/login'); + } + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + // Do nothing + } +} diff --git a/app/Models/Auth/UsersModel.php b/app/Models/Auth/UsersModel.php new file mode 100644 index 0000000..408eff3 --- /dev/null +++ b/app/Models/Auth/UsersModel.php @@ -0,0 +1,38 @@ +where('username', $username)->first(); + } + + public function verifyPassword($userId, $password) + { + $user = $this->find($userId); + if (!$user) { + return false; + } + return password_verify($password, $user['password']); + } + + public function setRememberToken($userId, $token) + { + return $this->update($userId, ['remember_token' => $token]); + } + + public function findByRememberToken($token) + { + return $this->where('remember_token', $token)->first(); + } +} diff --git a/app/Models/Qc/ResultCommentsModel.php b/app/Models/Qc/ResultCommentsModel.php deleted file mode 100644 index 28af2d7..0000000 --- a/app/Models/Qc/ResultCommentsModel.php +++ /dev/null @@ -1,152 +0,0 @@ -groupStart() - ->like('comment_text', $keyword) - ->groupEnd() - ->findAll(); - } - return $this->findAll(); - } - - /** - * Get comments by result_id - */ - public function getByResult(int $resultId): ?array { - return $this->where('result_id', $resultId) - ->where('deleted_at', null) - ->first(); - } - - /** - * Get all comments for a control+test combination (via results) - */ - public function getByControlTest(int $controlId, int $testId): ?array { - // First get result IDs for this control+test - $db = \Config\Database::connect(); - $results = $db->table('results') - ->select('result_id') - ->where('control_id', $controlId) - ->where('test_id', $testId) - ->where('deleted_at', null) - ->get() - ->getResultArray(); - - if (empty($results)) { - return null; - } - - $resultIds = array_column($results, 'result_id'); - return $this->whereIn('result_id', $resultIds) - ->where('deleted_at', null) - ->orderBy('created_at', 'DESC') - ->findAll(); - } - - /** - * Get all comments for a test (via results) - */ - public function getByTest(int $testId): array { - $db = \Config\Database::connect(); - $results = $db->table('results') - ->select('result_id') - ->where('test_id', $testId) - ->where('deleted_at', null) - ->get() - ->getResultArray(); - - if (empty($results)) { - return []; - } - - $resultIds = array_column($results, 'result_id'); - return $this->whereIn('result_id', $resultIds) - ->where('deleted_at', null) - ->findAll(); - } - - /** - * Get comments by control, test, and month (for reports) - */ - public function getByControlTestMonth(int $controlId, int $testId, string $month): ?array { - $db = \Config\Database::connect(); - $results = $db->table('results') - ->select('result_id') - ->where('control_id', $controlId) - ->where('test_id', $testId) - ->where('res_date >=', $month . '-01') - ->where('res_date <=', $month . '-31') - ->where('deleted_at', null) - ->get() - ->getResultArray(); - - if (empty($results)) { - return null; - } - - $resultIds = array_column($results, 'result_id'); - $comments = $this->whereIn('result_id', $resultIds) - ->where('deleted_at', null) - ->orderBy('created_at', 'DESC') - ->findAll(); - - return $comments ?: null; - } - - /** - * Get comments for multiple results - */ - public function getByResultIds(array $resultIds): array { - if (empty($resultIds)) { - return []; - } - return $this->whereIn('result_id', $resultIds) - ->where('deleted_at', null) - ->findAll(); - } - - /** - * Upsert comment for a result - */ - public function upsertComment(array $data): int { - if (!isset($data['result_id'])) { - return 0; - } - - $existing = $this->where('result_id', $data['result_id']) - ->where('deleted_at', null) - ->first(); - - if ($existing) { - if (empty($data['comment_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['comment_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 7db0510..d7e1a2b 100644 --- a/app/Models/Qc/ResultsModel.php +++ b/app/Models/Qc/ResultsModel.php @@ -39,9 +39,9 @@ class ResultsModel extends BaseModel { r.test_id as testId, r.res_date as resDate, r.res_value as resValue, - rc.comment_text as resComment + tc.comment_text as resComment '); - $builder->join('result_comments rc', 'rc.result_id = r.result_id AND rc.deleted_at IS NULL', 'left'); + $builder->join('test_comments tc', 'tc.test_id = r.test_id AND tc.comment_date = r.res_date AND tc.deleted_at IS NULL', 'left'); $builder->where('r.res_date', $date); $builder->where('r.control_id', $controlId); $builder->where('r.deleted_at', null); @@ -60,9 +60,9 @@ class ResultsModel extends BaseModel { r.test_id as testId, r.res_date as resDate, r.res_value as resValue, - rc.comment_text as resComment + tc.comment_text as resComment '); - $builder->join('result_comments rc', 'rc.result_id = r.result_id AND rc.deleted_at IS NULL', 'left'); + $builder->join('test_comments tc', 'tc.test_id = r.test_id AND tc.comment_date = r.res_date AND tc.deleted_at IS NULL', 'left'); $builder->where('r.test_id', $testId); $builder->where('r.res_date >=', $month . '-01'); $builder->where('r.res_date <=', $month . '-31'); @@ -81,9 +81,9 @@ class ResultsModel extends BaseModel { r.result_id as id, r.res_date as resDate, r.res_value as resValue, - rc.comment_text as resComment + tc.comment_text as resComment '); - $builder->join('result_comments rc', 'rc.result_id = r.result_id AND rc.deleted_at IS NULL', 'left'); + $builder->join('test_comments tc', 'tc.test_id = r.test_id AND tc.comment_date = r.res_date AND tc.deleted_at IS NULL', 'left'); $builder->where('r.control_id', $controlId); $builder->where('r.test_id', $testId); $builder->where('r.res_date >=', $month . '-01'); diff --git a/app/Models/Qc/TestCommentsModel.php b/app/Models/Qc/TestCommentsModel.php new file mode 100644 index 0000000..2bc0a2f --- /dev/null +++ b/app/Models/Qc/TestCommentsModel.php @@ -0,0 +1,137 @@ +groupStart() + ->like('comment_text', $keyword) + ->groupEnd() + ->findAll(); + } + return $this->findAll(); + } + + /** + * Get comment by test_id and date + */ + public function getByTestAndDate(int $testId, string $date): ?array { + return $this->where('test_id', $testId) + ->where('comment_date', $date) + ->where('deleted_at', null) + ->first(); + } + + /** + * Get all comments for a test + */ + public function getByTest(int $testId): array { + return $this->where('test_id', $testId) + ->where('deleted_at', null) + ->orderBy('comment_date', 'DESC') + ->findAll(); + } + + /** + * Get comments for test(s) within a date range + * @param int|array $testId Single test ID or array of test IDs + */ + public function getByTestAndDateRange($testId, string $startDate, string $endDate): array { + $builder = $this->where('comment_date >=', $startDate) + ->where('comment_date <=', $endDate) + ->where('deleted_at', null); + + if (is_array($testId)) { + $builder->whereIn('test_id', $testId); + } else { + $builder->where('test_id', $testId); + } + + return $builder->orderBy('comment_date', 'ASC') + ->findAll(); + } + + /** + * Get comments by month for a test + */ + public function getByTestAndMonth(int $testId, string $month): array { + return $this->where('test_id', $testId) + ->where('comment_date >=', $month . '-01') + ->where('comment_date <=', $month . '-31') + ->where('deleted_at', null) + ->orderBy('comment_date', 'ASC') + ->findAll(); + } + + /** + * Get all comments for multiple tests + */ + public function getByTestIds(array $testIds): array { + if (empty($testIds)) { + return []; + } + return $this->whereIn('test_id', $testIds) + ->where('deleted_at', null) + ->orderBy('comment_date', 'DESC') + ->findAll(); + } + + /** + * Upsert comment for a test + date combination + */ + public function upsertComment(array $data): int { + if (!isset($data['test_id']) || !isset($data['comment_date'])) { + return 0; + } + + $existing = $this->where('test_id', $data['test_id']) + ->where('comment_date', $data['comment_date']) + ->where('deleted_at', null) + ->first(); + + if ($existing) { + if (empty($data['comment_text'])) { + // If text is empty, soft delete + $this->update($existing['test_comment_id'], ['deleted_at' => date('Y-m-d H:i:s')]); + return $existing['test_comment_id']; + } + $this->update($existing['test_comment_id'], $data); + return $existing['test_comment_id']; + } else { + if (empty($data['comment_text'])) { + return 0; // Don't insert empty comments + } + return $this->insert($data, true); + } + } + + /** + * Delete comment by test_id and date + */ + public function deleteByTestAndDate(int $testId, string $date): bool { + $existing = $this->where('test_id', $testId) + ->where('comment_date', $date) + ->where('deleted_at', null) + ->first(); + + if ($existing) { + return $this->delete($existing['test_comment_id']); + } + return false; + } +} diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php new file mode 100644 index 0000000..d7c754f --- /dev/null +++ b/app/Views/auth/login.php @@ -0,0 +1,171 @@ + + + + + + + + Login - TinyQC + + + + + + + + + +
+
+
+
+
+ +
+

TinyQC

+

QC Management System

+
+ +
+ + + + + + +
+ +
+ +
+ + +
+ + +
+ +
or
+ +
+ +
+
+
+ +

+ © TinyQC - PT.Summit +

+
+ + + + + \ No newline at end of file diff --git a/app/Views/entry/daily.php b/app/Views/entry/daily.php index c4384f5..160f203 100644 --- a/app/Views/entry/daily.php +++ b/app/Views/entry/daily.php @@ -243,8 +243,10 @@ document.addEventListener('alpine:init', () => { 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; + // Get comment from either the new comment field or existing result + const comment = test.comment || (test.existingResult && test.existingResult.resComment); + if (comment) { + this.commentsData[test.testId] = comment; } } } catch (e) { @@ -288,7 +290,7 @@ document.addEventListener('alpine:init', () => { for (const item of savedIds) { const comment = this.commentsData[item.testId]; if (comment) { - await this.saveComment(item.resultId, comment); + await this.saveComment(item.testId, this.date, comment); } } @@ -309,13 +311,14 @@ document.addEventListener('alpine:init', () => { } }, - async saveComment(resultId, commentText) { + async saveComment(testId, date, commentText) { try { const response = await fetch(`${BASEURL}api/entry/comment`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - resultId: resultId, + testId: testId, + date: date, comment: commentText }) }); diff --git a/app/Views/entry/monthly.php b/app/Views/entry/monthly.php index 2b1f2e1..ce6f18f 100644 --- a/app/Views/entry/monthly.php +++ b/app/Views/entry/monthly.php @@ -446,14 +446,12 @@ document.addEventListener('alpine:init', () => { const json = await response.json(); if (json.status === 'success') { - // Save comments using the returned result ID map - const resultIdMap = json.data.resultIdMap || {}; + // Save comments for changed entries for (const key in this.commentsData) { if (this.originalComments[key] !== this.commentsData[key]) { - const resultId = resultIdMap[key]; - if (resultId) { - await this.saveComment(resultId, this.commentsData[key]); - } + const [controlId, day] = key.split('_'); + const date = `${this.month}-${day.padStart(2, '0')}`; + await this.saveComment(this.selectedTest, date, this.commentsData[key]); } } @@ -470,13 +468,14 @@ document.addEventListener('alpine:init', () => { } }, - async saveComment(resultId, commentText) { + async saveComment(testId, date, commentText) { try { const response = await fetch(`${BASEURL}api/entry/comment`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - resultId: resultId, + testId: testId, + date: date, comment: commentText }) }); diff --git a/app/Views/layout/main_layout.php b/app/Views/layout/main_layout.php index b3d8890..ffff5cb 100644 --- a/app/Views/layout/main_layout.php +++ b/app/Views/layout/main_layout.php @@ -45,22 +45,20 @@ -