feat: Implement Monthly Entry interface and consolidate Entry API controller

- Implement Monthly Entry interface with full data entry grid
    - Add batch save with validation and statistics for monthly results
    - Support daily comments per day per test
    - Add result status indicators and validation summaries
  - Consolidate Entry API controller
    - Refactor EntryApiController to handle both daily/monthly operations
    - Add batch save endpoints with comprehensive validation
    - Implement statistics calculation for result entries
  - Add Control Test master data management
    - Create MasterControlsController for CRUD operations
    - Add dialog forms for control test configuration
    - Implement control-test associations with QC parameters
  - Refactor Report API and views
    - Implement new report index with Levey-Jennings charts placeholder
    - Add monthly report functionality with result statistics
    - Include QC summary with mean, SD, and CV calculations
  - UI improvements
    - Overhaul dashboard with improved layout
    - Update daily entry interface with inline editing
    - Enhance master data management with DaisyUI components
    - Add proper modal dialogs and form validation
  - Database and seeding
    - Update migration for control_tests table schema
    - Remove redundant migration and seed files
    - Update seeders with comprehensive test data
  - Documentation
    - Update CLAUDE.md with comprehensive project documentation
    - Add architecture overview and conventions

  BREAKING CHANGES:
  - Refactored Entry API endpoints structure
  - Removed ReportApiController::view() - consolidated into new report index
This commit is contained in:
mahdahar 2026-01-21 13:41:37 +07:00
parent dd7a058511
commit 0a96b04bdf
33 changed files with 2161 additions and 859 deletions

View File

@ -27,7 +27,7 @@ This is a CodeIgniter 4 Quality Control management system with:
- **Database**: SQL Server (uses `SQLSRV` driver) - **Database**: SQL Server (uses `SQLSRV` driver)
- **Frontend**: TailwindCSS + Alpine.js + DaisyUI (CDN-based, no build step) - **Frontend**: TailwindCSS + Alpine.js + DaisyUI (CDN-based, no build step)
- **Testing**: PHPUnit 10 - **Testing**: PHPUnit 10
- **Icons**: FontAwesome 6 - **Icons**: FontAwesome 7
### Key Components ### Key Components
@ -39,8 +39,8 @@ This is a CodeIgniter 4 Quality Control management system with:
**Controllers** (`app/Controllers/`): **Controllers** (`app/Controllers/`):
- `PageController` - Renders page views with `main_layout` - `PageController` - Renders page views with `main_layout`
- `Api\*` - Generic entry API controllers (Dashboard, Report, Entry) - `Api\*` - Consolidated entry API controllers (DashboardApi, EntryApi, ReportApi)
- `Master\*` - CRUD for master data (Depts, Tests, Controls) - `Master\*` - CRUD for master data (MasterDepts, MasterTests, MasterControls)
- `Qc\*` - QC domain controllers (ControlTests, Results, ResultComments) - `Qc\*` - QC domain controllers (ControlTests, Results, ResultComments)
**Views** (`app/Views/`): **Views** (`app/Views/`):
@ -50,6 +50,7 @@ This is a CodeIgniter 4 Quality Control management system with:
**Helpers** (`app/Helpers/`): **Helpers** (`app/Helpers/`):
- `stringcase_helper.php` - `camel_to_snake_array()`, `snake_to_camel_array()` - `stringcase_helper.php` - `camel_to_snake_array()`, `snake_to_camel_array()`
- The `stringcase` helper is auto-loaded in `BaseController`
### Database Schema ### Database Schema
@ -170,3 +171,8 @@ class MasterDeptsModel extends BaseModel {
1. Don't skip soft deletes (`deleted_at`) 1. Don't skip soft deletes (`deleted_at`)
2. Don't mix concerns - controllers handle HTTP, models handle data 2. Don't mix concerns - controllers handle HTTP, models handle data
3. Don't forget case conversion - use helpers or BaseModel 3. Don't forget case conversion - use helpers or BaseModel
## Response Style
- Use emojis in responses where appropriate to add visual appeal 😊
- Keep responses concise and helpful

View File

@ -88,5 +88,7 @@ class Autoload extends AutoloadConfig
* *
* @var list<string> * @var list<string>
*/ */
public $helpers = []; public $helpers = [
'stringcase',
];
} }

View File

@ -10,6 +10,7 @@ $routes->get('/', 'PageController::dashboard');
$routes->get('/master/dept', 'PageController::masterDept'); $routes->get('/master/dept', 'PageController::masterDept');
$routes->get('/master/test', 'PageController::masterTest'); $routes->get('/master/test', 'PageController::masterTest');
$routes->get('/master/control', 'PageController::masterControl'); $routes->get('/master/control', 'PageController::masterControl');
$routes->get('/master/control-tests', 'PageController::controlTests');
$routes->get('/dept', 'PageController::dept'); $routes->get('/dept', 'PageController::dept');
$routes->get('/test', 'PageController::test'); $routes->get('/test', 'PageController::test');
$routes->get('/control', 'PageController::control'); $routes->get('/control', 'PageController::control');

View File

@ -32,6 +32,7 @@ class DashboardApiController extends BaseController
r.res_value, r.res_value,
r.created_at, r.created_at,
c.control_name as controlName, c.control_name as controlName,
c.lot,
t.test_name as testName, t.test_name as testName,
ct.mean, ct.mean,
ct.sd ct.sd
@ -65,6 +66,7 @@ class DashboardApiController extends BaseController
'resValue' => $row['res_value'], 'resValue' => $row['res_value'],
'createdAt' => $row['created_at'], 'createdAt' => $row['created_at'],
'controlName' => $row['controlName'], 'controlName' => $row['controlName'],
'lot' => $row['lot'],
'testName' => $row['testName'], 'testName' => $row['testName'],
'mean' => $row['mean'], 'mean' => $row['mean'],
'sd' => $row['sd'], 'sd' => $row['sd'],

View File

@ -185,15 +185,18 @@ class EntryApiController extends BaseController
'control_id' => $r['controlId'], 'control_id' => $r['controlId'],
'test_id' => $r['testId'], 'test_id' => $r['testId'],
'res_date' => $date, 'res_date' => $date,
'res_value' => $r['value'] !== '' ? (float) $r['value'] : null, 'res_value' => $r['value'] !== '' ? (float) $r['value'] : null
'res_comment' => $r['comment'] ?? null
]; ];
if ($data['res_value'] === null) { if ($data['res_value'] === null) {
continue; // Skip empty values continue; // Skip empty values
} }
$savedIds[] = $this->resultModel->upsertResult($data); $resultId = $this->resultModel->upsertResult($data);
$savedIds[] = [
'testId' => $r['testId'],
'resultId' => $resultId
];
} }
// Commit transaction // Commit transaction
@ -231,14 +234,14 @@ class EntryApiController extends BaseController
return $this->failNotFound('Test not found'); return $this->failNotFound('Test not found');
} }
// Get controls for this test with QC parameters // Get controls for this test with QC parameters (filter out expired)
$controls = $this->controlTestModel->getByTest((int) $testId); $controls = $this->controlTestModel->getByTest((int) $testId, $month);
// Get existing results for this month // Get existing results for this month
$results = $this->resultModel->getByMonth((int) $testId, $month); $results = $this->resultModel->getByMonth((int) $testId, $month);
// Get comments for this month // Get comments for this test (via results)
$comments = $this->commentModel->getByTestMonth((int) $testId, $month); $comments = $this->commentModel->getByTest((int) $testId);
// Map results by control_id and day // Map results by control_id and day
$resultsByControl = []; $resultsByControl = [];
@ -246,17 +249,16 @@ class EntryApiController extends BaseController
$day = (int) date('j', strtotime($r['resDate'])); $day = (int) date('j', strtotime($r['resDate']));
$resultsByControl[$r['controlId']][$day] = [ $resultsByControl[$r['controlId']][$day] = [
'resultId' => $r['id'], 'resultId' => $r['id'],
'resValue' => $r['resValue'], 'resValue' => $r['resValue']
'resComment' => $r['resComment']
]; ];
} }
// Map comments by control_id (BaseModel returns camelCase) // Map comments by result_id
$commentsByControl = []; $commentsByResultId = [];
foreach ($comments as $c) { foreach ($comments as $c) {
$commentsByControl[$c['controlId']] = [ $commentsByResultId[$c['resultId']] = [
'commentId' => $c['resultCommentId'], 'commentId' => $c['resultCommentId'],
'comText' => $c['comText'] 'commentText' => $c['commentText']
]; ];
} }
@ -267,10 +269,15 @@ class EntryApiController extends BaseController
$resultsArray = array_fill(1, 31, null); $resultsArray = array_fill(1, 31, null);
foreach ($resultsByDay as $day => $val) { foreach ($resultsByDay as $day => $val) {
$resultsArray[$day] = $val; $resultWithComment = $val;
// Add comment if exists for this result
if (isset($commentsByResultId[$val['resultId']])) {
$resultWithComment['resComment'] = $commentsByResultId[$val['resultId']]['commentText'];
} else {
$resultWithComment['resComment'] = null;
}
$resultsArray[$day] = $resultWithComment;
} }
$comment = $commentsByControl[$c['controlId']] ?? null;
$controlsWithData[] = [ $controlsWithData[] = [
'controlTestId' => $c['id'], 'controlTestId' => $c['id'],
@ -280,8 +287,7 @@ class EntryApiController extends BaseController
'producer' => $c['producer'], 'producer' => $c['producer'],
'mean' => $c['mean'], 'mean' => $c['mean'],
'sd' => $c['sd'], 'sd' => $c['sd'],
'results' => $resultsArray, 'results' => $resultsArray
'comment' => $comment
]; ];
} }
@ -326,7 +332,7 @@ class EntryApiController extends BaseController
$daysInMonth = (int) date('t', strtotime($month . '-01')); $daysInMonth = (int) date('t', strtotime($month . '-01'));
$savedCount = 0; $savedCount = 0;
$commentCount = 0; $resultIdMap = []; // Map controlId + day -> resultId
// Start transaction // Start transaction
$this->resultModel->db->transBegin(); $this->resultModel->db->transBegin();
@ -334,10 +340,18 @@ class EntryApiController extends BaseController
foreach ($controls as $c) { foreach ($controls as $c) {
$controlId = $c['controlId']; $controlId = $c['controlId'];
$results = $c['results'] ?? []; $results = $c['results'] ?? [];
$commentText = $c['comment'] ?? null;
// Save results // Save results with optional comments
foreach ($results as $day => $value) { foreach ($results as $day => $data) {
// Handle both old format (value only) and new format (value + comment)
if (is_array($data)) {
$value = $data['value'];
$commentText = $data['comment'] ?? null;
} else {
$value = $data;
$commentText = null;
}
if ($value === null || $value === '') { if ($value === null || $value === '') {
continue; continue;
} }
@ -348,29 +362,17 @@ class EntryApiController extends BaseController
} }
$date = $month . '-' . str_pad($day, 2, '0', STR_PAD_LEFT); $date = $month . '-' . str_pad($day, 2, '0', STR_PAD_LEFT);
$data = [ $resultData = [
'control_id' => $controlId, 'control_id' => $controlId,
'test_id' => $testId, 'test_id' => $testId,
'res_date' => $date, 'res_date' => $date,
'res_value' => (float) $value 'res_value' => (float) $value
]; ];
$this->resultModel->upsertResult($data); $resultId = $this->resultModel->upsertResult($resultData);
$resultIdMap["{$controlId}_{$day}"] = $resultId;
$savedCount++; $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 // Commit transaction
@ -378,8 +380,11 @@ class EntryApiController extends BaseController
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => "Saved {$savedCount} results and {$commentCount} comments", 'message' => "Saved {$savedCount} results",
'data' => ['savedCount' => $savedCount, 'commentCount' => $commentCount] 'data' => [
'savedCount' => $savedCount,
'resultIdMap' => $resultIdMap
]
], 200); ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
// Rollback transaction on error // Rollback transaction on error
@ -390,14 +395,14 @@ class EntryApiController extends BaseController
/** /**
* POST /api/entry/comment * POST /api/entry/comment
* Save monthly comment (single) * Save daily comment (single)
*/ */
public function saveComment() public function saveComment()
{ {
try { try {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
$required = ['controlId', 'testId', 'month', 'comment']; $required = ['resultId', 'comment'];
foreach ($required as $field) { foreach ($required as $field) {
if (!isset($input[$field])) { if (!isset($input[$field])) {
return $this->failValidationErrors([$field => 'Required']); return $this->failValidationErrors([$field => 'Required']);
@ -405,10 +410,8 @@ class EntryApiController extends BaseController
} }
$commentData = [ $commentData = [
'control_id' => $input['controlId'], 'result_id' => $input['resultId'],
'test_id' => $input['testId'], 'comment_text' => trim($input['comment'])
'comment_month' => $input['month'],
'com_text' => trim($input['comment'])
]; ];
$id = $this->commentModel->upsertComment($commentData); $id = $this->commentModel->upsertComment($commentData);

View File

@ -45,22 +45,22 @@ class ReportApiController extends BaseController
$control = $this->dictControlModel->find($controlId); $control = $this->dictControlModel->find($controlId);
if (!$control) continue; if (!$control) continue;
$controlTest = $this->controlTestModel->getByControlAndTest($control['control_id'], $test); $controlTest = $this->controlTestModel->getByControlAndTest($control['controlId'], $test);
$results = $this->resultModel->getByMonth($control['control_id'], $test, $dates); $results = $this->resultModel->getByControlAndMonth($control['controlId'], $test, $dates);
$comment = $this->commentModel->getByControlTestMonth($control['control_id'], $test, $dates); $comment = $this->commentModel->getByControlTestMonth($control['controlId'], $test, $dates);
$testInfo = $this->dictTestModel->find($test); $testInfo = $this->dictTestModel->find($test);
$outOfRangeCount = 0; $outOfRangeCount = 0;
$processedResults = []; $processedResults = [];
if ($controlTest && $controlTest['sd'] > 0) { if ($controlTest && $controlTest['sd'] > 0) {
foreach ($results as $res) { foreach ($results as $res) {
$zScore = ($res['resvalue'] - $controlTest['mean']) / $controlTest['sd']; $zScore = ($res['resValue'] - $controlTest['mean']) / $controlTest['sd'];
$outOfRange = abs($zScore) > 2; $outOfRange = abs($zScore) > 2;
if ($outOfRange) $outOfRangeCount++; if ($outOfRange) $outOfRangeCount++;
$processedResults[] = [ $processedResults[] = [
'resdate' => $res['resdate'], 'resdate' => $res['resDate'],
'resvalue' => $res['resvalue'], 'resvalue' => $res['resValue'],
'zScore' => round($zScore, 2), 'zScore' => round($zScore, 2),
'outOfRange' => $outOfRange, 'outOfRange' => $outOfRange,
'status' => $zScore === null ? '-' : (abs($zScore) > 2 ? 'Out' : (abs($zScore) > 1 ? 'Warn' : 'OK')) 'status' => $zScore === null ? '-' : (abs($zScore) > 2 ? 'Out' : (abs($zScore) > 1 ? 'Warn' : 'OK'))
@ -69,8 +69,8 @@ class ReportApiController extends BaseController
} else { } else {
foreach ($results as $res) { foreach ($results as $res) {
$processedResults[] = [ $processedResults[] = [
'resdate' => $res['resdate'], 'resdate' => $res['resDate'],
'resvalue' => $res['resvalue'], 'resvalue' => $res['resValue'],
'zScore' => null, 'zScore' => null,
'outOfRange' => false, 'outOfRange' => false,
'status' => '-' 'status' => '-'

View File

@ -18,6 +18,6 @@ abstract class BaseController extends Controller
{ {
parent::initController($request, $response, $logger); parent::initController($request, $response, $logger);
$this->session = \Config\Services::session(); $this->session = \Config\Services::session();
$this->helpers = ['form', 'url', 'json']; $this->helpers = ['form', 'url', 'json', 'stringcase'];
} }
} }

View File

@ -14,7 +14,7 @@ class MasterControlsController extends BaseController {
public function __construct() { public function __construct() {
$this->model = new MasterControlsModel(); $this->model = new MasterControlsModel();
$this->rules = [ $this->rules = [
'name' => 'required|min_length[1]', 'controlName' => 'required|min_length[1]',
'lot' => 'required|min_length[1]', 'lot' => 'required|min_length[1]',
]; ];
} }
@ -71,10 +71,10 @@ class MasterControlsController extends BaseController {
public function update($id = null) { public function update($id = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) { if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors()); return $this->failValidationErrors($this->validator->getErrors());
} }
$input = camel_to_snake_array($input);
try { try {
$this->model->update($id, $input); $this->model->update($id, $input);
return $this->respond([ return $this->respond([

View File

@ -14,7 +14,7 @@ class MasterTestsController extends BaseController {
public function __construct() { public function __construct() {
$this->model = new MasterTestsModel(); $this->model = new MasterTestsModel();
$this->rules = [ $this->rules = [
'name' => 'required|min_length[1]', 'testName' => 'required|min_length[1]',
]; ];
} }

View File

@ -23,6 +23,10 @@ class PageController extends BaseController {
return view('master/control/index'); return view('master/control/index');
} }
public function controlTests() {
return view('master/control_test/index');
}
public function entry() { public function entry() {
return view('entry/index'); return view('entry/index');
} }

View File

@ -5,93 +5,67 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\Qc\ControlTestsModel; use App\Models\Qc\ControlTestsModel;
class ControlTestsController extends BaseController { class ControlTestsController extends BaseController
{
use ResponseTrait; use ResponseTrait;
protected $model; protected $model;
protected $rules; protected $rules = [
'controlId' => 'required|numeric',
'testId' => 'required|numeric',
'mean' => 'required|decimal',
'sd' => 'required|decimal',
];
public function __construct() { public function __construct()
{
$this->model = new ControlTestsModel(); $this->model = new ControlTestsModel();
$this->rules = [];
} }
public function index() { public function index()
{
$keyword = $this->request->getGet('keyword'); $keyword = $this->request->getGet('keyword');
try {
$rows = $this->model->search($keyword);
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'fetch success', 'data' => $this->model->search($keyword)
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function show($id = null) {
try {
$row = $this->model->find($id);
if (!$row) {
return $this->respond([
'status' => 'success',
'message' => 'data not found.'
], 200);
}
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => [$row]
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function create() {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$id = $this->model->insert($input, true);
return $this->respondCreated([
'status' => 'success',
'message' => $id
]); ]);
}
public function create()
{
$input = $this->request->getJSON(true);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$id = $this->model->insert($input);
return $this->respondCreated(['status' => 'success', 'data' => $id]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError($e->getMessage()); return $this->failServerError($e->getMessage());
} }
} }
public function update($id = null) { public function update($id = null)
{
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) { if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors()); return $this->failValidationErrors($this->validator->getErrors());
} }
try { try {
$this->model->update($id, $input); $this->model->update($id, $input);
return $this->respond([ return $this->respond(['status' => 'success']);
'status' => 'success',
'message' => 'update success',
'data' => [$id]
], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError($e->getMessage()); return $this->failServerError($e->getMessage());
} }
} }
public function delete($id = null) { public function delete($id = null)
{
try { try {
$this->model->delete($id); $this->model->delete($id);
return $this->respond([ return $this->respond(['status' => 'success']);
'status' => 'success',
'message' => 'delete success',
'data' => [$id]
], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError($e->getMessage()); return $this->failServerError($e->getMessage());
} }

View File

@ -8,10 +8,10 @@ class QualityControlSystem extends Migration
{ {
public function up() public function up()
{ {
// master_depts - No dependencies // master_depts
$this->forge->addField([ $this->forge->addField([
'dept_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], 'dept_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'dept_name' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true], 'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true], 'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true], 'deleted_at' => ['type' => 'DATETIME', 'null' => true],
@ -19,11 +19,11 @@ class QualityControlSystem extends Migration
$this->forge->addKey('dept_id', true); $this->forge->addKey('dept_id', true);
$this->forge->createTable('master_depts'); $this->forge->createTable('master_depts');
// master_controls - FK to master_depts // master_controls
$this->forge->addField([ $this->forge->addField([
'control_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], 'control_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'dept_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true], 'dept_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'control_name' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'lot' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'lot' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'producer' => ['type' => 'TEXT', 'null' => true], 'producer' => ['type' => 'TEXT', 'null' => true],
'exp_date' => ['type' => 'DATE', 'null' => true], 'exp_date' => ['type' => 'DATE', 'null' => true],
@ -35,13 +35,14 @@ class QualityControlSystem extends Migration
$this->forge->addForeignKey('dept_id', 'master_depts', 'dept_id', 'SET NULL', 'CASCADE'); $this->forge->addForeignKey('dept_id', 'master_depts', 'dept_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('master_controls'); $this->forge->createTable('master_controls');
// master_tests - FK to master_depts // master_tests
$this->forge->addField([ $this->forge->addField([
'test_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], 'test_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'dept_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true], 'dept_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'test_code' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'unit' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'test_name' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'method' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'test_unit' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
'test_method' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'cva' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'cva' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'ba' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'ba' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'tea' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'tea' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
@ -50,10 +51,11 @@ class QualityControlSystem extends Migration
'deleted_at' => ['type' => 'DATETIME', 'null' => true], 'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]); ]);
$this->forge->addKey('test_id', true); $this->forge->addKey('test_id', true);
$this->forge->addUniqueKey('test_code');
$this->forge->addForeignKey('dept_id', 'master_depts', 'dept_id', 'SET NULL', 'CASCADE'); $this->forge->addForeignKey('dept_id', 'master_depts', 'dept_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('master_tests'); $this->forge->createTable('master_tests');
// control_tests - FK to master_controls, master_tests // control_tests
$this->forge->addField([ $this->forge->addField([
'control_test_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], 'control_test_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'control_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true], 'control_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
@ -65,18 +67,18 @@ class QualityControlSystem extends Migration
'deleted_at' => ['type' => 'DATETIME', 'null' => true], 'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]); ]);
$this->forge->addKey('control_test_id', true); $this->forge->addKey('control_test_id', true);
$this->forge->addUniqueKey(['control_id', 'test_id']);
$this->forge->addForeignKey('control_id', 'master_controls', 'control_id', 'SET NULL', 'CASCADE'); $this->forge->addForeignKey('control_id', 'master_controls', 'control_id', 'SET NULL', 'CASCADE');
$this->forge->addForeignKey('test_id', 'master_tests', 'test_id', 'SET NULL', 'CASCADE'); $this->forge->addForeignKey('test_id', 'master_tests', 'test_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('control_tests'); $this->forge->createTable('control_tests');
// results - FK to master_controls, master_tests // results
$this->forge->addField([ $this->forge->addField([
'result_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], 'result_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'control_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true], 'control_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'test_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true], 'test_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'res_date' => ['type' => 'DATETIME', 'null' => true], 'res_date' => ['type' => 'DATE', 'null' => true],
'res_value' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], 'res_value' => ['type' => 'DECIMAL', 'constraint' => '18,4', 'null' => true],
'res_comment' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true], 'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true], 'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true], 'deleted_at' => ['type' => 'DATETIME', 'null' => true],
@ -86,21 +88,17 @@ class QualityControlSystem extends Migration
$this->forge->addForeignKey('test_id', 'master_tests', 'test_id', 'SET NULL', 'CASCADE'); $this->forge->addForeignKey('test_id', 'master_tests', 'test_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('results'); $this->forge->createTable('results');
// result_comments - FK to master_controls, master_tests (composite unique key) // result_comments
$this->forge->addField([ $this->forge->addField([
'result_comment_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], 'result_comment_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'control_id' => ['type' => 'INT', 'unsigned' => true], 'result_id' => ['type' => 'INT', 'unsigned' => true],
'test_id' => ['type' => 'INT', 'unsigned' => true], 'comment_text' => ['type' => 'TEXT', 'null' => true],
'comment_month' => ['type' => 'VARCHAR', 'constraint' => 7],
'com_text' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true], 'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true], 'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true], 'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]); ]);
$this->forge->addKey('result_comment_id', true); $this->forge->addKey('result_comment_id', true);
$this->forge->addUniqueKey(['control_id', 'test_id', 'comment_month']); $this->forge->addForeignKey('result_id', 'results', 'result_id', 'CASCADE', 'CASCADE');
$this->forge->addForeignKey('control_id', 'master_controls', 'control_id', 'CASCADE', 'CASCADE');
$this->forge->addForeignKey('test_id', 'master_tests', 'test_id', 'CASCADE', 'CASCADE');
$this->forge->createTable('result_comments'); $this->forge->createTable('result_comments');
} }

View File

@ -1,36 +0,0 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class RenameMasterColumns extends Migration
{
public function up()
{
// master_depts: name -> dept_name
$this->db->query("ALTER TABLE master_depts CHANGE COLUMN name dept_name VARCHAR(255) NOT NULL");
// master_controls: name -> control_name
$this->db->query("ALTER TABLE master_controls CHANGE COLUMN name control_name VARCHAR(255) NOT NULL");
// master_tests: name -> test_name, unit -> test_unit, method -> test_method
$this->db->query("ALTER TABLE master_tests CHANGE COLUMN name test_name VARCHAR(255) NOT NULL");
$this->db->query("ALTER TABLE master_tests CHANGE COLUMN unit test_unit VARCHAR(100) NULL");
$this->db->query("ALTER TABLE master_tests CHANGE COLUMN method test_method VARCHAR(255) NULL");
}
public function down()
{
// master_tests: revert
$this->db->query("ALTER TABLE master_tests CHANGE COLUMN test_method method VARCHAR(255) NULL");
$this->db->query("ALTER TABLE master_tests CHANGE COLUMN test_unit unit VARCHAR(100) NULL");
$this->db->query("ALTER TABLE master_tests CHANGE COLUMN test_name name VARCHAR(255) NOT NULL");
// master_controls: revert
$this->db->query("ALTER TABLE master_controls CHANGE COLUMN control_name name VARCHAR(255) NOT NULL");
// master_depts: revert
$this->db->query("ALTER TABLE master_depts CHANGE COLUMN dept_name name VARCHAR(255) NOT NULL");
}
}

View File

@ -8,108 +8,110 @@ class CmodQcSeeder extends Seeder
{ {
public function run() public function run()
{ {
// 1. Insert Departments (4 entries) $this->seedDepts();
$depts = [ $this->seedControls();
['name' => 'Chemistry'], $this->seedTests();
['name' => 'Hematology'], $this->seedControlTests();
['name' => 'Immunology'], $this->seedResults();
['name' => 'Urinalysis'], $this->seedResultComments();
];
$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 (6 entries - 2 per dept for first 3 depts)
$controls = [
['dept_id' => $deptIdMap[0], 'name' => 'QC Normal Chemistry', 'lot' => 'QC2024001', 'producer' => 'BioRad', 'exp_date' => '2025-12-31'],
['dept_id' => $deptIdMap[0], 'name' => 'QC High Chemistry', 'lot' => 'QC2024002', 'producer' => 'BioRad', 'exp_date' => '2025-12-31'],
['dept_id' => $deptIdMap[1], 'name' => 'QC Normal Hema', 'lot' => 'QC2024003', 'producer' => 'Streck', 'exp_date' => '2025-11-30'],
['dept_id' => $deptIdMap[1], 'name' => 'QC Low Hema', 'lot' => 'QC2024004', 'producer' => 'Streck', 'exp_date' => '2025-11-30'],
['dept_id' => $deptIdMap[2], 'name' => 'QC Normal Immuno', 'lot' => 'QC2024005', 'producer' => 'Roche', 'exp_date' => '2025-10-31'],
['dept_id' => $deptIdMap[3], 'name' => 'QC Normal Urine', 'lot' => 'QC2024006', 'producer' => 'Siemens', 'exp_date' => '2025-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], 'name' => 'Glucose', 'unit' => 'mg/dL', 'method' => 'GOD-PAP', 'cva' => 5, 'ba' => 3, 'tea' => 10],
['dept_id' => $deptIdMap[0], 'name' => 'Creatinine', 'unit' => 'mg/dL', 'method' => 'Jaffe', 'cva' => 4, 'ba' => 2, 'tea' => 8],
['dept_id' => $deptIdMap[0], 'name' => 'Urea Nitrogen', 'unit' => 'mg/dL', 'method' => 'UREASE', 'cva' => 5, 'ba' => 3, 'tea' => 12],
['dept_id' => $deptIdMap[0], 'name' => 'Cholesterol', 'unit' => 'mg/dL', 'method' => 'CHOD-PAP', 'cva' => 6, 'ba' => 4, 'tea' => 15],
['dept_id' => $deptIdMap[1], 'name' => 'WBC', 'unit' => 'x10^3/uL', 'method' => 'Impedance', 'cva' => 8, 'ba' => 5, 'tea' => 20],
['dept_id' => $deptIdMap[1], 'name' => 'RBC', 'unit' => 'x10^6/uL', 'method' => 'Impedance', 'cva' => 3, 'ba' => 2, 'tea' => 8],
['dept_id' => $deptIdMap[1], 'name' => 'Hemoglobin', 'unit' => 'g/dL', 'method' => 'Cyanmethemoglobin', 'cva' => 2, 'ba' => 1, 'tea' => 5],
['dept_id' => $deptIdMap[2], 'name' => 'TSH', 'unit' => 'mIU/L', 'method' => 'ECLIA', 'cva' => 10, 'ba' => 6, 'tea' => 25],
['dept_id' => $deptIdMap[2], 'name' => 'Free T4', 'unit' => 'ng/dL', 'method' => 'ECLIA', 'cva' => 8, 'ba' => 5, 'tea' => 20],
['dept_id' => $deptIdMap[3], 'name' => 'Urine Protein', 'unit' => 'mg/dL', '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')->get()->getResultArray();
// 5. Insert Results (50 entries - random values around mean)
$results = [];
$faker = \Faker\Factory::create();
$resultDate = date('2024-12-01');
// Pre-calculate control_test info for result generation
$ctInfo = [];
foreach ($ctRows as $ct) {
$key = $ct['control_id'] . '-' . $ct['test_id'];
$ctInfo[$key] = $ct['control_test_id'];
} }
protected function seedDepts()
{
$depts = [
['dept_name' => 'Chemistry'],
['dept_name' => 'Hematology'],
['dept_name' => 'Immunology'],
['dept_name' => 'Urinalysis'],
];
$this->db->table('master_depts')->insertBatch($depts);
}
protected function seedControls()
{
$controls = [
['dept_id' => 1, 'control_name' => 'QC Normal Chemistry', 'lot' => 'QC2024001', 'producer' => 'BioRad', 'exp_date' => '2025-12-31'],
['dept_id' => 1, 'control_name' => 'QC High Chemistry', 'lot' => 'QC2024002', 'producer' => 'BioRad', 'exp_date' => '2025-12-31'],
['dept_id' => 2, 'control_name' => 'QC Normal Hema', 'lot' => 'QC2024003', 'producer' => 'Streck', 'exp_date' => '2025-11-30'],
['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
['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'],
];
$this->db->table('master_controls')->insertBatch($controls);
}
protected function seedTests()
{
$tests = [
['dept_id' => 1, 'test_code' => 'GLU', 'test_name' => 'Glucose', 'test_unit' => 'mg/dL', 'test_method' => 'GOD-PAP', 'cva' => 5, 'ba' => 3, 'tea' => 10],
['dept_id' => 1, 'test_code' => 'CRE', 'test_name' => 'Creatinine', 'test_unit' => 'mg/dL', 'test_method' => 'Jaffe', 'cva' => 4, 'ba' => 2, 'tea' => 8],
['dept_id' => 1, 'test_code' => 'BUN', 'test_name' => 'Urea Nitrogen', 'test_unit' => 'mg/dL', 'test_method' => 'UREASE', 'cva' => 5, 'ba' => 3, 'tea' => 12],
['dept_id' => 1, 'test_code' => 'CHOL', 'test_name' => 'Cholesterol', 'test_unit' => 'mg/dL', 'test_method' => 'CHOD-PAP', 'cva' => 6, 'ba' => 4, 'tea' => 15],
['dept_id' => 2, 'test_code' => 'WBC', 'test_name' => 'WBC', 'test_unit' => 'x10^3/uL', 'test_method' => 'Impedance', 'cva' => 8, 'ba' => 5, 'tea' => 20],
['dept_id' => 2, 'test_code' => 'RBC', 'test_name' => 'RBC', 'test_unit' => 'x10^6/uL', 'test_method' => 'Impedance', 'cva' => 3, 'ba' => 2, 'tea' => 8],
['dept_id' => 2, 'test_code' => 'HGB', 'test_name' => 'Hemoglobin', 'test_unit' => 'g/dL', 'test_method' => 'Cyanmethemoglobin', 'cva' => 2, 'ba' => 1, 'tea' => 5],
['dept_id' => 3, 'test_code' => 'TSH', 'test_name' => 'TSH', 'test_unit' => 'mIU/L', 'test_method' => 'ECLIA', 'cva' => 10, 'ba' => 6, 'tea' => 25],
['dept_id' => 3, 'test_code' => 'FT4', 'test_name' => 'Free T4', 'test_unit' => 'ng/dL', 'test_method' => 'ECLIA', 'cva' => 8, 'ba' => 5, 'tea' => 20],
['dept_id' => 4, 'test_code' => 'UP', 'test_name' => 'Urine Protein', 'test_unit' => 'mg/dL', 'test_method' => 'Dipstick', 'cva' => 10, 'ba' => 8, 'tea' => 30],
];
$this->db->table('master_tests')->insertBatch($tests);
}
protected function seedControlTests()
{
$controlTests = [
['control_id' => 1, 'test_id' => 1, 'mean' => 95, 'sd' => 5],
['control_id' => 1, 'test_id' => 2, 'mean' => 1.0, 'sd' => 0.05],
['control_id' => 1, 'test_id' => 3, 'mean' => 15, 'sd' => 1.2],
['control_id' => 2, 'test_id' => 1, 'mean' => 180, 'sd' => 12],
['control_id' => 2, 'test_id' => 2, 'mean' => 2.5, 'sd' => 0.15],
['control_id' => 2, 'test_id' => 4, 'mean' => 200, 'sd' => 15],
['control_id' => 3, 'test_id' => 5, 'mean' => 7.5, 'sd' => 0.6],
['control_id' => 3, 'test_id' => 6, 'mean' => 4.8, 'sd' => 0.2],
['control_id' => 3, 'test_id' => 7, 'mean' => 14.5, 'sd' => 0.5],
['control_id' => 4, 'test_id' => 5, 'mean' => 3.5, 'sd' => 0.3],
['control_id' => 4, 'test_id' => 6, 'mean' => 2.5, 'sd' => 0.15],
['control_id' => 5, 'test_id' => 8, 'mean' => 2.5, 'sd' => 0.3],
['control_id' => 5, 'test_id' => 9, 'mean' => 1.2, 'sd' => 0.1],
['control_id' => 6, 'test_id' => 10, 'mean' => 10, 'sd' => 1.5],
['control_id' => 1, 'test_id' => 4, 'mean' => 150, 'sd' => 10],
// New control-tests for January 2026
['control_id' => 7, 'test_id' => 1, 'mean' => 90, 'sd' => 4], // Trulab N - Glucose
['control_id' => 7, 'test_id' => 2, 'mean' => 0.9, 'sd' => 0.04], // Trulab N - Creatinine
['control_id' => 7, 'test_id' => 4, 'mean' => 145, 'sd' => 8], // Trulab N - Cholesterol
['control_id' => 8, 'test_id' => 1, 'mean' => 175, 'sd' => 10], // Trulab P - Glucose
['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
];
$this->db->table('control_tests')->insertBatch($controlTests);
}
protected function seedResults()
{
$faker = \Faker\Factory::create();
$resultDate = '2026-01-01';
$results = [];
$controlTests = $this->db->table('control_tests')->get()->getResultArray();
$resultCount = 0; $resultCount = 0;
foreach ($ctRows as $ct) {
// Generate 3-4 results per control-test foreach ($controlTests as $ct) {
$numResults = $faker->numberBetween(3, 4); $numResults = $faker->numberBetween(3, 4);
for ($i = 0; $i < $numResults && $resultCount < 50; $i++) { for ($i = 0; $i < $numResults && $resultCount < 50; $i++) {
// Generate random date within December 2024
$resDate = date('Y-m-d', strtotime($resultDate . ' +' . $faker->numberBetween(0, 20) . ' days')); $resDate = date('Y-m-d', strtotime($resultDate . ' +' . $faker->numberBetween(0, 20) . ' days'));
$value = $ct['mean'] + ($faker->randomFloat(2, -2.5, 2.5) * $ct['sd']);
// Get mean/sd for value generation
$mean = $this->db->table('control_tests')
->where('control_test_id', $ct['control_test_id'])
->get()
->getRowArray()['mean'] ?? 100;
$sd = $this->db->table('control_tests')
->where('control_test_id', $ct['control_test_id'])
->get()
->getRowArray()['sd'] ?? 5;
// Generate value within +/- 2-3 SD
$value = $mean + ($faker->randomFloat(2, -2.5, 2.5) * $sd);
$results[] = [ $results[] = [
'control_id' => $ct['control_id'], 'control_id' => $ct['control_id'],
'test_id' => $ct['test_id'], 'test_id' => $ct['test_id'],
'res_date' => $resDate, 'res_date' => $resDate,
'res_value' => round($value, 2), 'res_value' => round($value, 2),
'res_comment' => null,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]; ];
@ -117,21 +119,59 @@ class CmodQcSeeder extends Seeder
} }
} }
$this->db->table('results')->insertBatch($results); $this->db->table('results')->insertBatch($results);
$resultIds = $this->db->table('results')->select('result_id, control_id, test_id, res_date')->get()->getResultArray(); }
// 6. Insert Result Comments (10 entries - monthly comments for various control-test combos) protected function seedResultComments()
$resultComments = [ {
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[0], 'comment_month' => '2024-12', 'com_text' => 'Slight drift observed, instrument recalibrated on 12/15'], // Get all results to associate comments with specific results
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[3], 'comment_month' => '2024-12', 'com_text' => 'High cholesterol values noted, lot change recommended'], $results = $this->db->table('results')->get()->getResultArray();
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[4], 'comment_month' => '2024-12', 'com_text' => 'WBC controls stable throughout the month'],
['control_id' => $controlIdMap[3], 'test_id' => $testIdMap[5], 'comment_month' => '2024-12', 'com_text' => 'RBC QC intermittent shift, probe cleaned'], if (empty($results)) {
['control_id' => $controlIdMap[4], 'test_id' => $testIdMap[7], 'comment_month' => '2024-12', 'com_text' => 'TSH assay maintenance performed on 12/10'], return;
['control_id' => $controlIdMap[5], 'test_id' => $testIdMap[9], 'comment_month' => '2024-12', 'com_text' => 'Urine protein controls within range'], }
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[1], 'comment_month' => '2024-12', 'com_text' => 'Creatinine QC stable, no issues'],
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[0], 'comment_month' => '2024-12', 'com_text' => 'Glucose high QC showed consistent elevation, reagent lot changed'], // Map control_id + test_id to result_ids
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[6], 'comment_month' => '2024-12', 'com_text' => 'Hemoglobin QC performance acceptable'], $resultMap = [];
['control_id' => $controlIdMap[4], 'test_id' => $testIdMap[8], 'comment_month' => '2024-12', 'com_text' => 'Free T4 calibration curve verified'], 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'],
];
$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
$comments[] = [
'result_id' => $resultMap[$key][0],
'comment_text' => $data['comment_text'],
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]; ];
$this->db->table('result_comments')->insertBatch($resultComments); }
}
$this->db->table('result_comments')->insertBatch($comments);
} }
} }

View File

@ -1,189 +0,0 @@
<?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

@ -8,6 +8,7 @@ class MasterTestsModel extends BaseModel {
protected $primaryKey = 'test_id'; protected $primaryKey = 'test_id';
protected $allowedFields = [ protected $allowedFields = [
'dept_id', 'dept_id',
'test_code',
'test_name', 'test_name',
'test_unit', 'test_unit',
'test_method', 'test_method',
@ -22,12 +23,30 @@ class MasterTestsModel extends BaseModel {
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
public function search($keyword = null) { public function search($keyword = null) {
$builder = $this->builder();
$builder->select('
master_tests.test_id as testId,
master_tests.test_code as testCode,
master_tests.test_name as testName,
master_tests.test_unit as testUnit,
master_tests.test_method as testMethod,
master_tests.cva,
master_tests.ba,
master_tests.tea,
master_depts.dept_name as deptName
');
$builder->join('master_depts', 'master_depts.dept_id = master_tests.dept_id', 'left');
$builder->where('master_tests.deleted_at', null);
if ($keyword) { if ($keyword) {
return $this->groupStart() $builder->groupStart()
->like('test_name', $keyword) ->like('master_tests.test_name', $keyword)
->groupEnd() ->groupEnd();
->findAll();
} }
return $this->findAll();
$builder->groupBy('master_tests.test_id, master_depts.dept_name');
$builder->orderBy('master_tests.test_name', 'ASC');
return $builder->get()->getResultArray();
} }
} }

View File

@ -3,7 +3,8 @@ namespace App\Models\Qc;
use App\Models\BaseModel; use App\Models\BaseModel;
class ControlTestsModel extends BaseModel { class ControlTestsModel extends BaseModel
{
protected $table = 'control_tests'; protected $table = 'control_tests';
protected $primaryKey = 'control_test_id'; protected $primaryKey = 'control_test_id';
protected $allowedFields = [ protected $allowedFields = [
@ -18,20 +19,44 @@ class ControlTestsModel extends BaseModel {
protected $useTimestamps = true; protected $useTimestamps = true;
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
public function search($keyword = null) { public function search($keyword = null)
{
$builder = $this->db->table($this->table . ' ct');
$builder->select('
ct.control_test_id,
ct.control_id,
ct.test_id,
ct.mean,
ct.sd,
c.control_name,
c.lot,
t.test_name,
t.test_unit
');
$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.deleted_at', null);
if ($keyword) { if ($keyword) {
return $this->groupStart() $builder->groupStart()
->like('mean', $keyword) ->like('c.control_name', $keyword)
->groupEnd() ->orLike('t.test_name', $keyword)
->findAll(); ->orLike('c.lot', $keyword)
->groupEnd();
} }
return $this->findAll();
$builder->orderBy('c.control_name', 'ASC');
$builder->orderBy('t.test_name', 'ASC');
$rows = $builder->get()->getResultArray();
return $this->snakeToCamelRecursive($rows);
} }
/** /**
* Get control-test with control and test details * Get control-test with control and test details
*/ */
public function getWithDetails(int $controlTestId): ?array { public function getWithDetails(int $controlTestId): ?array
{
$builder = $this->db->table('control_tests ct'); $builder = $this->db->table('control_tests ct');
$builder->select(' $builder->select('
ct.control_test_id as id, ct.control_test_id as id,
@ -55,7 +80,8 @@ class ControlTestsModel extends BaseModel {
/** /**
* Get tests for a control with QC parameters * Get tests for a control with QC parameters
*/ */
public function getByControl(int $controlId): array { public function getByControl(int $controlId): array
{
$builder = $this->db->table('control_tests ct'); $builder = $this->db->table('control_tests ct');
$builder->select(' $builder->select('
ct.control_test_id as id, ct.control_test_id as id,
@ -77,8 +103,10 @@ class ControlTestsModel extends BaseModel {
/** /**
* Get controls for a test with QC parameters * Get controls for a test with QC parameters
* Optionally filter by month to exclude expired controls
*/ */
public function getByTest(int $testId): array { public function getByTest(int $testId, ?string $month = null): array
{
$builder = $this->db->table('control_tests ct'); $builder = $this->db->table('control_tests ct');
$builder->select(' $builder->select('
ct.control_test_id as id, ct.control_test_id as id,
@ -88,12 +116,20 @@ class ControlTestsModel extends BaseModel {
ct.sd, ct.sd,
c.control_name as controlName, c.control_name as controlName,
c.lot, c.lot,
c.producer c.producer,
c.exp_date as expDate
'); ');
$builder->join('master_controls c', 'c.control_id = ct.control_id'); $builder->join('master_controls c', 'c.control_id = ct.control_id');
$builder->where('ct.test_id', $testId); $builder->where('ct.test_id', $testId);
$builder->where('ct.deleted_at', null); $builder->where('ct.deleted_at', null);
$builder->where('c.deleted_at', null); $builder->where('c.deleted_at', null);
// Filter out expired controls if month provided
if ($month) {
$monthEnd = $month . '-01';
$builder->where('c.exp_date >=', $monthEnd);
}
$builder->orderBy('c.control_name', 'ASC'); $builder->orderBy('c.control_name', 'ASC');
return $builder->get()->getResultArray(); return $builder->get()->getResultArray();
@ -102,7 +138,8 @@ class ControlTestsModel extends BaseModel {
/** /**
* Get by control and test * Get by control and test
*/ */
public function getByControlAndTest(int $controlId, int $testId): ?array { public function getByControlAndTest(int $controlId, int $testId): ?array
{
$builder = $this->db->table('control_tests ct'); $builder = $this->db->table('control_tests ct');
$builder->select(' $builder->select('
ct.control_test_id as id, ct.control_test_id as id,

View File

@ -7,10 +7,8 @@ class ResultCommentsModel extends BaseModel {
protected $table = 'result_comments'; protected $table = 'result_comments';
protected $primaryKey = 'result_comment_id'; protected $primaryKey = 'result_comment_id';
protected $allowedFields = [ protected $allowedFields = [
'control_id', 'result_id',
'test_id', 'comment_text',
'comment_month',
'com_text',
'created_at', 'created_at',
'updated_at', 'updated_at',
'deleted_at' 'deleted_at'
@ -21,8 +19,7 @@ class ResultCommentsModel extends BaseModel {
public function search($keyword = null) { public function search($keyword = null) {
if ($keyword) { if ($keyword) {
return $this->groupStart() return $this->groupStart()
->like('comment_month', $keyword) ->like('comment_text', $keyword)
->orLike('com_text', $keyword)
->groupEnd() ->groupEnd()
->findAll(); ->findAll();
} }
@ -30,38 +27,115 @@ class ResultCommentsModel extends BaseModel {
} }
/** /**
* Get comments by control, test and month * Get comments by result_id
*/ */
public function getByControlTestMonth(int $controlId, int $testId, string $month): ?array { public function getByResult(int $resultId): ?array {
return $this->where('control_id', $controlId) return $this->where('result_id', $resultId)
->where('test_id', $testId)
->where('comment_month', $month)
->where('deleted_at', null) ->where('deleted_at', null)
->first(); ->first();
} }
/** /**
* Get all comments for a test and month * Get all comments for a control+test combination (via results)
*/ */
public function getByTestMonth(int $testId, string $month): array { public function getByControlTest(int $controlId, int $testId): ?array {
return $this->where('test_id', $testId) // First get result IDs for this control+test
->where('comment_month', $month) $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) ->where('deleted_at', null)
->findAll(); ->findAll();
} }
/** /**
* Upsert comment (insert or update based on control/test/month) * 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 { public function upsertComment(array $data): int {
$existing = $this->where('control_id', $data['control_id']) if (!isset($data['result_id'])) {
->where('test_id', $data['test_id']) return 0;
->where('comment_month', $data['comment_month']) }
$existing = $this->where('result_id', $data['result_id'])
->where('deleted_at', null) ->where('deleted_at', null)
->first(); ->first();
if ($existing) { if ($existing) {
if (empty($data['com_text'])) { if (empty($data['comment_text'])) {
// If text is empty, soft delete // If text is empty, soft delete
$this->update($existing['result_comment_id'], ['deleted_at' => date('Y-m-d H:i:s')]); $this->update($existing['result_comment_id'], ['deleted_at' => date('Y-m-d H:i:s')]);
return $existing['result_comment_id']; return $existing['result_comment_id'];
@ -69,7 +143,7 @@ class ResultCommentsModel extends BaseModel {
$this->update($existing['result_comment_id'], $data); $this->update($existing['result_comment_id'], $data);
return $existing['result_comment_id']; return $existing['result_comment_id'];
} else { } else {
if (empty($data['com_text'])) { if (empty($data['comment_text'])) {
return 0; // Don't insert empty comments return 0; // Don't insert empty comments
} }
return $this->insert($data, true); return $this->insert($data, true);

View File

@ -11,7 +11,6 @@ class ResultsModel extends BaseModel {
'test_id', 'test_id',
'res_date', 'res_date',
'res_value', 'res_value',
'res_comment',
'created_at', 'created_at',
'updated_at', 'updated_at',
'deleted_at' 'deleted_at'
@ -40,8 +39,9 @@ class ResultsModel extends BaseModel {
r.test_id as testId, r.test_id as testId,
r.res_date as resDate, r.res_date as resDate,
r.res_value as resValue, r.res_value as resValue,
r.res_comment as resComment rc.comment_text as resComment
'); ');
$builder->join('result_comments rc', 'rc.result_id = r.result_id AND rc.deleted_at IS NULL', 'left');
$builder->where('r.res_date', $date); $builder->where('r.res_date', $date);
$builder->where('r.control_id', $controlId); $builder->where('r.control_id', $controlId);
$builder->where('r.deleted_at', null); $builder->where('r.deleted_at', null);
@ -60,8 +60,9 @@ class ResultsModel extends BaseModel {
r.test_id as testId, r.test_id as testId,
r.res_date as resDate, r.res_date as resDate,
r.res_value as resValue, r.res_value as resValue,
r.res_comment as resComment rc.comment_text as resComment
'); ');
$builder->join('result_comments rc', 'rc.result_id = r.result_id AND rc.deleted_at IS NULL', 'left');
$builder->where('r.test_id', $testId); $builder->where('r.test_id', $testId);
$builder->where('r.res_date >=', $month . '-01'); $builder->where('r.res_date >=', $month . '-01');
$builder->where('r.res_date <=', $month . '-31'); $builder->where('r.res_date <=', $month . '-31');
@ -79,8 +80,10 @@ class ResultsModel extends BaseModel {
$builder->select(' $builder->select('
r.result_id as id, r.result_id as id,
r.res_date as resDate, r.res_date as resDate,
r.res_value as resValue r.res_value as resValue,
rc.comment_text as resComment
'); ');
$builder->join('result_comments rc', 'rc.result_id = r.result_id AND rc.deleted_at IS NULL', 'left');
$builder->where('r.control_id', $controlId); $builder->where('r.control_id', $controlId);
$builder->where('r.test_id', $testId); $builder->where('r.test_id', $testId);
$builder->where('r.res_date >=', $month . '-01'); $builder->where('r.res_date >=', $month . '-01');

View File

@ -79,6 +79,7 @@
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Control</th> <th>Control</th>
<th>Lot</th>
<th>Test</th> <th>Test</th>
<th class="text-right">Value</th> <th class="text-right">Value</th>
<th>Range (Mean ± 2SD)</th> <th>Range (Mean ± 2SD)</th>
@ -90,6 +91,7 @@
<tr class="hover"> <tr class="hover">
<td x-text="row.resDate || '-'"></td> <td x-text="row.resDate || '-'"></td>
<td x-text="row.controlName || '-'"></td> <td x-text="row.controlName || '-'"></td>
<td x-text="row.lot || '-'"></td>
<td x-text="row.testName || '-'"></td> <td x-text="row.testName || '-'"></td>
<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>

View File

@ -28,7 +28,7 @@
x-model="date" x-model="date"
@change="fetchControls()" @change="fetchControls()"
:max="today" :max="today"
class="input input-bordered w-40"> class="input input-bordered input-sm w-40">
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@ -45,7 +45,7 @@
</label> </label>
<select x-model="selectedControl" <select x-model="selectedControl"
@change="fetchTests()" @change="fetchTests()"
class="select select-bordered w-64"> class="select select-bordered select-sm w-64">
<option value="">Select Control</option> <option value="">Select Control</option>
<template x-for="control in controls" :key="control.id"> <template x-for="control in controls" :key="control.id">
<option :value="control.id" x-text="control.controlName + ' (Lot: ' + control.lot + ')'"></option> <option :value="control.id" x-text="control.controlName + ' (Lot: ' + control.lot + ')'"></option>
@ -222,8 +222,7 @@ document.addEventListener('alpine:init', () => {
results.push({ results.push({
controlId: test.controlId, controlId: test.controlId,
testId: test.testId, testId: test.testId,
value: parseFloat(value), value: parseFloat(value)
comment: this.commentsData[test.testId] || null
}); });
} }
} }
@ -239,6 +238,15 @@ document.addEventListener('alpine:init', () => {
const json = await response.json(); const json = await response.json();
if (json.status === 'success') { if (json.status === 'success') {
// Save comments using the returned result IDs
const savedIds = json.data.savedIds || [];
for (const item of savedIds) {
const comment = this.commentsData[item.testId];
if (comment) {
await this.saveComment(item.resultId, comment);
}
}
// Show success notification // Show success notification
this.$dispatch('notify', { type: 'success', message: json.message }); this.$dispatch('notify', { type: 'success', message: json.message });
// Refresh data // Refresh data
@ -256,6 +264,23 @@ document.addEventListener('alpine:init', () => {
} }
}, },
async saveComment(resultId, commentText) {
try {
const response = await fetch(`${BASEURL}api/entry/comment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resultId: resultId,
comment: commentText
})
});
return response.json();
} catch (e) {
console.error('Failed to save comment:', e);
return { status: 'error', message: e.message };
}
},
updateResult(testId, value) { updateResult(testId, value) {
if (value === '') { if (value === '') {
delete this.resultsData[testId]; delete this.resultsData[testId];

View File

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

View File

@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" /> <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" /> <link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script> <script>
@ -76,9 +77,15 @@
</div> </div>
</nav> </nav>
<div class="flex-1">
<?= $this->renderSection('content') ?> <?= $this->renderSection('content') ?>
</div> </div>
<footer class="footer footer-center p-4 bg-base-300 text-base-content border-t border-base-300 mt-auto">
<p>&copy;<?= date('Y') ?> made by 5panda for PT.Summit</p>
</footer>
</div>
<div class="drawer-side z-40"> <div class="drawer-side z-40">
<label for="sidebar-drawer" class="drawer-overlay"></label> <label for="sidebar-drawer" class="drawer-overlay"></label>
<aside class="bg-base-300 border-r border-base-300 w-64 min-h-full flex flex-col"> <aside class="bg-base-300 border-r border-base-300 w-64 min-h-full flex flex-col">
@ -109,12 +116,19 @@
</a> </a>
</li> </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(), 'master/control') ? '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(), 'master/control') && !str_contains(uri_string(), 'master/control-tests') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full"
href="<?= base_url('/master/control') ?>"> href="<?= base_url('/master/control') ?>">
<i class="fa-solid fa-vial w-5"></i> <i class="fa-solid fa-vial w-5"></i>
Controls Controls
</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(), 'master/control-tests') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full"
href="<?= base_url('/master/control-tests') ?>">
<i class="fa-solid fa-link w-5"></i>
Control Tests
</a>
</li>
<li class="mt-6 mb-2 min-h-0"> <li class="mt-6 mb-2 min-h-0">
<p class="px-4 text-xs font-semibold opacity-40 uppercase tracking-wider">QC Operations</p> <p class="px-4 text-xs font-semibold opacity-40 uppercase tracking-wider">QC Operations</p>
@ -149,16 +163,6 @@
</li> </li>
</ul> </ul>
<div class="p-4 border-t border-base-300">
<div class="bg-base-100/50 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs opacity-60">Storage</span>
<span class="text-xs text-primary font-bold">68%</span>
</div>
<progress class="progress progress-primary w-full h-2" value="68" max="100"></progress>
<p class="text-xs opacity-50 mt-2">6.8 GB of 10 GB used</p>
</div>
</div>
</aside> </aside>
</div> </div>
</div> </div>

View File

@ -1,22 +1,24 @@
<dialog class="modal modal-bottom sm:modal-middle" :class="{ 'modal-open': showModal }"> <dialog class="modal modal-bottom sm:modal-middle" :class="{ 'modal-open': showModal }">
<div class="modal-box border border-base-300 shadow-2xl bg-base-100"> <div class="modal-box p-5 border border-base-300 shadow-2xl bg-base-100 max-w-sm">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2 text-base-content"> <h3 class="font-bold text-base mb-3 flex items-center gap-2 text-base-content">
<i class="fa-solid fa-vial text-primary"></i> <i class="fa-solid fa-vial text-primary text-sm"></i>
<span x-text="form.controlId ? 'Edit Control' : 'New Control'"></span> <span x-text="form.controlId ? 'Edit Control' : 'New Control'"></span>
</h3> </h3>
<div class="space-y-4"> <div class="space-y-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label py-1">
<span class="label-text font-medium text-base-content opacity-80">Control Name</span> <span class="label-text-alt font-semibold text-base-content/70">Control Name</span>
</label> </label>
<input <input type="text"
type="text" class="input input-bordered input-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50" :class="{'border-error': errors.controlName}" x-model="form.controlName" list="control-names-list"
:class="{'border-error': errors.controlName}" placeholder="Enter control name" />
x-model="form.controlName" <datalist id="control-names-list">
placeholder="Enter control name" <template x-for="name in uniqueControlNames" :key="name">
/> <option :value="name" x-text="name"></option>
</template>
</datalist>
<template x-if="errors.controlName"> <template x-if="errors.controlName">
<label class="label"> <label class="label">
<span class="label-text-alt text-error" x-text="errors.controlName"></span> <span class="label-text-alt text-error" x-text="errors.controlName"></span>
@ -24,52 +26,45 @@
</template> </template>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-3">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label py-1">
<span class="label-text font-medium text-base-content opacity-80">Lot Number</span> <span class="label-text-alt font-semibold text-base-content/70">Lot Number</span>
</label> </label>
<input <input type="text"
type="text" class="input input-bordered input-sm w-full focus:outline-none focus:ring-1 focus:ring-primary/40 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50" x-model="form.lot" placeholder="e.g., LOT12345" />
x-model="form.lot"
placeholder="e.g., LOT12345"
/>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label py-1">
<span class="label-text font-medium text-base-content opacity-80">Expiry Date</span> <span class="label-text-alt font-semibold text-base-content/70">Expiry Date</span>
</label> </label>
<input <input type="date"
type="date" class="input input-bordered input-sm w-full focus:outline-none focus:ring-1 focus:ring-primary/40 focus:border-primary bg-base-200 border-base-300 text-base-content"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content" x-model="form.expDate" />
x-model="form.expDate"
/>
</div> </div>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label py-1">
<span class="label-text font-medium text-base-content opacity-80">Producer</span> <span class="label-text-alt font-semibold text-base-content/70">Producer</span>
</label> </label>
<input <input type="text"
type="text" class="input input-bordered input-sm w-full focus:outline-none focus:ring-1 focus:ring-primary/40 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50" x-model="form.producer" list="producers-list" placeholder="Enter producer name" />
x-model="form.producer" <datalist id="producers-list">
placeholder="Enter producer name" <template x-for="producer in uniqueProducers" :key="producer">
/> <option :value="producer" x-text="producer"></option>
</template>
</datalist>
</div> </div>
</div> </div>
<div class="modal-action mt-6"> <div class="modal-action mt-5">
<button class="btn btn-ghost opacity-70" @click="closeModal()">Cancel</button> <button class="btn btn-sm btn-ghost opacity-70" @click="closeModal()">Cancel</button>
<button <button class="btn btn-sm btn-primary gap-2 shadow-md shadow-primary/20 font-medium"
class="btn btn-primary gap-2 shadow-lg shadow-primary/20 font-medium" :class="{'loading': loading}" @click="save()" :disabled="loading">
:class="{'loading': loading}"
@click="save()"
:disabled="loading"
>
<template x-if="!loading"> <template x-if="!loading">
<span><i class="fa-solid fa-save"></i> Save</span> <span><i class="fa-solid fa-save"></i> Save</span>
</template> </template>

View File

@ -1,101 +1,153 @@
<?= $this->extend("layout/main_layout"); ?> <?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?> <?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto" x-data="controls()"> <main class="flex-1 p-4 overflow-auto" x-data="controls()">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-4">
<div> <div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Controls</h1> <h1 class="text-xl font-bold text-base-content tracking-tight">Controls</h1>
<p class="text-sm mt-1 opacity-70">Manage QC control standards</p> <p class="text-xs mt-0.5 opacity-70">Manage QC control standards</p>
</div> </div>
<button <button
class="btn btn-sm gap-2 shadow-sm border-0 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-700 hover:to-blue-600 text-white transition-all duration-200" class="btn btn-sm gap-2 shadow-sm border-0 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-700 hover:to-blue-600 text-white transition-all duration-200"
@click="showForm()" @click="showForm()">
> <i class="fa-solid fa-plus text-xs"></i> New Control
<i class="fa-solid fa-plus"></i> New Control
</button> </button>
</div> </div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6"> <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-3 mb-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-2">
<div class="relative flex-1 max-w-md"> <div class="relative flex-1 max-w-md">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-sm"></i> <input type="text" placeholder="Search by name..."
<input class="input input-bordered input-sm w-full px-3 py-2 text-sm bg-base-200 border border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all"
type="text" x-model="keyword" @keyup.enter="fetchList()" />
placeholder="Search by name..."
class="w-full pl-10 pr-4 py-2.5 text-sm bg-base-200 border border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all"
x-model="keyword"
@keyup.enter="fetchList()"
/>
</div> </div>
<button <button class="btn btn-sm btn-neutral gap-2" @click="fetchList()">
class="px-4 py-2.5 text-sm font-medium bg-base-content text-base-100 rounded-lg hover:bg-base-content/90 transition-all duration-200 flex items-center gap-2"
@click="fetchList()"
>
<i class="fa-solid fa-magnifying-glass text-xs"></i> Search <i class="fa-solid fa-magnifying-glass text-xs"></i> Search
</button> </button>
</div> </div>
</div> </div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden"> <div class="space-y-4">
<template x-if="loading"> <template x-if="loading && !list">
<div class="p-8 text-center"> <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-8 text-center">
<span class="loading loading-spinner loading-lg text-primary"></span> <span class="loading loading-spinner loading-md text-primary"></span>
<p class="mt-2 text-base-content/60">Loading...</p> <p class="mt-2 text-base-content/60 text-xs font-medium">Fetching controls...</p>
</div> </div>
</template> </template>
<template x-if="!loading && error"> <template x-if="!loading && error">
<div class="p-8 text-center"> <div class="bg-base-100 rounded-xl border border-error/20 shadow-sm p-8 text-center">
<i class="fa-solid fa-triangle-exclamation text-4xl text-error mb-2"></i> <div
<p class="text-error" x-text="error"></p> class="w-12 h-12 bg-error/10 text-error rounded-full flex items-center justify-center mx-auto mb-3">
<i class="fa-solid fa-triangle-exclamation text-xl"></i>
</div>
<h3 class="font-bold text-base-content">Something went wrong</h3>
<p class="text-error/80 mt-0.5 text-xs" x-text="error"></p>
<button @click="fetchList()" class="btn btn-sm btn-ghost mt-3 border border-base-300">Try Again</button>
</div> </div>
</template> </template>
<template x-if="!loading && !error && list"> <template x-if="!loading && !error && list">
<div class="space-y-4">
<template x-for="(group, name) in groupedList" :key="name">
<div
class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden transition-all hover:shadow-md">
<div
class="p-3 bg-base-200/40 border-b border-base-300 flex flex-wrap justify-between items-center gap-3">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<i class="fa-solid fa-vial text-base"></i>
</div>
<div>
<h3 class="font-bold text-sm text-base-content leading-tight" x-text="name"></h3>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[10px] flex items-center gap-1.5 text-base-content/60">
<i class="fa-solid fa-industry opacity-50"></i>
<span x-text="group.producer || 'No producer info'"></span>
</span>
<span class="w-1 h-1 rounded-full bg-base-300"></span>
<span class="text-[10px] font-medium text-primary"
x-text="group.lots.length + ' Lot(s)'"></span>
</div>
</div>
</div>
<button class="btn btn-sm btn-primary gap-1.5 px-3" @click="addLotToControl(group)">
<i class="fa-solid fa-plus text-[10px]"></i> Add Lot
</button>
</div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm text-left"> <table class="w-full text-sm">
<thead class="uppercase tracking-wider font-semibold bg-base-200 text-base-content/70 text-xs"> <thead>
<tr> <tr class="bg-base-100 text-left border-b border-base-300">
<th class="py-3 px-5 font-semibold">Control Name</th> <th
<th class="py-3 px-5 font-semibold">Lot Number</th> class="py-2 px-4 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">
<th class="py-3 px-5 font-semibold">Producer</th> Lot Number</th>
<th class="py-3 px-5 font-semibold">Expiry Date</th> <th
<th class="py-3 px-5 font-semibold text-right">Action</th> class="py-2 px-3 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">
Expiry Date</th>
<th
class="py-2 px-3 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">
Status</th>
<th
class="py-2 px-4 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider text-right">
Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-base-content/80 divide-y divide-base-300"> <tbody class="divide-y divide-base-200">
<template x-for="item in list" :key="item.controlId"> <template x-for="lot in group.lots" :key="lot.controlId">
<tr class="hover:bg-base-200 transition-colors"> <tr class="hover:bg-base-200/30 transition-colors group">
<td class="py-3 px-5 font-medium text-base-content" x-text="item.controlName"></td> <td class="py-2 px-4">
<td class="py-3 px-5"> <span
<span class="font-mono text-xs bg-base-200 text-base-content/70 px-2 py-1 rounded" x-text="item.lot"></span> class="font-mono text-[10px] bg-base-200 text-base-content/70 px-1.5 py-0.5 rounded border border-base-300"
x-text="lot.lot"></span>
</td> </td>
<td class="py-3 px-5" x-text="item.producer"></td> <td class="py-2 px-3 text-base-content/80 text-xs font-medium"
<td class="py-3 px-5" x-text="item.expDate"></td> x-text="lot.expDate">
<td class="py-3 px-5 text-right"> </td>
<td class="py-2 px-3">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider"
:class="getStatusBadgeClass(lot.expDate)">
<span class="w-1 h-1 rounded-full"
:class="getStatusDotClass(lot.expDate)"></span>
<span x-text="getStatusLabel(lot.expDate)"></span>
</span>
</td>
<td class="py-2 px-4 text-right">
<div class="flex justify-end items-center gap-1">
<button <button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors" class="p-1.5 text-amber-600 hover:bg-amber-50 rounded transition-colors tooltip tooltip-left"
@click="showForm(item.controlId)" data-tip="Edit Lot" @click="showForm(lot.controlId)">
> <i class="fa-solid fa-pencil text-xs"></i>
<i class="fa-solid fa-pencil"></i> Edit
</button> </button>
<button <button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-error bg-error/10 hover:bg-error/20 rounded-lg transition-colors ml-1" class="p-1.5 text-error hover:bg-error/10 rounded transition-colors tooltip tooltip-left"
@click="deleteData(item.controlId)" data-tip="Delete Lot" @click="deleteData(lot.controlId)">
> <i class="fa-solid fa-trash text-xs"></i>
<i class="fa-solid fa-trash"></i>
</button> </button>
</div>
</td> </td>
</tr> </tr>
</template> </template>
<template x-if="list.length === 0">
<tr>
<td colspan="5" class="py-8 text-center text-base-content/60">No data available</td>
</tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</template>
<template x-if="list.length === 0">
<div class="bg-base-100 rounded-xl border border-dashed border-base-300 p-8 text-center">
<div
class="w-12 h-12 bg-base-200 text-base-content/30 rounded-full flex items-center justify-center mx-auto mb-3">
<i class="fa-solid fa-box-open text-xl"></i>
</div>
<h3 class="font-bold text-base-content/70">No data found</h3>
<p class="text-base-content/50 mt-0.5 text-xs">Try adjusting your search keyword or add a new
control.</p>
</div>
</template>
</div>
</template> </template>
</div> </div>
@ -121,6 +173,61 @@
expDate: "", expDate: "",
}, },
get groupedList() {
if (!this.list) return {};
const groups = {};
this.list.forEach(item => {
if (!groups[item.controlName]) {
groups[item.controlName] = {
name: item.controlName,
producer: item.producer,
lots: []
};
}
groups[item.controlName].lots.push(item);
});
return groups;
},
get uniqueControlNames() {
if (!this.list) return [];
return [...new Set(this.list.map(i => i.controlName))];
},
get uniqueProducers() {
if (!this.list) return [];
return [...new Set(this.list.map(i => i.producer))];
},
getStatusLabel(date) {
if (!date) return 'Unknown';
const exp = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (exp < today) return 'Expired';
const diffTime = exp - today;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 30) return 'Expiring Soon';
return 'Active';
},
getStatusBadgeClass(date) {
const status = this.getStatusLabel(date);
if (status === 'Expired') return 'bg-error/10 text-error border border-error/20';
if (status === 'Expiring Soon') return 'bg-warning/10 text-warning-content border border-warning/20';
return 'bg-success/10 text-success border border-success/20';
},
getStatusDotClass(date) {
const status = this.getStatusLabel(date);
if (status === 'Expired') return 'bg-error';
if (status === 'Expiring Soon') return 'bg-warning';
return 'bg-success';
},
init() { init() {
this.fetchList(); this.fetchList();
}, },
@ -173,6 +280,18 @@
} }
}, },
async addLotToControl(group) {
this.showModal = true;
this.errors = {};
this.form = {
controlId: null,
controlName: group.name,
lot: "",
producer: group.producer,
expDate: ""
};
},
closeModal() { closeModal() {
this.showModal = false; this.showModal = false;
this.form = { controlId: null, controlName: "", lot: "", producer: "", expDate: "" }; this.form = { controlId: null, controlName: "", lot: "", producer: "", expDate: "" };

View File

@ -0,0 +1,65 @@
<dialog class="modal modal-bottom sm:modal-middle" :class="{ 'modal-open': showModal }">
<div class="modal-box border border-base-300 shadow-xl bg-base-100 max-w-md p-0 overflow-hidden">
<div class="px-6 py-4 bg-base-200/50 border-b border-base-300 flex items-center justify-between">
<h3 class="font-bold text-base flex items-center gap-2">
<i class="fa-solid fa-link text-primary text-sm"></i>
<span x-text="form.controlTestId ? 'Edit Association' : 'New Association'"></span>
</h3>
<button class="btn btn-sm btn-circle btn-ghost" @click="closeModal()"></button>
</div>
<div class="p-6 space-y-4">
<div class="form-control">
<label class="label pt-0">
<span class="label-text font-semibold text-xs opacity-70">Control Product</span>
</label>
<select class="select select-bordered select-sm w-full" x-model="form.controlId"
:class="{'select-error': errors.controlId}">
<option value="">Select control...</option>
<template x-for="c in controls" :key="c.controlId">
<option :value="c.controlId" x-text="`${c.controlName} (${c.lot})`"></option>
</template>
</select>
</div>
<div class="form-control">
<label class="label pt-0">
<span class="label-text font-semibold text-xs opacity-70">Test Parameter</span>
</label>
<select class="select select-bordered select-sm w-full" x-model="form.testId"
:class="{'select-error': errors.testId}">
<option value="">Select test...</option>
<template x-for="t in tests" :key="t.testId">
<option :value="t.testId" x-text="t.testName"></option>
</template>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label pt-0">
<span class="label-text font-semibold text-xs opacity-70">Mean (Target)</span>
</label>
<input type="number" step="0.0001" class="input input-bordered input-sm w-full" x-model="form.mean"
:class="{'input-error': errors.mean}" />
</div>
<div class="form-control">
<label class="label pt-0">
<span class="label-text font-semibold text-xs opacity-70">Standard Dev.</span>
</label>
<input type="number" step="0.0001" class="input input-bordered input-sm w-full" x-model="form.sd"
:class="{'input-error': errors.sd}" />
</div>
</div>
</div>
<div class="px-6 py-4 bg-base-200/30 border-t border-base-300 flex justify-end gap-2">
<button class="btn btn-sm btn-ghost" @click="closeModal()">Cancel</button>
<button class="btn btn-sm btn-primary px-6" @click="save()" :disabled="loading">
<span x-show="!loading">Save Changes</span>
<span x-show="loading" class="loading loading-spinner loading-xs"></span>
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop bg-black/40" @click="closeModal()"></form>
</dialog>

View File

@ -0,0 +1,307 @@
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-4 lg:p-6 overflow-auto" x-data="controlTests()">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-xl font-bold text-base-content tracking-tight">Control Tests</h1>
<p class="text-xs mt-1 opacity-60">Manage QC parameters for control-test associations</p>
</div>
<button class="btn btn-sm btn-primary gap-2" @click="showForm()">
<i class="fa-solid fa-plus"></i> New Association
</button>
</div>
<!-- Tools & Actions -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div class="flex items-center gap-2">
<div class="relative w-full sm:w-64">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 opacity-40 text-xs"></i>
<input type="text" placeholder="Search associations..."
class="input input-bordered input-sm w-full pl-9" x-model="keyword"
@input.debounce.300ms="fetchList()" />
</div>
<div class="flex border border-base-300 rounded-lg overflow-hidden bg-base-100 p-0.5">
<button class="btn btn-xs btn-ghost hover:bg-base-200 px-2" @click="expandAll()">Expand All</button>
<div class="w-px bg-base-300 mx-0.5"></div>
<button class="btn btn-xs btn-ghost hover:bg-base-200 px-2" @click="collapseAll()">Collapse All</button>
</div>
</div>
</div>
<!-- Grouped List -->
<div class="space-y-4">
<template x-if="loading && list.length === 0">
<div class="py-12 text-center">
<span class="loading loading-spinner loading-md text-primary"></span>
</div>
</template>
<template x-if="!loading && list.length === 0">
<div class="py-12 text-center opacity-40">
<i class="fa-solid fa-inbox text-4xl mb-2"></i>
<p>No records found</p>
</div>
</template>
<!-- Product Groups -->
<template x-for="(lots, productName) in nestedList" :key="productName">
<div
class="bg-base-100 rounded-2xl border border-base-300 shadow-sm overflow-hidden border-l-4 border-l-primary">
<!-- Product Header -->
<div class="px-6 py-4 bg-base-200/30 flex items-center justify-between border-b border-base-300">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-primary text-primary-content flex items-center justify-center shadow-md">
<i class="fa-solid fa-boxes-stacked"></i>
</div>
<div>
<h3 class="font-bold text-base text-base-content" x-text="productName"></h3>
<p class="text-[10px] opacity-60 uppercase font-semibold tracking-wider"
x-text="Object.keys(lots).length + ' lot(s) active'"></p>
</div>
</div>
</div>
<!-- Lots Container -->
<div class="p-4 bg-base-100 space-y-4">
<template x-for="(lotData, lotName) in lots" :key="lotName">
<div class="border border-base-300 rounded-xl overflow-hidden bg-base-200/20">
<!-- Lot Header -->
<div class="px-4 py-2 bg-base-200/50 flex items-center justify-between cursor-pointer hover:bg-base-200/80 transition-colors"
@click="toggleLot(productName, lotName)">
<div class="flex items-center gap-3">
<i class="fa-solid fa-chevron-right text-[10px] opacity-40 transition-transform"
:class="isLotOpen(productName, lotName) ? 'rotate-90' : ''"></i>
<span class="text-xs font-bold text-base-content/80">
Lot: <span class="badge badge-sm badge-outline border-base-300"
x-text="lotName"></span>
</span>
</div>
<button class="btn btn-xs btn-ghost text-primary gap-1"
@click.stop="showForm(null, productName, lotName)">
<i class="fa-solid fa-plus text-[10px]"></i> Add Parameter
</button>
</div>
<!-- Table Section -->
<div x-show="isLotOpen(productName, lotName)" x-collapse>
<div class="overflow-x-auto">
<table class="table table-sm w-full divide-y divide-base-300">
<thead>
<tr class="bg-base-200/30 text-[10px] opacity-50 uppercase">
<th class="pl-12">Test Name</th>
<th class="text-right">Target Mean</th>
<th class="text-right">Target SD</th>
<th class="text-right pr-4">Actions</th>
</tr>
</thead>
<tbody class="bg-base-100 divide-y divide-base-200">
<template x-for="item in lotData.tests" :key="item.controlTestId">
<tr class="hover:bg-primary/5 group/row">
<td class="pl-12 font-medium text-xs py-2" x-text="item.testName">
</td>
<td class="text-right font-mono text-xs"
x-text="formatNumber(item.mean)"></td>
<td class="text-right font-mono text-xs"
x-text="formatNumber(item.sd)"></td>
<td class="text-right pr-4">
<div
class="flex justify-end gap-1 opacity-0 group-hover/row:opacity-100 transition-opacity">
<button
class="btn btn-xs btn-square btn-ghost text-amber-600"
@click="showForm(item.controlTestId)">
<i class="fa-solid fa-pencil text-[10px]"></i>
</button>
<button class="btn btn-xs btn-square btn-ghost text-error"
@click="deleteData(item.controlTestId)">
<i class="fa-solid fa-trash text-[10px]"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
<?= $this->include('master/control_test/dialog_control_test_form'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script"); ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("controlTests", () => ({
loading: false,
showModal: false,
errors: {},
error: null,
keyword: "",
list: [],
controls: [],
tests: [],
openLots: [], // Store as "ProductName:LotName"
form: {
controlTestId: null,
controlId: "",
testId: "",
mean: "",
sd: "",
},
get nestedList() {
return this.list.reduce((acc, item) => {
const prod = item.controlName || 'Unassigned';
const lot = item.lot || 'No Lot';
if (!acc[prod]) acc[prod] = {};
if (!acc[prod][lot]) acc[prod][lot] = { tests: [], controlId: item.controlId };
acc[prod][lot].tests.push(item);
return acc;
}, {});
},
formatNumber(value) {
return value ? parseFloat(value).toFixed(4) : '-';
},
async init() {
await Promise.all([this.fetchDropdowns(), this.fetchList()]);
},
isLotOpen(prod, lot) {
return this.openLots.includes(`${prod}:${lot}`);
},
toggleLot(prod, lot) {
const key = `${prod}:${lot}`;
if (this.isLotOpen(prod, lot)) {
this.openLots = this.openLots.filter(k => k !== key);
} else {
this.openLots.push(key);
}
},
expandAll() {
const keys = [];
for (const prod in this.nestedList) {
for (const lot in this.nestedList[prod]) {
keys.push(`${prod}:${lot}`);
}
}
this.openLots = keys;
},
collapseAll() {
this.openLots = [];
},
async fetchDropdowns() {
try {
const [cRes, tRes] = await Promise.all([
fetch(`${BASEURL}api/master/controls`).then(r => r.json()),
fetch(`${BASEURL}api/master/tests`).then(r => r.json())
]);
this.controls = cRes.data || [];
this.tests = tRes.data || [];
} catch (err) {
console.error("Failed to load dropdowns", err);
}
},
async fetchList() {
this.loading = true;
this.error = null;
try {
const params = new URLSearchParams({ keyword: this.keyword });
const res = await fetch(`${BASEURL}api/qc/control-tests?${params}`);
if (!res.ok) throw new Error("Load failed");
const data = await res.json();
this.list = data.data || [];
if (this.keyword.length > 0) this.expandAll();
else if (this.list.length > 0) {
// Open first lot of first product by default
const firstProd = Object.keys(this.nestedList)[0];
const firstLot = Object.keys(this.nestedList[firstProd])[0];
this.openLots = [`${firstProd}:${firstLot}`];
}
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
async showForm(id = null, prodName = null, lotName = null) {
this.errors = {};
if (id) {
const item = this.list.find(i => i.controlTestId === id);
this.form = { ...item };
} else {
this.form = { controlTestId: null, controlId: "", testId: "", mean: "", sd: "" };
if (prodName && lotName) {
const ctrl = this.controls.find(c => c.controlName === prodName && c.lot === lotName);
if (ctrl) this.form.controlId = ctrl.controlId;
}
}
this.showModal = true;
},
closeModal() {
this.showModal = false;
},
async save() {
this.errors = {};
if (!this.form.controlId) this.errors.controlId = "Required";
if (!this.form.testId) this.errors.testId = "Required";
if (!this.form.mean) this.errors.mean = "Required";
if (!this.form.sd) this.errors.sd = "Required";
if (Object.keys(this.errors).length > 0) return;
this.loading = true;
const isEdit = !!this.form.controlTestId;
const url = `${BASEURL}api/qc/control-tests${isEdit ? '/' + this.form.controlTestId : ''}`;
try {
const res = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const data = await res.json();
if (data.status === 'success') {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Save failed");
}
} catch (err) {
alert("Save failed");
} finally {
this.loading = false;
}
},
async deleteData(id) {
if (!confirm("Delete association?")) return;
try {
const res = await fetch(`${BASEURL}api/qc/control-tests/${id}`, { method: "DELETE" });
const data = await res.json();
if (data.status === 'success') this.fetchList();
} catch (err) {
alert("Delete failed");
}
}
}));
});
</script>
<?= $this->endSection(); ?>

View File

@ -11,7 +11,7 @@
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50" class="input input-bordered input-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
:class="{'border-error': errors.deptName}" :class="{'border-error': errors.deptName}"
x-model="form.deptName" x-model="form.deptName"
placeholder="Enter department name" placeholder="Enter department name"

View File

@ -19,7 +19,7 @@
<div class="relative flex-1 max-w-md"> <div class="relative flex-1 max-w-md">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-sm"></i> <i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-sm"></i>
<input type="text" placeholder="Search by name..." <input type="text" placeholder="Search by name..."
class="w-full pl-10 pr-4 py-2.5 text-sm bg-base-200 border border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all" class="input input-bordered input-sm w-full pl-10 pr-4 py-2.5 text-sm bg-base-200 border border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all"
x-model="keyword" @keyup.enter="fetchList()" /> x-model="keyword" @keyup.enter="fetchList()" />
</div> </div>
<button <button

View File

@ -6,13 +6,25 @@
</h3> </h3>
<div class="space-y-4"> <div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Test Code</span>
</label>
<input
type="text"
class="input input-bordered input-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
x-model="form.testCode"
placeholder="Enter test code"
/>
</div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text font-medium text-base-content opacity-80">Test Name</span> <span class="label-text font-medium text-base-content opacity-80">Test Name</span>
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50" class="input input-bordered input-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
:class="{'border-error': errors.testName}" :class="{'border-error': errors.testName}"
x-model="form.testName" x-model="form.testName"
placeholder="Enter test name" placeholder="Enter test name"
@ -31,7 +43,7 @@
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50" class="input input-bordered input-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
x-model="form.testUnit" x-model="form.testUnit"
placeholder="e.g., mg/dL" placeholder="e.g., mg/dL"
/> />
@ -43,7 +55,7 @@
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50" class="input input-bordered input-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
x-model="form.testMethod" x-model="form.testMethod"
placeholder="e.g., Enzymatic" placeholder="e.g., Enzymatic"
/> />
@ -58,7 +70,7 @@
<input <input
type="number" type="number"
step="0.01" step="0.01"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50" class="input input-bordered input-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
x-model="form.cva" x-model="form.cva"
placeholder="0.00" placeholder="0.00"
/> />
@ -71,7 +83,7 @@
<input <input
type="number" type="number"
step="0.01" step="0.01"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50" class="input input-bordered input-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
x-model="form.ba" x-model="form.ba"
placeholder="0.00" placeholder="0.00"
/> />
@ -84,7 +96,7 @@
<input <input
type="number" type="number"
step="0.01" step="0.01"
class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50" class="input input-bordered input-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content placeholder:opacity-50"
x-model="form.tea" x-model="form.tea"
placeholder="0.00" placeholder="0.00"
/> />

View File

@ -22,7 +22,7 @@
<input <input
type="text" type="text"
placeholder="Search by name..." placeholder="Search by name..."
class="w-full pl-10 pr-4 py-2.5 text-sm bg-base-200 border border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all" class="input input-bordered input-sm w-full pl-10 pr-4 py-2.5 text-sm bg-base-200 border border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all"
x-model="keyword" x-model="keyword"
@keyup.enter="fetchList()" @keyup.enter="fetchList()"
/> />
@ -56,6 +56,7 @@
<table class="w-full text-sm text-left"> <table class="w-full text-sm text-left">
<thead class="uppercase tracking-wider font-semibold bg-base-200 text-base-content/70 text-xs"> <thead class="uppercase tracking-wider font-semibold bg-base-200 text-base-content/70 text-xs">
<tr> <tr>
<th class="py-3 px-5 font-semibold">Test Code</th>
<th class="py-3 px-5 font-semibold">Test Name</th> <th class="py-3 px-5 font-semibold">Test Name</th>
<th class="py-3 px-5 font-semibold">Unit</th> <th class="py-3 px-5 font-semibold">Unit</th>
<th class="py-3 px-5 font-semibold">Method</th> <th class="py-3 px-5 font-semibold">Method</th>
@ -65,6 +66,7 @@
<tbody class="text-base-content/80 divide-y divide-base-300"> <tbody class="text-base-content/80 divide-y divide-base-300">
<template x-for="item in list" :key="item.testId"> <template x-for="item in list" :key="item.testId">
<tr class="hover:bg-base-200 transition-colors"> <tr class="hover:bg-base-200 transition-colors">
<td class="py-3 px-5 font-medium text-base-content" x-text="item.testCode"></td>
<td class="py-3 px-5 font-medium text-base-content" x-text="item.testName"></td> <td class="py-3 px-5 font-medium text-base-content" x-text="item.testName"></td>
<td class="py-3 px-5" x-text="item.testUnit"></td> <td class="py-3 px-5" x-text="item.testUnit"></td>
<td class="py-3 px-5" x-text="item.testMethod"></td> <td class="py-3 px-5" x-text="item.testMethod"></td>
@ -86,7 +88,7 @@
</template> </template>
<template x-if="list.length === 0"> <template x-if="list.length === 0">
<tr> <tr>
<td colspan="4" class="py-8 text-center text-base-content/60">No data available</td> <td colspan="5" class="py-8 text-center text-base-content/60">No data available</td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
@ -111,6 +113,7 @@
list: null, list: null,
form: { form: {
testId: null, testId: null,
testCode: "",
testName: "", testName: "",
testUnit: "", testUnit: "",
testMethod: "", testMethod: "",
@ -167,13 +170,13 @@
if (id) { if (id) {
await this.loadData(id); await this.loadData(id);
} else { } else {
this.form = { testId: null, testName: "", testUnit: "", testMethod: "", cva: "", ba: "", tea: "" }; this.form = { testId: null, testCode: "", testName: "", testUnit: "", testMethod: "", cva: "", ba: "", tea: "" };
} }
}, },
closeModal() { closeModal() {
this.showModal = false; this.showModal = false;
this.form = { testId: null, testName: "", testUnit: "", testMethod: "", cva: "", ba: "", tea: "" }; this.form = { testId: null, testCode: "", testName: "", testUnit: "", testMethod: "", cva: "", ba: "", tea: "" };
}, },
validate() { validate() {

View File

@ -1,16 +1,613 @@
<?= $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="reportModule()">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6 no-print">
<div> <div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Reports</h1> <h1 class="text-2xl font-bold text-base-content tracking-tight">Monthly QC Report</h1>
<p class="text-sm mt-1 opacity-70">Generate and view QC reports</p> <p class="text-sm mt-1 opacity-70">View summary and Levey-Jennings charts for laboratory tests</p>
</div>
<div class="flex gap-2">
<button @click="window.print()" class="btn btn-outline btn-sm" :disabled="!selectedTest">
<i class="fa-solid fa-print mr-2"></i>
Print Report
</button>
</div> </div>
</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">Reports module coming soon...</p> <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6 no-print">
<div class="flex flex-wrap gap-6 items-center">
<div class="form-control">
<label class="label pt-0">
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Reporting Month</span>
</label>
<div class="join">
<button @click="prevMonth()" class="join-item btn btn-sm btn-outline border-base-300"><i
class="fa-solid fa-chevron-left"></i></button>
<input type="month" x-model="month" @change="onMonthChange()"
class="join-item input input-bordered input-sm w-36 text-center font-medium bg-base-200/30">
<button @click="nextMonth()" class="join-item btn btn-sm btn-outline border-base-300"><i
class="fa-solid fa-chevron-right"></i></button>
</div>
</div>
<div class="divider divider-horizontal mx-0 hidden sm:flex"></div>
<div class="form-control flex-1 max-w-xs">
<label class="label pt-0">
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Select Test</span>
</label>
<select x-model="selectedTest" @change="fetchData()"
class="select select-bordered select-sm w-full font-medium">
<option value="">Choose a test...</option>
<template x-for="test in tests" :key="test.testId">
<option :value="String(test.testId)" x-text="test.testName"></option>
</template>
</select>
</div>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex flex-col items-center justify-center py-24 gap-4">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-sm opacity-50 animate-pulse">Generating report...</p>
</div>
<!-- Empty State -->
<div x-show="!loading && !selectedTest"
class="bg-base-100 rounded-2xl border-2 border-dashed border-base-300 p-16 text-center">
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fa-solid fa-chart-bar text-2xl opacity-20"></i>
</div>
<h3 class="font-bold text-lg">No Test Selected</h3>
<p class="text-base-content/60 max-w-xs mx-auto">Please select a laboratory test to generate the monthly report.
</p>
</div>
<!-- Report Content -->
<div x-show="!loading && selectedTest && controls.length > 0" class="space-y-6 animate-in fade-in duration-500">
<!-- Report Header (Print only) -->
<div class="hidden print:block mb-8 border-b-2 border-base-content/20 pb-4">
<div class="flex justify-between items-end">
<div>
<h2 class="text-3xl font-black uppercase tracking-tighter" x-text="testName"></h2>
<p class="text-sm font-bold opacity-60" x-text="departmentName"></p>
</div>
<div class="text-right">
<p class="text-xl font-bold" x-text="monthDisplay"></p>
<p class="text-xs opacity-50">Report Generated: <?= date('d M Y H:i') ?></p>
</div>
</div>
</div>
<!-- summary stats -->
<div class="flex flex-wrap gap-3 mb-6 no-print-gap">
<template x-for="control in processedControls" :key="control.controlId">
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden flex flex-col w-full sm:w-[calc(50%-0.75rem)] lg:w-[calc(33.333%-0.75rem)] xl:w-[calc(25%-0.75rem)] print:w-[calc(33.333%-0.5rem)] print:text-[11px]">
<div class="bg-base-200/50 p-2 border-b border-base-300">
<div class="flex justify-between items-center gap-2">
<h3 class="font-bold truncate text-xs" x-text="control.controlName"></h3>
<span class="badge badge-xs badge-neutral shrink-0 print:scale-75" x-text="'Lot: ' + (control.lot || 'N/A')"></span>
</div>
</div>
<div class="p-2 flex-1 grid grid-cols-2 gap-2">
<div class="flex flex-col">
<span class="text-[9px] uppercase font-bold opacity-50">N</span>
<span class="text-sm font-mono font-bold" x-text="control.stats.n || 0"></span>
</div>
<div class="flex flex-col">
<span class="text-[9px] uppercase font-bold opacity-50">Mean</span>
<span class="text-sm font-mono font-bold text-primary"
x-text="formatNum(control.stats.mean)"></span>
</div>
<div class="flex flex-col">
<span class="text-[9px] uppercase font-bold opacity-50">SD</span>
<span class="text-sm font-mono font-bold" x-text="formatNum(control.stats.sd)"></span>
</div>
<div class="flex flex-col">
<span class="text-[9px] uppercase font-bold opacity-50">% CV</span>
<span class="text-sm font-mono font-bold"
:class="control.stats.cv > 5 ? 'text-warning' : 'text-success'"
x-text="formatNum(control.stats.cv, 1) + '%'"></span>
</div>
</div>
<div class="px-2 py-1 bg-base-200/30 text-[9px] flex justify-between border-t border-base-300 font-medium">
<span>Tgt: <span x-text="formatNum(control.mean)"></span>±<span x-text="formatNum(2*control.sd)"></span></span>
<span x-show="control.stats.mean" :class="getBiasClass(control)"
x-text="'B: ' + getBias(control) + '%'"></span>
</div>
</div>
</template>
</div>
<div class="flex flex-col xl:flex-row gap-6 items-start">
<!-- Monthly Table -->
<div class="w-full xl:w-80 print:w-72 shrink-0">
<div
class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden print:break-inside-avoid">
<div class="p-4 border-b border-base-300 bg-base-200/50 print:p-2">
<h3 class="font-bold text-sm uppercase tracking-widest opacity-70 print:text-[10px]">Daily
Results Log</h3>
</div>
<div class="overflow-x-auto">
<table class="table table-xs w-full print:table-xs">
<thead class="bg-base-200/50">
<tr>
<th class="w-10 text-center print:px-1">Day</th>
<template x-for="control in controls" :key="control.controlId">
<th class="text-center print:px-1 whitespace-normal break-words min-w-[60px] max-w-[100px] leading-tight" x-text="control.controlName"></th>
</template>
</tr>
</thead>
<tbody>
<template x-for="day in daysInMonth" :key="day">
<tr :class="isWeekend(day) ? 'bg-base-200/30' : ''">
<td class="text-center font-mono font-bold print:px-1" x-text="day"></td>
<template x-for="control in controls" :key="control.controlId">
<td class="text-center print:px-1">
<div class="flex flex-col gap-0.5">
<span class="font-mono text-[11px] print:text-[10px]"
:class="getValueClass(control, day)"
x-text="getResValue(control, day)"></span>
<span x-show="getResComment(control, day)"
class="text-[8px] opacity-40 italic block max-w-[80px] truncate mx-auto print:hidden"
:title="getResComment(control, day)"
x-text="getResComment(control, day)"></span>
</div>
</td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="flex-1 space-y-6 w-full">
<template x-for="control in processedControls" :key="'chart-'+control.controlId">
<div
class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 print:break-inside-avoid print:p-2">
<div class="flex justify-between items-center mb-4 print:mb-1">
<h3 class="font-bold text-sm opacity-70 uppercase tracking-widest print:text-[10px]"
x-text="'Levey-Jennings: ' + control.controlName"></h3>
<div class="flex gap-2 text-[10px] print:text-[8px]">
<span class="flex items-center gap-1"><span
class="w-2 h-2 rounded-full bg-success"></span>
In Range</span>
<span class="flex items-center gap-1"><span
class="w-2 h-2 rounded-full bg-error"></span>
Out Range</span>
</div>
</div>
<div class="h-64 w-full print:h-48">
<canvas :id="'chart-' + control.controlId"></canvas>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Error State -->
<div x-show="!loading && selectedTest && controls.length === 0"
class="bg-base-100 rounded-2xl border-2 border-dashed border-base-300 p-16 text-center">
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4 text-warning">
<i class="fa-solid fa-triangle-exclamation text-2xl"></i>
</div>
<h3 class="font-bold text-lg">No Data Found</h3>
<p class="text-base-content/60 max-w-xs mx-auto">There are no records found for this test and month. Please
check
the selection or enter data in the Entry module.</p>
</div> </div>
</main> </main>
<style>
@media print {
@page {
size: A4;
margin: 10mm;
}
body,
.drawer,
.drawer-content,
main,
.bg-base-100,
.bg-base-200,
.bg-base-300,
.navbar,
footer {
background-color: white !important;
background-image: none !important;
color: black !important;
}
.no-print,
.navbar,
footer,
.drawer-side,
.divider {
display: none !important;
}
main {
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
}
.shadow-sm,
.shadow,
.shadow-md,
.shadow-lg,
.shadow-xl,
.shadow-2xl {
box-shadow: none !important;
}
.border,
.border-base-300,
.rounded-xl,
.rounded-2xl {
border: 1px solid #ddd !important;
border-radius: 4px !important;
}
/* Side by side layout for print */
.flex-col.xl\:flex-row {
flex-direction: row !important;
gap: 10px !important;
align-items: flex-start !important;
}
.flex-1 {
flex: 1 1 0% !important;
}
.print\:w-72 {
width: 18rem !important;
}
.shrink-0 {
flex-shrink: 0 !important;
}
/* Condensed summary cards grid */
.grid.print\:grid-cols-3 {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 8px !important;
margin-bottom: 1rem !important;
}
/* Keep some semantic colors for data indicators but make them print-safe */
.text-success {
color: #065f46 !important;
}
.text-error {
color: #991b1b !important;
}
.text-warning {
color: #92400e !important;
}
.text-primary {
color: #570df8 !important;
}
canvas {
max-width: 100% !important;
height: auto !important;
}
.table-xs th, .table-xs td {
padding: 2px 4px !important;
font-size: 9px !important;
}
}
</style>
<?= $this->endSection(); ?>
<?= $this->section("script"); ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("reportModule", () => ({
tests: [],
selectedTest: '',
controls: [],
loading: false,
month: '',
charts: {},
lastRequestId: 0,
init() {
const now = new Date();
this.month = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
this.fetchTests();
},
async fetchTests() {
try {
const response = await fetch(`${BASEURL}api/master/tests`);
const json = await response.json();
this.tests = json.data || [];
} catch (e) {
console.error('Failed to fetch tests:', e);
}
},
onMonthChange() {
if (this.selectedTest) {
this.fetchData();
}
},
async fetchData() {
if (!this.selectedTest) return;
const requestId = ++this.lastRequestId;
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 (requestId !== this.lastRequestId) return;
if (json.status === 'success') {
this.controls = json.data.controls || [];
this.$nextTick(() => {
this.renderCharts();
});
}
} catch (e) {
if (requestId === this.lastRequestId) {
console.error('Failed to fetch data:', e);
}
} finally {
if (requestId === this.lastRequestId) {
this.loading = false;
}
}
},
get processedControls() {
return this.controls.map(c => {
const stats = this.calculateStats(c.results);
return { ...c, stats };
});
},
calculateStats(results) {
if (!results || typeof results !== 'object') return { n: 0, mean: null, sd: null, cv: null };
const values = Object.values(results)
.filter(r => r !== null && r !== undefined)
.map(r => parseFloat(r.resValue))
.filter(v => !isNaN(v));
if (values.length === 0) return { n: 0, mean: null, sd: null, cv: null };
const n = values.length;
const mean = values.reduce((a, b) => a + b, 0) / n;
const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (n > 1 ? n - 1 : 1);
const sd = Math.sqrt(variance);
const cv = mean !== 0 ? (sd / mean) * 100 : 0;
return { n, mean, sd, cv };
},
renderCharts() {
// Destroy existing charts
Object.values(this.charts).forEach(chart => chart.destroy());
this.charts = {};
this.processedControls.forEach(control => {
const ctx = document.getElementById('chart-' + control.controlId);
if (!ctx) return;
const days = this.daysInMonth;
const data = days.map(day => {
const res = control.results[day];
return res && res.resValue !== null ? parseFloat(res.resValue) : null;
});
const targetMean = control.mean !== null ? parseFloat(control.mean) : null;
const targetSD = control.sd !== null ? parseFloat(control.sd) : null;
const datasets = [
{
label: control.controlName,
data: data,
borderColor: '#570df8',
backgroundColor: '#570df820',
borderWidth: 2,
pointRadius: 4,
pointBackgroundColor: data.map(v => {
if (v === null || targetMean === null || targetSD === null) return '#570df8';
const dev = Math.abs(v - targetMean);
return dev > 2 * targetSD ? '#ff52d9' : '#570df8';
}),
spanGaps: true,
tension: 0.1
}
];
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: days,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (context) {
let label = 'Value: ' + context.parsed.y;
if (targetMean !== null && targetSD !== null) {
const dev = (context.parsed.y - targetMean) / targetSD;
label += ` (${dev.toFixed(2)} SD)`;
}
return label;
}
}
}
},
animation: false,
scales: {
y: {
beginAtZero: false,
grid: {
color: (context) => {
if (targetMean === null || targetSD === null) return '#e5e7eb';
const val = context.tick.value;
if (Math.abs(val - targetMean) < 0.001) return '#10b98180'; // Mean line
if (Math.abs(Math.abs(val - targetMean) - 2 * targetSD) < 0.001) return '#f59e0b40'; // 2SD
if (Math.abs(Math.abs(val - targetMean) - 3 * targetSD) < 0.001) return '#ef444440'; // 3SD
return '#e5e7eb';
},
lineWidth: (context) => {
if (targetMean === null) return 1;
const val = context.tick.value;
if (Math.abs(val - targetMean) < 0.001) return 2;
return 1;
}
},
ticks: {
font: { family: 'monospace', size: 10 }
}
},
x: {
grid: { display: false },
ticks: { font: { family: 'monospace', size: 10 } }
}
}
}
});
if (targetMean !== null && targetSD !== null) {
const min = Math.min(...data.filter(v => v !== null), targetMean - 3.5 * targetSD);
const max = Math.max(...data.filter(v => v !== null), targetMean + 3.5 * targetSD);
chart.options.scales.y.min = min;
chart.options.scales.y.max = max;
// Force ticks at target mean and SD levels if possible,
// or just let Chart.js decide but we highlighted the grid lines.
}
chart.update();
this.charts[control.controlId] = chart;
});
},
get testName() {
const test = this.tests.find(t => t.testId == this.selectedTest);
return test ? test.testName : '';
},
get departmentName() {
const test = this.tests.find(t => t.testId == this.selectedTest);
// We don't have dept name in the test object from api/master/tests directly maybe?
// Let's assume it might be there or just show "Department QC Report"
return test && test.deptName ? test.deptName : 'Laboratory Department';
},
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 daysInMonth() {
if (!this.month) return [];
const [year, month] = this.month.split('-').map(Number);
const days = new Date(year, month, 0).getDate();
return Array.from({ length: days }, (_, i) => i + 1);
},
prevMonth() {
const [year, month] = this.month.split('-').map(Number);
const date = new Date(year, month - 2, 1);
this.month = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
this.onMonthChange();
},
nextMonth() {
const [year, month] = this.month.split('-').map(Number);
const date = new Date(year, month, 1);
this.month = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
this.onMonthChange();
},
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;
},
formatNum(val, dec = 2) {
if (val === null || val === undefined) return '-';
return parseFloat(val).toFixed(dec);
},
getBias(control) {
if (!control.stats.mean || !control.mean) return '-';
const bias = ((control.stats.mean - control.mean) / control.mean) * 100;
return bias > 0 ? '+' + bias.toFixed(1) : bias.toFixed(1);
},
getBiasClass(control) {
if (!control.stats.mean || !control.mean) return 'opacity-50';
const bias = Math.abs(((control.stats.mean - control.mean) / control.mean) * 100);
if (bias > 10) return 'text-error font-bold';
if (bias > 5) return 'text-warning font-bold';
return 'text-success font-bold';
},
getResValue(control, day) {
const res = control.results[day];
return res && res.resValue !== null ? res.resValue : '-';
},
getResComment(control, day) {
const res = control.results[day];
return res ? res.resComment : '';
},
getValueClass(control, day) {
const res = control.results[day];
if (!res || res.resValue === null) return 'opacity-20';
if (control.mean === null || control.sd === null) return '';
const val = parseFloat(res.resValue);
const target = parseFloat(control.mean);
const sd = parseFloat(control.sd);
const dev = Math.abs(val - target);
if (dev > 3 * sd) return 'text-error font-bold underline decoration-wavy';
if (dev > 2 * sd) return 'text-error font-bold';
return 'text-success';
}
}));
});
</script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -1,16 +0,0 @@
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Report View</h1>
<p class="text-sm mt-1 opacity-70">View detailed QC report</p>
</div>
</div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
<p class="text-base-content/60 text-center py-8">Report viewer coming soon...</p>
</div>
</main>
<?= $this->endSection(); ?>