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:
parent
dd7a058511
commit
0a96b04bdf
12
CLAUDE.md
12
CLAUDE.md
@ -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
|
||||||
|
|||||||
@ -88,5 +88,7 @@ class Autoload extends AutoloadConfig
|
|||||||
*
|
*
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
public $helpers = [];
|
public $helpers = [
|
||||||
|
'stringcase',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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'],
|
||||||
|
|||||||
@ -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,11 +269,16 @@ 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'],
|
||||||
'controlId' => $c['controlId'],
|
'controlId' => $c['controlId'],
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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' => '-'
|
||||||
|
|||||||
@ -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'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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]',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
return $this->respond([
|
||||||
$rows = $this->model->search($keyword);
|
'status' => 'success',
|
||||||
return $this->respond([
|
'data' => $this->model->search($keyword)
|
||||||
'status' => 'success',
|
]);
|
||||||
'message' => 'fetch success',
|
|
||||||
'data' => $rows
|
|
||||||
], 200);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
return $this->failServerError($e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show($id = null) {
|
public function create()
|
||||||
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 = $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 {
|
||||||
$id = $this->model->insert($input, true);
|
$id = $this->model->insert($input);
|
||||||
return $this->respondCreated([
|
return $this->respondCreated(['status' => 'success', 'data' => $id]);
|
||||||
'status' => 'success',
|
|
||||||
'message' => $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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -8,108 +8,110 @@ class CmodQcSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
public function run()
|
public function run()
|
||||||
{
|
{
|
||||||
// 1. Insert Departments (4 entries)
|
$this->seedDepts();
|
||||||
|
$this->seedControls();
|
||||||
|
$this->seedTests();
|
||||||
|
$this->seedControlTests();
|
||||||
|
$this->seedResults();
|
||||||
|
$this->seedResultComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function seedDepts()
|
||||||
|
{
|
||||||
$depts = [
|
$depts = [
|
||||||
['name' => 'Chemistry'],
|
['dept_name' => 'Chemistry'],
|
||||||
['name' => 'Hematology'],
|
['dept_name' => 'Hematology'],
|
||||||
['name' => 'Immunology'],
|
['dept_name' => 'Immunology'],
|
||||||
['name' => 'Urinalysis'],
|
['dept_name' => 'Urinalysis'],
|
||||||
];
|
];
|
||||||
$this->db->table('master_depts')->insertBatch($depts);
|
$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)
|
protected function seedControls()
|
||||||
|
{
|
||||||
$controls = [
|
$controls = [
|
||||||
['dept_id' => $deptIdMap[0], 'name' => 'QC Normal Chemistry', 'lot' => 'QC2024001', 'producer' => 'BioRad', 'exp_date' => '2025-12-31'],
|
['dept_id' => 1, 'control_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' => 1, 'control_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' => 2, 'control_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' => 2, 'control_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' => 3, 'control_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'],
|
['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);
|
$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)
|
protected function seedTests()
|
||||||
|
{
|
||||||
$tests = [
|
$tests = [
|
||||||
['dept_id' => $deptIdMap[0], 'name' => 'Glucose', 'unit' => 'mg/dL', 'method' => 'GOD-PAP', 'cva' => 5, 'ba' => 3, 'tea' => 10],
|
['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' => $deptIdMap[0], 'name' => 'Creatinine', 'unit' => 'mg/dL', 'method' => 'Jaffe', 'cva' => 4, 'ba' => 2, 'tea' => 8],
|
['dept_id' => 1, 'test_code' => 'CRE', 'test_name' => 'Creatinine', 'test_unit' => 'mg/dL', 'test_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' => 1, 'test_code' => 'BUN', 'test_name' => 'Urea Nitrogen', 'test_unit' => 'mg/dL', 'test_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' => 1, 'test_code' => 'CHOL', 'test_name' => 'Cholesterol', 'test_unit' => 'mg/dL', 'test_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' => 2, 'test_code' => 'WBC', 'test_name' => 'WBC', 'test_unit' => 'x10^3/uL', 'test_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' => 2, 'test_code' => 'RBC', 'test_name' => 'RBC', 'test_unit' => 'x10^6/uL', 'test_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' => 2, 'test_code' => 'HGB', 'test_name' => 'Hemoglobin', 'test_unit' => 'g/dL', 'test_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' => 3, 'test_code' => 'TSH', 'test_name' => 'TSH', 'test_unit' => 'mIU/L', 'test_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' => 3, 'test_code' => 'FT4', 'test_name' => 'Free T4', 'test_unit' => 'ng/dL', 'test_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],
|
['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);
|
$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)
|
protected function seedControlTests()
|
||||||
|
{
|
||||||
$controlTests = [
|
$controlTests = [
|
||||||
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[0], 'mean' => 95, 'sd' => 5],
|
['control_id' => 1, 'test_id' => 1, 'mean' => 95, 'sd' => 5],
|
||||||
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[1], 'mean' => 1.0, 'sd' => 0.05],
|
['control_id' => 1, 'test_id' => 2, 'mean' => 1.0, 'sd' => 0.05],
|
||||||
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[2], 'mean' => 15, 'sd' => 1.2],
|
['control_id' => 1, 'test_id' => 3, 'mean' => 15, 'sd' => 1.2],
|
||||||
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[0], 'mean' => 180, 'sd' => 12],
|
['control_id' => 2, 'test_id' => 1, 'mean' => 180, 'sd' => 12],
|
||||||
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[1], 'mean' => 2.5, 'sd' => 0.15],
|
['control_id' => 2, 'test_id' => 2, 'mean' => 2.5, 'sd' => 0.15],
|
||||||
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[3], 'mean' => 200, 'sd' => 15],
|
['control_id' => 2, 'test_id' => 4, 'mean' => 200, 'sd' => 15],
|
||||||
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[4], 'mean' => 7.5, 'sd' => 0.6],
|
['control_id' => 3, 'test_id' => 5, 'mean' => 7.5, 'sd' => 0.6],
|
||||||
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[5], 'mean' => 4.8, 'sd' => 0.2],
|
['control_id' => 3, 'test_id' => 6, 'mean' => 4.8, 'sd' => 0.2],
|
||||||
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[6], 'mean' => 14.5, 'sd' => 0.5],
|
['control_id' => 3, 'test_id' => 7, 'mean' => 14.5, 'sd' => 0.5],
|
||||||
['control_id' => $controlIdMap[3], 'test_id' => $testIdMap[4], 'mean' => 3.5, 'sd' => 0.3],
|
['control_id' => 4, 'test_id' => 5, 'mean' => 3.5, 'sd' => 0.3],
|
||||||
['control_id' => $controlIdMap[3], 'test_id' => $testIdMap[5], 'mean' => 2.5, 'sd' => 0.15],
|
['control_id' => 4, 'test_id' => 6, 'mean' => 2.5, 'sd' => 0.15],
|
||||||
['control_id' => $controlIdMap[4], 'test_id' => $testIdMap[7], 'mean' => 2.5, 'sd' => 0.3],
|
['control_id' => 5, 'test_id' => 8, 'mean' => 2.5, 'sd' => 0.3],
|
||||||
['control_id' => $controlIdMap[4], 'test_id' => $testIdMap[8], 'mean' => 1.2, 'sd' => 0.1],
|
['control_id' => 5, 'test_id' => 9, 'mean' => 1.2, 'sd' => 0.1],
|
||||||
['control_id' => $controlIdMap[5], 'test_id' => $testIdMap[9], 'mean' => 10, 'sd' => 1.5],
|
['control_id' => 6, 'test_id' => 10, 'mean' => 10, 'sd' => 1.5],
|
||||||
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[3], 'mean' => 150, 'sd' => 10],
|
['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);
|
$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)
|
protected function seedResults()
|
||||||
$results = [];
|
{
|
||||||
$faker = \Faker\Factory::create();
|
$faker = \Faker\Factory::create();
|
||||||
$resultDate = date('2024-12-01');
|
$resultDate = '2026-01-01';
|
||||||
|
$results = [];
|
||||||
// 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'];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
$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'],
|
||||||
];
|
];
|
||||||
$this->db->table('result_comments')->insertBatch($resultComments);
|
|
||||||
|
$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($comments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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>
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
<div class="divider divider-horizontal mx-0 hidden sm:flex"></div>
|
||||||
<span class="label-text font-medium">Test</span>
|
|
||||||
|
<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">
|
<thead>
|
||||||
<div class="flex justify-between items-center">
|
<tr class="bg-base-200 border-b border-base-300">
|
||||||
<div>
|
<th
|
||||||
<h3 class="font-medium" x-text="testName + ' - ' + monthDisplay"></h3>
|
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">
|
||||||
<p class="text-xs text-base-content/60" x-text="testUnit || ''"></p>
|
Day
|
||||||
</div>
|
</th>
|
||||||
<div class="text-xs text-base-content/70 text-right" x-show="qcParameters">
|
<template x-for="(control, cIdx) in controls" :key="control.controlId">
|
||||||
<span x-text="qcParameters"></span>
|
<th class="p-3 text-center border-r border-base-300 min-w-32 bg-base-200/50">
|
||||||
</div>
|
<div class="flex flex-col gap-1">
|
||||||
</div>
|
<span class="text-sm font-bold truncate" x-text="control.controlName"></span>
|
||||||
</div>
|
<span class="text-[10px] opacity-60 font-mono"
|
||||||
<div class="overflow-x-auto">
|
x-text="'LOT: ' + (control.lot || 'N/A')"></span>
|
||||||
<table class="w-full">
|
<div class="flex items-center justify-center gap-1 mt-1">
|
||||||
<thead>
|
<span class="badge badge-xs badge-neutral px-1.5"
|
||||||
<tr>
|
x-text="formatParam(control)"></span>
|
||||||
<th class="sticky left-0 bg-base-200 z-10 w-48 p-2 text-left border-r border-base-300">
|
</div>
|
||||||
Control
|
</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>
|
|
||||||
<th class="w-48 p-2 text-left border-l border-base-300">Comment</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-for="control in controls" :key="control.controlId">
|
|
||||||
<tr class="hover">
|
|
||||||
<td class="sticky left-0 bg-base-100 z-10 p-2 border-r border-base-300">
|
|
||||||
<div class="font-medium text-sm" x-text="control.controlName"></div>
|
|
||||||
<div class="text-xs text-base-content/50" x-text="control.lot || ''"></div>
|
|
||||||
</td>
|
|
||||||
<template x-for="day in daysInMonth" :key="day">
|
|
||||||
<td class="p-0.5 text-center border-r border-base-200 last:border-r-0"
|
|
||||||
:class="{
|
|
||||||
'bg-base-200/50': isWeekend(day)
|
|
||||||
}">
|
|
||||||
<input type="text"
|
|
||||||
inputmode="decimal"
|
|
||||||
:placeholder="'/'"
|
|
||||||
class="input input-bordered input-xs w-full text-center font-mono"
|
|
||||||
:class="getCellClass(control, day)"
|
|
||||||
@input="updateResult(control.controlId, day, $event.target.value)"
|
|
||||||
:value="getResultValue(control, day)"
|
|
||||||
@focus="selectCell($event.target)">
|
|
||||||
</td>
|
|
||||||
</template>
|
|
||||||
<td class="p-2 border-l border-base-300">
|
|
||||||
<textarea class="textarea textarea-bordered textarea-xs w-full"
|
|
||||||
:placeholder="'Monthly comment...'"
|
|
||||||
rows="1"
|
|
||||||
@input="updateComment(control.controlId, $event.target.value)"
|
|
||||||
:value="getComment(control.controlId)"></textarea>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</thead>
|
||||||
</div>
|
<tbody @keydown="handleKeydown($event)">
|
||||||
|
<template x-for="day in daysInMonth" :key="day">
|
||||||
|
<tr class="hover:bg-primary/5 transition-colors group">
|
||||||
|
<td class="sticky left-0 z-10 p-2 text-center font-mono font-bold text-sm bg-base-100 border-r border-base-300 group-hover:bg-primary/10"
|
||||||
|
:class="{
|
||||||
|
'bg-base-200/50 text-base-content/40': isWeekend(day),
|
||||||
|
'text-primary bg-primary/5': isToday(day)
|
||||||
|
}">
|
||||||
|
<span x-text="day"></span>
|
||||||
|
<span class="block text-[8px] opacity-40 leading-none mt-0.5"
|
||||||
|
x-text="getDayName(day)"></span>
|
||||||
|
</td>
|
||||||
|
<template x-for="(control, cIdx) in controls" :key="control.controlId">
|
||||||
|
<td class="p-1 border-r border-base-200 last:border-r-0"
|
||||||
|
:class="{ 'bg-base-200/20': isWeekend(day) }">
|
||||||
|
<div class="relative group/cell px-0.5">
|
||||||
|
<input type="number" step="any"
|
||||||
|
class="input input-sm input-ghost no-spinner w-full text-center font-mono text-sm rounded focus:bg-base-100 focus:shadow-sm focus:ring-1 focus:ring-primary/20 placeholder:text-base-content/20 transition-all"
|
||||||
|
:class="getCellClass(control, day)" :data-day="day" :data-cidx="cIdx"
|
||||||
|
x-model="resultsData[control.controlId + '_' + day]"
|
||||||
|
@focus="onCellFocus(control, day, $event)" placeholder="-">
|
||||||
|
|
||||||
|
<!-- Floating Action Buttons for cell -->
|
||||||
|
<div
|
||||||
|
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover/cell:opacity-100 transition-opacity pointer-events-none">
|
||||||
|
<button @click="showComment(control.controlId, day)"
|
||||||
|
class="btn btn-circle btn-ghost btn-xs h-5 w-5 min-h-0 pointer-events-auto"
|
||||||
|
:class="hasComment(control.controlId, day) ? 'text-info' : 'text-base-content/30'">
|
||||||
|
<i class="fa-solid fa-comment text-[10px]"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Highlight indicator for edited cells -->
|
||||||
|
<div x-show="isChanged(control.controlId, day)"
|
||||||
|
class="absolute left-1 top-1.5 w-1.5 h-1.5 rounded-full bg-info ring-1 ring-base-100 shadow-sm pointer-events-none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Legend -->
|
<!-- Legend & Footer Info -->
|
||||||
<div class="flex flex-wrap gap-4 text-sm text-base-content/70">
|
<div class="flex flex-wrap items-center justify-between gap-4 px-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-wrap gap-4 text-[10px] items-center">
|
||||||
<span class="w-4 h-4 border border-base-300 rounded bg-success/20"></span>
|
<div class="flex items-center gap-1.5 opacity-70">
|
||||||
<span>In Range</span>
|
<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>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-4 h-4 border border-base-300 rounded bg-error/20"></span>
|
<div class="flex items-center gap-3 text-xs opacity-60 italic">
|
||||||
<span>Out of Range</span>
|
<i class="fa-solid fa-lightbulb"></i>
|
||||||
</div>
|
<span>Tip: Use arrow keys to navigate between cells.</span>
|
||||||
<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,21 +352,37 @@ 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({
|
|
||||||
controlId: control.controlId,
|
if (hasControlData) {
|
||||||
results: results,
|
controlsToSave.push({
|
||||||
comment: this.commentsData[control.controlId] || null
|
controlId: control.controlId,
|
||||||
});
|
results: results
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controlsToSave.length === 0) {
|
||||||
|
this.saving = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${BASEURL}api/entry/monthly`, {
|
const response = await fetch(`${BASEURL}api/entry/monthly`, {
|
||||||
@ -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() {
|
||||||
|
|||||||
@ -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,7 +77,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<?= $this->renderSection('content') ?>
|
<div class="flex-1">
|
||||||
|
<?= $this->renderSection('content') ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer footer-center p-4 bg-base-300 text-base-content border-t border-base-300 mt-auto">
|
||||||
|
<p>©<?= date('Y') ?> made by 5panda for PT.Summit</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drawer-side z-40">
|
<div class="drawer-side z-40">
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,100 +1,152 @@
|
|||||||
<?= $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="overflow-x-auto">
|
<div class="space-y-4">
|
||||||
<table class="w-full text-sm text-left">
|
<template x-for="(group, name) in groupedList" :key="name">
|
||||||
<thead class="uppercase tracking-wider font-semibold bg-base-200 text-base-content/70 text-xs">
|
<div
|
||||||
<tr>
|
class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden transition-all hover:shadow-md">
|
||||||
<th class="py-3 px-5 font-semibold">Control Name</th>
|
<div
|
||||||
<th class="py-3 px-5 font-semibold">Lot Number</th>
|
class="p-3 bg-base-200/40 border-b border-base-300 flex flex-wrap justify-between items-center gap-3">
|
||||||
<th class="py-3 px-5 font-semibold">Producer</th>
|
<div class="flex items-center gap-3">
|
||||||
<th class="py-3 px-5 font-semibold">Expiry Date</th>
|
<div
|
||||||
<th class="py-3 px-5 font-semibold text-right">Action</th>
|
class="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||||
</tr>
|
<i class="fa-solid fa-vial text-base"></i>
|
||||||
</thead>
|
</div>
|
||||||
<tbody class="text-base-content/80 divide-y divide-base-300">
|
<div>
|
||||||
<template x-for="item in list" :key="item.controlId">
|
<h3 class="font-bold text-sm text-base-content leading-tight" x-text="name"></h3>
|
||||||
<tr class="hover:bg-base-200 transition-colors">
|
<div class="flex items-center gap-2 mt-0.5">
|
||||||
<td class="py-3 px-5 font-medium text-base-content" x-text="item.controlName"></td>
|
<span class="text-[10px] flex items-center gap-1.5 text-base-content/60">
|
||||||
<td class="py-3 px-5">
|
<i class="fa-solid fa-industry opacity-50"></i>
|
||||||
<span class="font-mono text-xs bg-base-200 text-base-content/70 px-2 py-1 rounded" x-text="item.lot"></span>
|
<span x-text="group.producer || 'No producer info'"></span>
|
||||||
</td>
|
</span>
|
||||||
<td class="py-3 px-5" x-text="item.producer"></td>
|
<span class="w-1 h-1 rounded-full bg-base-300"></span>
|
||||||
<td class="py-3 px-5" x-text="item.expDate"></td>
|
<span class="text-[10px] font-medium text-primary"
|
||||||
<td class="py-3 px-5 text-right">
|
x-text="group.lots.length + ' Lot(s)'"></span>
|
||||||
<button
|
</div>
|
||||||
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"
|
</div>
|
||||||
@click="showForm(item.controlId)"
|
</div>
|
||||||
>
|
<button class="btn btn-sm btn-primary gap-1.5 px-3" @click="addLotToControl(group)">
|
||||||
<i class="fa-solid fa-pencil"></i> Edit
|
<i class="fa-solid fa-plus text-[10px]"></i> Add Lot
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
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"
|
|
||||||
@click="deleteData(item.controlId)"
|
<div class="overflow-x-auto">
|
||||||
>
|
<table class="w-full text-sm">
|
||||||
<i class="fa-solid fa-trash"></i>
|
<thead>
|
||||||
</button>
|
<tr class="bg-base-100 text-left border-b border-base-300">
|
||||||
</td>
|
<th
|
||||||
</tr>
|
class="py-2 px-4 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">
|
||||||
</template>
|
Lot Number</th>
|
||||||
<template x-if="list.length === 0">
|
<th
|
||||||
<tr>
|
class="py-2 px-3 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">
|
||||||
<td colspan="5" class="py-8 text-center text-base-content/60">No data available</td>
|
Expiry Date</th>
|
||||||
</tr>
|
<th
|
||||||
</template>
|
class="py-2 px-3 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">
|
||||||
</tbody>
|
Status</th>
|
||||||
</table>
|
<th
|
||||||
|
class="py-2 px-4 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider text-right">
|
||||||
|
Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-base-200">
|
||||||
|
<template x-for="lot in group.lots" :key="lot.controlId">
|
||||||
|
<tr class="hover:bg-base-200/30 transition-colors group">
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
<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 class="py-2 px-3 text-base-content/80 text-xs font-medium"
|
||||||
|
x-text="lot.expDate">
|
||||||
|
</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
|
||||||
|
class="p-1.5 text-amber-600 hover:bg-amber-50 rounded transition-colors tooltip tooltip-left"
|
||||||
|
data-tip="Edit Lot" @click="showForm(lot.controlId)">
|
||||||
|
<i class="fa-solid fa-pencil text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 text-error hover:bg-error/10 rounded transition-colors tooltip tooltip-left"
|
||||||
|
data-tip="Delete Lot" @click="deleteData(lot.controlId)">
|
||||||
|
<i class="fa-solid fa-trash text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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>
|
</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: "" };
|
||||||
|
|||||||
65
app/Views/master/control_test/dialog_control_test_form.php
Normal file
65
app/Views/master/control_test/dialog_control_test_form.php
Normal 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>
|
||||||
307
app/Views/master/control_test/index.php
Normal file
307
app/Views/master/control_test/index.php
Normal 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(); ?>
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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(); ?>
|
||||||
@ -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(); ?>
|
|
||||||
Loading…
x
Reference in New Issue
Block a user