Refactor application structure, unify entry management, and overhaul UI with DaisyUI

- Reorganized Architecture: Moved Master data management (Controls, Departments, Tests) to a dedicated Master namespace/directory for better modularity.
- Unified Data Entry: Consolidated daily and monthly QC entry views and logic into a single streamlined system.
- UI/UX Modernization:
    - Integrated DaisyUI and Alpine.js for a responsive, themeable, and interactive experience.
    - Replaced legacy layouts with a new DaisyUI-based template.
    - Updated branding with new logo and favicon assets.
    - Cleaned up deprecated JavaScript assets (app.js, charts.js, tables.js).
- Backend Enhancements:
    - Added `ControlEntryModel` and `ResultCommentsController` for improved data handling and auditing.
    - Updated routing to support the new controller structure and consolidated API endpoints.
- Documentation & Assets:
    - Added [docs/PRD.md](cci:7://file:///c:/www/tinyqc/docs/PRD.md:0:0-0:0) and [docs/llms.txt](cci:7://file:///c:/www/tinyqc/docs/llms.txt:0:0-0:0) for project context and requirements.
    - Included database schema scripts and backups in the `backup/` directory.
- Cleanup: Removed legacy TUI progress files and unused commands.
This commit is contained in:
mahdahar 2026-01-19 06:37:37 +07:00
parent 2e23fc38c3
commit 5cae572916
74 changed files with 6164 additions and 4768 deletions

View File

@ -7,6 +7,9 @@ use CodeIgniter\Router\RouteCollection;
*/ */
$routes->get('/', 'PageController::dashboard'); $routes->get('/', 'PageController::dashboard');
$routes->get('/master/dept', 'PageController::masterDept');
$routes->get('/master/test', 'PageController::masterTest');
$routes->get('/master/control', 'PageController::masterControl');
$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');
@ -42,3 +45,43 @@ $routes->group('api', function ($routes) {
$routes->post('entry/monthly', 'Api\EntryApiController::saveMonthly'); $routes->post('entry/monthly', 'Api\EntryApiController::saveMonthly');
$routes->post('entry/comment', 'Api\EntryApiController::saveComment'); $routes->post('entry/comment', 'Api\EntryApiController::saveComment');
}); });
$routes->group('api/master', function ($routes) {
$routes->get('depts', 'Master\MasterDeptsController::index');
$routes->get('depts/(:num)', 'Master\MasterDeptsController::show/$1');
$routes->post('depts', 'Master\MasterDeptsController::create');
$routes->patch('depts/(:num)', 'Master\MasterDeptsController::update/$1');
$routes->delete('depts/(:num)', 'Master\MasterDeptsController::delete/$1');
$routes->get('controls', 'Master\MasterControlsController::index');
$routes->get('controls/(:num)', 'Master\MasterControlsController::show/$1');
$routes->post('controls', 'Master\MasterControlsController::create');
$routes->patch('controls/(:num)', 'Master\MasterControlsController::update/$1');
$routes->delete('controls/(:num)', 'Master\MasterControlsController::delete/$1');
$routes->get('tests', 'Master\MasterTestsController::index');
$routes->get('tests/(:num)', 'Master\MasterTestsController::show/$1');
$routes->post('tests', 'Master\MasterTestsController::create');
$routes->patch('tests/(:num)', 'Master\MasterTestsController::update/$1');
$routes->delete('tests/(:num)', 'Master\MasterTestsController::delete/$1');
});
$routes->group('api/qc', function ($routes) {
$routes->get('control-tests', 'Qc\ControlTestsController::index');
$routes->get('control-tests/(:num)', 'Qc\ControlTestsController::show/$1');
$routes->post('control-tests', 'Qc\ControlTestsController::create');
$routes->patch('control-tests/(:num)', 'Qc\ControlTestsController::update/$1');
$routes->delete('control-tests/(:num)', 'Qc\ControlTestsController::delete/$1');
$routes->get('results', 'Qc\ResultsController::index');
$routes->get('results/(:num)', 'Qc\ResultsController::show/$1');
$routes->post('results', 'Qc\ResultsController::create');
$routes->patch('results/(:num)', 'Qc\ResultsController::update/$1');
$routes->delete('results/(:num)', 'Qc\ResultsController::delete/$1');
$routes->get('result-comments', 'Qc\ResultCommentsController::index');
$routes->get('result-comments/(:num)', 'Qc\ResultCommentsController::show/$1');
$routes->post('result-comments', 'Qc\ResultCommentsController::create');
$routes->patch('result-comments/(:num)', 'Qc\ResultCommentsController::update/$1');
$routes->delete('result-comments/(:num)', 'Qc\ResultCommentsController::delete/$1');
});

View File

@ -1,148 +0,0 @@
<?php
namespace App\Controllers\Api;
use App\Controllers\BaseController;
use App\Models\DictControlModel;
use App\Models\ControlTestModel;
class ControlApiController extends BaseController
{
protected $dictControlModel;
protected $controlTestModel;
protected $rules;
public function __construct()
{
$this->dictControlModel = new DictControlModel();
$this->controlTestModel = new ControlTestModel();
$this->rules = [
'name' => 'required|min_length[1]',
'dept_ref_id' => 'required',
];
}
public function index()
{
$keyword = $this->request->getGet('keyword');
$deptId = $this->request->getGet('deptId');
try {
$rows = $this->dictControlModel->getWithDept($keyword, $deptId);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Exception: ' . $e->getMessage());
}
}
public function show($id = null)
{
try {
$rows = $this->dictControlModel->where('control_id', $id)->findAll();
if (empty($rows)) {
return $this->respond([
'status' => 'success',
'message' => 'data not found.'
], 200);
}
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function getTests($id = null)
{
try {
$rows = $this->controlTestModel->where('control_ref_id', $id)->findAll();
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function create()
{
$input = $this->request->getJSON(true);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$controlId = $this->dictControlModel->insert($input, true);
if (!empty($input['test_ids'])) {
foreach ($input['test_ids'] as $testId) {
$this->controlTestModel->insert([
'control_ref_id' => $controlId,
'test_ref_id' => $testId,
'mean' => 0,
'sd' => 0,
]);
}
}
return $this->respondCreated([
'status' => 'success',
'message' => $controlId
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function update($id = null)
{
$input = $this->request->getJSON(true);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$this->dictControlModel->update($id, $input);
if (!empty($input['test_ids'])) {
$this->controlTestModel->where('control_ref_id', $id)->delete();
foreach ($input['test_ids'] as $testId) {
$this->controlTestModel->insert([
'control_ref_id' => $id,
'test_ref_id' => $testId,
'mean' => 0,
'sd' => 0,
]);
}
}
return $this->respond([
'status' => 'success',
'message' => 'update success',
'data' => $id
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function delete($id = null)
{
try {
$this->controlTestModel->where('control_ref_id', $id)->delete();
$this->dictControlModel->delete($id);
return $this->respond([
'status' => 'success',
'message' => 'delete success'
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}

View File

@ -1,252 +0,0 @@
<?php
namespace App\Controllers\Api;
use App\Controllers\BaseController;
use App\Models\DictControlModel;
use App\Models\ControlTestModel;
use App\Models\DailyResultModel;
use App\Models\ResultModel;
use App\Models\ResultCommentModel;
class EntryApiController extends BaseController
{
protected $dictControlModel;
protected $controlTestModel;
protected $dailyResultModel;
protected $resultModel;
protected $commentModel;
public function __construct()
{
$this->dictControlModel = new \App\Models\DictControlModel();
$this->controlTestModel = new \App\Models\ControlTestModel();
$this->dailyResultModel = new \App\Models\DailyResultModel();
$this->resultModel = new \App\Models\ResultModel();
$this->commentModel = new \App\Models\ResultCommentModel();
}
public function getControls()
{
try {
$date = $this->request->getGet('date');
$deptId = $this->request->getGet('deptId');
$rows = $this->dictControlModel->getActiveByDate($date, $deptId);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function getTests()
{
try {
$controlId = $this->request->getGet('controlId');
$rows = $this->controlTestModel->getByControl($controlId);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function saveDaily()
{
$input = $this->request->getJSON(true);
try {
$resultData = [
'control_ref_id' => $input['controlId'] ?? 0,
'test_ref_id' => $input['testId'] ?? 0,
'resdate' => $input['resdate'] ?? date('Y-m-d'),
'resvalue' => $input['resvalue'] ?? '',
'rescomment' => $input['rescomment'] ?? '',
];
$this->dailyResultModel->saveResult($resultData);
return $this->respond([
'status' => 'success',
'message' => 'save success'
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function saveMonthly()
{
$input = $this->request->getJSON(true);
try {
$controlId = $input['controlId'] ?? 0;
$yearMonth = $input['yearMonth'] ?? '';
$operation = $input['operation'] ?? 'replace';
$tests = $input['tests'] ?? [];
$results = [];
$statistics = [];
$validations = [];
foreach ($tests as $testData) {
$testId = $testData['testId'] ?? 0;
$resvalues = $testData['resvalue'] ?? [];
$rescomments = $testData['rescomment'] ?? [];
$controlTest = $this->controlTestModel->getByControlAndTest($controlId, $testId);
$mean = $controlTest['mean'] ?? 0;
$sd = $controlTest['sd'] ?? 0;
$sdLimit = $sd > 0 ? $sd * 2 : 0;
$testValues = [];
$validCount = 0;
$validSum = 0;
$validSqSum = 0;
foreach ($resvalues as $day => $value) {
if (!empty($value)) {
$resdate = $yearMonth . '-' . str_pad($day, 2, '0', STR_PAD_LEFT);
$rescomment = $rescomments[$day] ?? '';
$resultData = [
'control_ref_id' => $controlId,
'test_ref_id' => $testId,
'resdate' => $resdate,
'resvalue' => $value,
'rescomment' => $rescomment,
];
if ($operation === 'replace') {
$this->resultModel->saveResult($resultData);
} else {
$existing = $this->resultModel->checkExisting($controlId, $testId, $resultData['resdate']);
if (!$existing) {
$this->resultModel->saveResult($resultData);
}
}
$numValue = (float) $value;
$testValues[] = $numValue;
$validCount++;
$validSum += $numValue;
$validSqSum += $numValue * $numValue;
$withinLimit = true;
if ($sdLimit > 0) {
$withinLimit = abs($numValue - $mean) <= $sdLimit;
}
if (!$withinLimit) {
$validations[] = [
'testId' => $testId,
'day' => $day,
'value' => $value,
'mean' => $mean,
'sd' => $sd,
'status' => 'out_of_range'
];
}
}
}
if ($validCount > 0) {
$calcMean = $validSum / $validCount;
$calcSd = 0;
if ($validCount > 1) {
$variance = ($validSqSum - ($validSum * $validSum) / $validCount) / ($validCount - 1);
$calcSd = $variance > 0 ? sqrt($variance) : 0;
}
$cv = $calcMean > 0 ? ($calcSd / $calcMean) * 100 : 0;
$statistics[] = [
'testId' => $testId,
'n' => $validCount,
'mean' => round($calcMean, 3),
'sd' => round($calcSd, 3),
'cv' => round($cv, 2)
];
}
$results[] = [
'testId' => $testId,
'saved' => count($resvalues)
];
}
return $this->respond([
'status' => 'success',
'message' => 'save success',
'data' => [
'results' => $results,
'statistics' => $statistics,
'validations' => $validations
]
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function saveComment()
{
$input = $this->request->getJSON(true);
try {
$commentData = [
'control_ref_id' => $input['controlId'] ?? 0,
'test_ref_id' => $input['testId'] ?? 0,
'commonth' => $input['commonth'] ?? '',
'comtext' => $input['comtext'] ?? '',
];
$this->commentModel->saveComment($commentData);
return $this->respond([
'status' => 'success',
'message' => 'save success'
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function getMonthlyData()
{
$controlId = $this->request->getGet('controlId');
$testId = $this->request->getGet('testId');
$yearMonth = $this->request->getGet('yearMonth');
try {
$results = $this->resultModel->getByMonth($controlId, $testId, $yearMonth);
$comment = $this->commentModel->getByControlTestMonth($controlId, $testId, $yearMonth);
$formValues = [];
$comments = [];
foreach ($results as $row) {
$day = (int)date('j', strtotime($row['resdate']));
$formValues[$day] = $row['resvalue'];
if (!empty($row['rescomment'])) {
$comments[$day] = $row['rescomment'];
}
}
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => [
'formValues' => $formValues,
'comments' => $comments,
'comment' => $comment ? $comment['comtext'] : ''
]
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Controllers;
use App\Models\DictControlModel;
use App\Models\ControlTestModel;
use App\Models\DictDeptModel;
use App\Models\DictTestModel;
class Control extends BaseController
{
protected $dictControlModel;
protected $controlTestModel;
protected $dictDeptModel;
protected $dictTestModel;
public function __construct()
{
$this->dictControlModel = new DictControlModel();
$this->controlTestModel = new ControlTestModel();
$this->dictDeptModel = new DictDeptModel();
$this->dictTestModel = new DictTestModel();
}
public function index(): string
{
return view('control/index', [
'title' => 'Control Dictionary',
'controls' => $this->dictControlModel->getWithDept(),
'depts' => $this->dictDeptModel->findAll(),
'tests' => $this->dictTestModel->getWithDept(),
'active_menu' => 'control',
'page_title' => 'Control Dictionary'
]);
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Controllers;
use App\Models\DictDeptModel;
use App\Models\DictTestModel;
use App\Models\DictControlModel;
use App\Models\ResultModel;
class Dashboard extends BaseController
{
public function index()
{
$dictDeptModel = new DictDeptModel();
$dictTestModel = new DictTestModel();
$dictControlModel = new DictControlModel();
$resultModel = new ResultModel();
return view('dashboard', [
'depts' => $dictDeptModel->findAll(),
'tests' => $dictTestModel->getWithDept(),
'controls' => $dictControlModel->getWithDept(),
'recent_results' => $resultModel->findAll(20),
'page_title' => 'Dashboard',
'active_menu' => 'dashboard'
]);
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Controllers;
use App\Models\DictDeptModel;
class Dept extends BaseController
{
protected $dictDeptModel;
public function __construct()
{
$this->dictDeptModel = new DictDeptModel();
}
public function index(): string
{
return view('dept/index', [
'title' => 'Department Dictionary',
'depts' => $this->dictDeptModel->findAll(),
'active_menu' => 'dept',
'page_title' => 'Department Dictionary'
]);
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Controllers;
use App\Models\DictDeptModel;
class Entry extends BaseController
{
public function index()
{
$dictDeptModel = new \App\Models\DictDeptModel();
return view('entry/monthly', [
'title' => 'Monthly Entry',
'depts' => $dictDeptModel->findAll(),
'active_menu' => 'entry',
'page_title' => 'Monthly Entry'
]);
}
public function daily()
{
$dictDeptModel = new \App\Models\DictDeptModel();
return view('entry/daily', [
'title' => 'Daily Entry',
'depts' => $dictDeptModel->findAll(),
'active_menu' => 'entry_daily',
'page_title' => 'Daily Entry'
]);
}
}

View File

@ -1,46 +1,42 @@
<?php <?php
namespace App\Controllers\Master;
namespace App\Controllers\Api; use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\DictTestModel; use App\Models\Master\MasterControlsModel;
use App\Models\DictDeptModel;
class TestApiController extends BaseController class MasterControlsController extends BaseController {
{ use ResponseTrait;
protected $dictTestModel;
protected $dictDeptModel; protected $model;
protected $rules; protected $rules;
public function __construct() public function __construct() {
{ $this->model = new MasterControlsModel();
$this->dictTestModel = new DictTestModel();
$this->dictDeptModel = new DictDeptModel();
$this->rules = [ $this->rules = [
'name' => 'required|min_length[1]', 'name' => 'required|min_length[1]',
'dept_ref_id' => 'required', 'lot' => 'required|min_length[1]',
]; ];
} }
public function index() public function index() {
{ $keyword = $this->request->getGet('keyword');
try { try {
$rows = $this->dictTestModel->getWithDept(); $rows = $this->model->search($keyword);
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'fetch success', 'message' => 'fetch success',
'data' => $rows 'data' => $rows
], 200); ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Exception: ' . $e->getMessage()); return $this->failServerError($e->getMessage());
} }
} }
public function show($id = null) public function show($id = null) {
{
try { try {
$rows = $this->dictTestModel->where('test_id', $id)->findAll(); $row = $this->model->find($id);
if (empty($rows)) { if (!$row) {
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'data not found.' 'message' => 'data not found.'
@ -49,58 +45,58 @@ class TestApiController extends BaseController
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'fetch success', 'message' => 'fetch success',
'data' => $rows 'data' => [$row]
], 200); ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError($e->getMessage());
} }
} }
public function create() 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->dictTestModel->insert($input, true); $id = $this->model->insert($input, true);
return $this->respondCreated([ return $this->respondCreated([
'status' => 'success', 'status' => 'success',
'message' => $id 'message' => $id
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $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->dictTestModel->update($id, $input); $this->model->update($id, $input);
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'update success', 'message' => 'update success',
'data' => $id 'data' => [$id]
]); ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError($e->getMessage());
} }
} }
public function delete($id = null) public function delete($id = null) {
{
try { try {
$this->dictTestModel->delete($id); $this->model->delete($id);
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'delete success' 'message' => 'delete success',
]); 'data' => [$id]
], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError($e->getMessage());
} }
} }
} }

View File

@ -1,42 +1,41 @@
<?php <?php
namespace App\Controllers\Master;
namespace App\Controllers\Api; use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\DictDeptModel; use App\Models\Master\MasterDeptsModel;
class DeptApiController extends BaseController class MasterDeptsController extends BaseController {
{ use ResponseTrait;
protected $dictDeptModel;
protected $model;
protected $rules; protected $rules;
public function __construct() public function __construct() {
{ $this->model = new MasterDeptsModel();
$this->dictDeptModel = new DictDeptModel();
$this->rules = [ $this->rules = [
'name' => 'required|min_length[1]', 'name' => 'required|min_length[1]',
]; ];
} }
public function index() public function index() {
{ $keyword = $this->request->getGet('keyword');
try { try {
$rows = $this->dictDeptModel->findAll(); $rows = $this->model->search($keyword);
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'fetch success', 'message' => 'fetch success',
'data' => $rows 'data' => $rows
], 200); ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Exception: ' . $e->getMessage()); return $this->failServerError($e->getMessage());
} }
} }
public function show($id = null) public function show($id = null) {
{
try { try {
$rows = $this->dictDeptModel->where('dept_id', $id)->findAll(); $row = $this->model->find($id);
if (empty($rows)) { if (!$row) {
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'data not found.' 'message' => 'data not found.'
@ -45,58 +44,58 @@ class DeptApiController extends BaseController
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'fetch success', 'message' => 'fetch success',
'data' => $rows 'data' => [$row]
], 200); ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError($e->getMessage());
} }
} }
public function create() 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->dictDeptModel->insert($input, true); $id = $this->model->insert($input, true);
return $this->respondCreated([ return $this->respondCreated([
'status' => 'success', 'status' => 'success',
'message' => $id 'message' => $id
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $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->dictDeptModel->update($id, $input); $this->model->update($id, $input);
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'update success', 'message' => 'update success',
'data' => $id 'data' => [$id]
]); ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError($e->getMessage());
} }
} }
public function delete($id = null) public function delete($id = null) {
{
try { try {
$this->dictDeptModel->delete($id); $this->model->delete($id);
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'delete success' 'message' => 'delete success',
]); 'data' => [$id]
], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError($e->getMessage());
} }
} }
} }

View File

@ -0,0 +1,101 @@
<?php
namespace App\Controllers\Master;
use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Master\MasterTestsModel;
class MasterTestsController extends BaseController {
use ResponseTrait;
protected $model;
protected $rules;
public function __construct() {
$this->model = new MasterTestsModel();
$this->rules = [
'name' => 'required|min_length[1]',
];
}
public function index() {
$keyword = $this->request->getGet('keyword');
try {
$rows = $this->model->search($keyword);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function show($id = null) {
try {
$row = $this->model->find($id);
if (!$row) {
return $this->respond([
'status' => 'success',
'message' => 'data not found.'
], 200);
}
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => [$row]
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function create() {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$id = $this->model->insert($input, true);
return $this->respondCreated([
'status' => 'success',
'message' => $id
]);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function update($id = null) {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$this->model->update($id, $input);
return $this->respond([
'status' => 'success',
'message' => 'update success',
'data' => [$id]
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function delete($id = null) {
try {
$this->model->delete($id);
return $this->respond([
'status' => 'success',
'message' => 'delete success',
'data' => [$id]
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
}

View File

@ -2,157 +2,40 @@
namespace App\Controllers; namespace App\Controllers;
class PageController extends BaseController use CodeIgniter\API\ResponseTrait;
{
protected $dictDeptModel;
protected $dictTestModel;
protected $dictControlModel;
protected $resultModel;
protected $controlTestModel;
protected $commentModel;
public function __construct() class PageController extends BaseController {
{ use ResponseTrait;
$this->dictDeptModel = new \App\Models\DictDeptModel();
$this->dictTestModel = new \App\Models\DictTestModel(); public function dashboard() {
$this->dictControlModel = new \App\Models\DictControlModel(); return view('dashboard');
$this->resultModel = new \App\Models\ResultModel();
$this->controlTestModel = new \App\Models\ControlTestModel();
$this->commentModel = new \App\Models\ResultCommentModel();
} }
public function dashboard(): string public function masterDept() {
{ return view('master/dept/index');
return view('dashboard', [
'depts' => $this->dictDeptModel->findAll(),
'tests' => $this->dictTestModel->getWithDept(),
'controls' => $this->dictControlModel->getWithDept(),
'recent_results' => $this->resultModel->findAll(20),
'page_title' => 'Dashboard',
'active_menu' => 'dashboard'
]);
} }
public function dept(): string public function masterTest() {
{ return view('master/test/index');
return view('dept/index', [
'title' => 'Department Dictionary',
'depts' => $this->dictDeptModel->findAll(),
'active_menu' => 'dept',
'page_title' => 'Department Dictionary'
]);
} }
public function test(): string public function masterControl() {
{ return view('master/control/index');
return view('test/index', [
'title' => 'Test Dictionary',
'tests' => $this->dictTestModel->getWithDept(),
'depts' => $this->dictDeptModel->findAll(),
'active_menu' => 'test',
'page_title' => 'Test Dictionary'
]);
} }
public function control(): string public function entry() {
{ return view('entry/index');
return view('control/index', [
'title' => 'Control Dictionary',
'controls' => $this->dictControlModel->getWithDept(),
'depts' => $this->dictDeptModel->findAll(),
'tests' => $this->dictTestModel->getWithDept(),
'active_menu' => 'control',
'page_title' => 'Control Dictionary'
]);
} }
public function entry(): string public function entryDaily() {
{ return view('entry/daily');
return view('entry/monthly', [
'title' => 'Monthly Entry',
'depts' => $this->dictDeptModel->findAll(),
'active_menu' => 'entry',
'page_title' => 'Monthly Entry'
]);
} }
public function entryDaily(): string public function report() {
{ return view('report/index');
return view('entry/daily', [
'title' => 'Daily Entry',
'depts' => $this->dictDeptModel->findAll(),
'active_menu' => 'entry_daily',
'page_title' => 'Daily Entry'
]);
} }
public function report(): string public function reportView() {
{ return view('report/view');
return view('report/index', [
'title' => 'Reports',
'depts' => $this->dictDeptModel->findAll(),
'tests' => $this->dictTestModel->findAll(),
'controls' => $this->dictControlModel->findAll(),
'active_menu' => 'report',
'page_title' => 'Reports'
]);
}
public function reportView(): string
{
$control1 = $this->request->getGet('control1') ?? 0;
$control2 = $this->request->getGet('control2') ?? 0;
$control3 = $this->request->getGet('control3') ?? 0;
$dates = $this->request->getGet('dates') ?? date('Y-m');
$test = $this->request->getGet('test') ?? 0;
$controls = [];
if ($control1) {
$c1 = $this->dictControlModel->find($control1);
if ($c1) $controls[] = $c1;
}
if ($control2) {
$c2 = $this->dictControlModel->find($control2);
if ($c2) $controls[] = $c2;
}
if ($control3) {
$c3 = $this->dictControlModel->find($control3);
if ($c3) $controls[] = $c3;
}
$reportData = [];
foreach ($controls as $control) {
$controlTest = $this->controlTestModel->getByControlAndTest($control['control_id'], $test);
$results = $this->resultModel->getByMonth($control['control_id'], $test, $dates);
$comment = $this->commentModel->getByControlTestMonth($control['control_id'], $test, $dates);
$outOfRangeCount = 0;
if ($controlTest && $controlTest['sd'] > 0) {
foreach ($results as $res) {
if (abs($res['resvalue'] - $controlTest['mean']) > 2 * $controlTest['sd']) {
$outOfRangeCount++;
}
}
}
$reportData[] = [
'control' => $control,
'controlTest' => $controlTest,
'results' => $results,
'comment' => $comment,
'test' => $this->dictTestModel->find($test),
'outOfRange' => $outOfRangeCount
];
}
return view('report/view', [
'title' => 'QC Report',
'reportData' => $reportData,
'dates' => $dates,
'test' => $test,
'depts' => $this->dictDeptModel->findAll(),
'active_menu' => 'report',
'page_title' => 'QC Report'
]);
} }
} }

View File

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

View File

@ -0,0 +1,99 @@
<?php
namespace App\Controllers\Qc;
use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Qc\ResultCommentsModel;
class ResultCommentsController extends BaseController {
use ResponseTrait;
protected $model;
protected $rules;
public function __construct() {
$this->model = new ResultCommentsModel();
$this->rules = [];
}
public function index() {
$keyword = $this->request->getGet('keyword');
try {
$rows = $this->model->search($keyword);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function show($id = null) {
try {
$row = $this->model->find($id);
if (!$row) {
return $this->respond([
'status' => 'success',
'message' => 'data not found.'
], 200);
}
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => [$row]
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function create() {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$id = $this->model->insert($input, true);
return $this->respondCreated([
'status' => 'success',
'message' => $id
]);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function update($id = null) {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$this->model->update($id, $input);
return $this->respond([
'status' => 'success',
'message' => 'update success',
'data' => [$id]
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function delete($id = null) {
try {
$this->model->delete($id);
return $this->respond([
'status' => 'success',
'message' => 'delete success',
'data' => [$id]
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Controllers\Qc;
use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Qc\ResultsModel;
class ResultsController extends BaseController {
use ResponseTrait;
protected $model;
protected $rules;
public function __construct() {
$this->model = new ResultsModel();
$this->rules = [];
}
public function index() {
$keyword = $this->request->getGet('keyword');
try {
$rows = $this->model->search($keyword);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function show($id = null) {
try {
$row = $this->model->find($id);
if (!$row) {
return $this->respond([
'status' => 'success',
'message' => 'data not found.'
], 200);
}
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => [$row]
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function create() {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$id = $this->model->insert($input, true);
return $this->respondCreated([
'status' => 'success',
'message' => $id
]);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function update($id = null) {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$this->model->update($id, $input);
return $this->respond([
'status' => 'success',
'message' => 'update success',
'data' => [$id]
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
public function delete($id = null) {
try {
$this->model->delete($id);
return $this->respond([
'status' => 'success',
'message' => 'delete success',
'data' => [$id]
], 200);
} catch (\Exception $e) {
return $this->failServerError($e->getMessage());
}
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Controllers;
use App\Models\DictDeptModel;
use App\Models\DictTestModel;
use App\Models\DictControlModel;
class Report extends BaseController
{
public function index()
{
$dictDeptModel = new DictDeptModel();
$dictTestModel = new DictTestModel();
$dictControlModel = new DictControlModel();
return view('report/index', [
'title' => 'Reports',
'depts' => $dictDeptModel->findAll(),
'tests' => $dictTestModel->findAll(),
'controls' => $dictControlModel->findAll(),
'active_menu' => 'report',
'page_title' => 'Reports'
]);
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Controllers;
use App\Models\DictTestModel;
use App\Models\DictDeptModel;
class Test extends BaseController
{
protected $dictTestModel;
protected $dictDeptModel;
public function __construct()
{
$this->dictTestModel = new DictTestModel();
$this->dictDeptModel = new DictDeptModel();
}
public function index()
{
return view('test/index', [
'title' => 'Test Dictionary',
'tests' => $this->dictTestModel->getWithDept(),
'depts' => $this->dictDeptModel->findAll(),
'active_menu' => 'test',
'page_title' => 'Test Dictionary'
]);
}
}

View File

@ -1,282 +0,0 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateCmodQcTables extends Migration
{
public function up()
{
$this->forge->addField([
'control_test_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'control_ref_id' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
],
'test_ref_id' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
],
'mean' => [
'type' => 'FLOAT',
'null' => true,
],
'sd' => [
'type' => 'FLOAT',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('control_test_id', true);
$this->forge->createTable('control_tests');
$this->forge->addField([
'result_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'control_ref_id' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
],
'test_ref_id' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
],
'resdate' => [
'type' => 'DATETIME',
'null' => true,
],
'resvalue' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'rescomment' => [
'type' => 'TEXT',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('result_id', true);
$this->forge->createTable('results');
$this->forge->addField([
'control_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'dept_ref_id' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
],
'name' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'lot' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'producer' => [
'type' => 'TEXT',
'null' => true,
],
'expdate' => [
'type' => 'DATE',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('control_id', true);
$this->forge->createTable('dict_controls');
$this->forge->addField([
'dept_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'name' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('dept_id', true);
$this->forge->createTable('dict_depts');
$this->forge->addField([
'test_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'dept_ref_id' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
],
'name' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'unit' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'method' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'cva' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'ba' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'tea' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('test_id', true);
$this->forge->createTable('dict_tests');
$this->forge->addField([
'result_comment_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'control_ref_id' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
],
'test_ref_id' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
],
'commonth' => [
'type' => 'VARCHAR',
'constraint' => 7,
'null' => true,
],
'comtext' => [
'type' => 'TEXT',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('result_comment_id', true);
$this->forge->createTable('result_comments');
}
public function down()
{
$this->forge->dropTable('control_tests');
$this->forge->dropTable('results');
$this->forge->dropTable('dict_controls');
$this->forge->dropTable('dict_depts');
$this->forge->dropTable('dict_tests');
$this->forge->dropTable('result_comments');
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class QualityControlSystem extends Migration {
public function up() {
$this->forge->addField([
'dept_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('dept_id', true);
$this->forge->createTable('dict_depts');
$this->forge->addField([
'control_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'dept_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'lot' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'producer' => ['type' => 'TEXT', 'null' => true],
'exp_date' => ['type' => 'DATE', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('control_id', true);
$this->forge->addForeignKey('dept_id', 'dict_depts', 'dept_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('dict_controls');
$this->forge->addField([
'test_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'dept_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'unit' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'method' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'cva' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'ba' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'tea' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('test_id', true);
$this->forge->addForeignKey('dept_id', 'dict_depts', 'dept_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('dict_tests');
$this->forge->addField([
'control_test_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'control_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'test_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'mean' => ['type' => 'FLOAT', 'null' => true],
'sd' => ['type' => 'FLOAT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('control_test_id', true);
$this->forge->addForeignKey('control_id', 'dict_controls', 'control_id', 'SET NULL', 'CASCADE');
$this->forge->addForeignKey('test_id', 'dict_tests', 'test_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('control_tests');
$this->forge->addField([
'result_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'control_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'test_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'res_date' => ['type' => 'DATETIME', 'null' => true],
'res_value' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'res_comment' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('result_id', true);
$this->forge->addForeignKey('control_id', 'dict_controls', 'control_id', 'SET NULL', 'CASCADE');
$this->forge->addForeignKey('test_id', 'dict_tests', 'test_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('results');
$this->forge->addField([
'result_comment_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'control_id' => ['type' => 'INT', 'unsigned' => true],
'test_id' => ['type' => 'INT', 'unsigned' => true],
'comment_month' => ['type' => 'VARCHAR', 'constraint' => 7],
'com_text' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('result_comment_id', true);
$this->forge->addUniqueKey(['control_id', 'test_id', 'comment_month']);
$this->forge->addForeignKey('control_id', 'dict_controls', 'control_id', 'CASCADE', 'CASCADE');
$this->forge->addForeignKey('test_id', 'dict_tests', 'test_id', 'CASCADE', 'CASCADE');
$this->forge->createTable('result_comments');
}
public function down() {
$this->forge->dropTable('result_comments', true);
$this->forge->dropTable('results', true);
$this->forge->dropTable('control_tests', true);
$this->forge->dropTable('dict_tests', true);
$this->forge->dropTable('dict_controls', true);
$this->forge->dropTable('dict_depts', true);
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class QualityControlSystem extends Migration
{
public function up()
{
$this->forge->addField([
'dept_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('dept_id', true);
$this->forge->createTable('master_depts');
$this->forge->addField([
'control_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'dept_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'lot' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'producer' => ['type' => 'TEXT', 'null' => true],
'exp_date' => ['type' => 'DATE', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('control_id', true);
$this->forge->addForeignKey('dept_id', 'master_depts', 'dept_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('master_controls');
$this->forge->addField([
'test_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'dept_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'unit' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'method' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'cva' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'ba' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'tea' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('test_id', true);
$this->forge->addForeignKey('dept_id', 'master_depts', 'dept_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('master_tests');
$this->forge->addField([
'control_test_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'control_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'test_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'mean' => ['type' => 'FLOAT', 'null' => true],
'sd' => ['type' => 'FLOAT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('control_test_id', true);
$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->createTable('control_tests');
$this->forge->addField([
'result_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'control_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'test_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'res_date' => ['type' => 'DATETIME', 'null' => true],
'res_value' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'res_comment' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('result_id', true);
$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->createTable('results');
$this->forge->addField([
'result_comment_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'control_id' => ['type' => 'INT', 'unsigned' => true],
'test_id' => ['type' => 'INT', 'unsigned' => true],
'comment_month' => ['type' => 'VARCHAR', 'constraint' => 7],
'com_text' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('result_comment_id', true);
$this->forge->addUniqueKey(['control_id', 'test_id', 'comment_month']);
$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');
}
public function down()
{
$this->forge->dropTable('result_comments', true);
$this->forge->dropTable('results', true);
$this->forge->dropTable('control_tests', true);
$this->forge->dropTable('master_tests', true);
$this->forge->dropTable('master_controls', true);
$this->forge->dropTable('master_depts', true);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class RenameDictToMasterTables extends Migration {
public function up() {
$db = \Config\Database::connect();
$db->query('SET FOREIGN_KEY_CHECKS=0');
$tables = $db->listTables();
if (in_array('dict_depts', $tables)) {
$db->query('RENAME TABLE dict_depts TO master_depts');
}
if (in_array('dict_controls', $tables)) {
$db->query('RENAME TABLE dict_controls TO master_controls');
}
if (in_array('dict_tests', $tables)) {
$db->query('RENAME TABLE dict_tests TO master_tests');
}
$db->query('SET FOREIGN_KEY_CHECKS=1');
}
public function down() {
$db = \Config\Database::connect();
$db->query('SET FOREIGN_KEY_CHECKS=0');
$tables = $db->listTables();
if (in_array('master_depts', $tables)) {
$db->query('RENAME TABLE master_depts TO dict_depts');
}
if (in_array('master_controls', $tables)) {
$db->query('RENAME TABLE master_controls TO dict_controls');
}
if (in_array('master_tests', $tables)) {
$db->query('RENAME TABLE master_tests TO dict_tests');
}
$db->query('SET FOREIGN_KEY_CHECKS=1');
}
}

View File

@ -0,0 +1,10 @@
<?php
$db = \Config\Database::connect();
echo "Current migrations table:\n";
$results = $db->query("SELECT * FROM migrations")->getResult();
print_r($results);
echo "\nChecking for dict_ tables:\n";
$tables = $db->listTables();
print_r($tables);

View File

@ -1,322 +0,0 @@
<?php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
class CmodQcSeeder extends Seeder
{
public function run()
{
$data = [
['control_test_id' => 440, 'control_ref_id' => 125, 'test_ref_id' => 18, 'mean' => 1.28, 'sd' => 0.64],
['control_test_id' => 441, 'control_ref_id' => 126, 'test_ref_id' => 18, 'mean' => 2.26, 'sd' => 1.13],
['control_test_id' => 541, 'control_ref_id' => 169, 'test_ref_id' => 18, 'mean' => 1.24, 'sd' => 0.155],
['control_test_id' => 443, 'control_ref_id' => 128, 'test_ref_id' => 20, 'mean' => 105, 'sd' => 14.6],
['control_test_id' => 565, 'control_ref_id' => 179, 'test_ref_id' => 15, 'mean' => 44, 'sd' => 2.2],
['control_test_id' => 566, 'control_ref_id' => 179, 'test_ref_id' => 16, 'mean' => 107, 'sd' => 5.35],
['control_test_id' => 482, 'control_ref_id' => 144, 'test_ref_id' => 3, 'mean' => 1.81, 'sd' => 0.1575],
['control_test_id' => 444, 'control_ref_id' => 127, 'test_ref_id' => 20, 'mean' => 26.8, 'sd' => 3.75],
['control_test_id' => 523, 'control_ref_id' => 163, 'test_ref_id' => 1, 'mean' => 49.8, 'sd' => 3.75],
['control_test_id' => 524, 'control_ref_id' => 163, 'test_ref_id' => 2, 'mean' => 56.4, 'sd' => 4.2],
['control_test_id' => 483, 'control_ref_id' => 144, 'test_ref_id' => 4, 'mean' => 4.17, 'sd' => 0.36],
['control_test_id' => 449, 'control_ref_id' => 133, 'test_ref_id' => 19, 'mean' => 12.55, 'sd' => 1.883],
['control_test_id' => 567, 'control_ref_id' => 179, 'test_ref_id' => 13, 'mean' => 168, 'sd' => 8.4],
['control_test_id' => 568, 'control_ref_id' => 179, 'test_ref_id' => 14, 'mean' => 124, 'sd' => 6.2],
['control_test_id' => 439, 'control_ref_id' => 124, 'test_ref_id' => 19, 'mean' => 3.03, 'sd' => 0.455],
['control_test_id' => 569, 'control_ref_id' => 180, 'test_ref_id' => 1, 'mean' => 49.5, 'sd' => 3.75],
['control_test_id' => 450, 'control_ref_id' => 134, 'test_ref_id' => 13, 'mean' => 166, 'sd' => 8.3],
['control_test_id' => 570, 'control_ref_id' => 180, 'test_ref_id' => 2, 'mean' => 56.4, 'sd' => 4.2],
['control_test_id' => 451, 'control_ref_id' => 134, 'test_ref_id' => 14, 'mean' => 127, 'sd' => 6.35],
['control_test_id' => 452, 'control_ref_id' => 134, 'test_ref_id' => 15, 'mean' => 43, 'sd' => 2.15],
['control_test_id' => 545, 'control_ref_id' => 173, 'test_ref_id' => 1, 'mean' => 150, 'sd' => 12],
['control_test_id' => 546, 'control_ref_id' => 173, 'test_ref_id' => 2, 'mean' => 137, 'sd' => 10.5],
['control_test_id' => 542, 'control_ref_id' => 170, 'test_ref_id' => 18, 'mean' => 2.02, 'sd' => 0.275],
['control_test_id' => 547, 'control_ref_id' => 173, 'test_ref_id' => 6, 'mean' => 126, 'sd' => 9],
['control_test_id' => 453, 'control_ref_id' => 134, 'test_ref_id' => 16, 'mean' => 105, 'sd' => 5.25],
['control_test_id' => 454, 'control_ref_id' => 135, 'test_ref_id' => 13, 'mean' => 232, 'sd' => 11.6],
['control_test_id' => 455, 'control_ref_id' => 135, 'test_ref_id' => 14, 'mean' => 179, 'sd' => 8.95],
['control_test_id' => 456, 'control_ref_id' => 135, 'test_ref_id' => 15, 'mean' => 61, 'sd' => 3.05],
['control_test_id' => 457, 'control_ref_id' => 135, 'test_ref_id' => 16, 'mean' => 146, 'sd' => 7.3],
['control_test_id' => 473, 'control_ref_id' => 143, 'test_ref_id' => 2, 'mean' => 137, 'sd' => 10.5],
['control_test_id' => 474, 'control_ref_id' => 143, 'test_ref_id' => 1, 'mean' => 146, 'sd' => 10.5],
['control_test_id' => 475, 'control_ref_id' => 143, 'test_ref_id' => 7, 'mean' => 4.3, 'sd' => 0.3225],
['control_test_id' => 476, 'control_ref_id' => 143, 'test_ref_id' => 9, 'mean' => 243, 'sd' => 19],
['control_test_id' => 489, 'control_ref_id' => 145, 'test_ref_id' => 37, 'mean' => 0.851, 'sd' => 0.113],
['control_test_id' => 480, 'control_ref_id' => 143, 'test_ref_id' => 5, 'mean' => 4.73, 'sd' => 2.4],
['control_test_id' => 481, 'control_ref_id' => 143, 'test_ref_id' => 36, 'mean' => 3.31, 'sd' => 0.12],
['control_test_id' => 484, 'control_ref_id' => 144, 'test_ref_id' => 10, 'mean' => 162, 'sd' => 20.25],
['control_test_id' => 485, 'control_ref_id' => 144, 'test_ref_id' => 11, 'mean' => 91.7, 'sd' => 6.7],
['control_test_id' => 580, 'control_ref_id' => 182, 'test_ref_id' => 17, 'mean' => 10.71, 'sd' => 0.25],
['control_test_id' => 429, 'control_ref_id' => 121, 'test_ref_id' => 3, 'mean' => 0.83, 'sd' => 0.073],
['control_test_id' => 430, 'control_ref_id' => 121, 'test_ref_id' => 4, 'mean' => 1.53, 'sd' => 0.13],
['control_test_id' => 431, 'control_ref_id' => 121, 'test_ref_id' => 10, 'mean' => 66.6, 'sd' => 5.575],
['control_test_id' => 432, 'control_ref_id' => 121, 'test_ref_id' => 11, 'mean' => 27.5, 'sd' => 2.025],
['control_test_id' => 582, 'control_ref_id' => 184, 'test_ref_id' => 10, 'mean' => 61.65, 'sd' => 5.125],
['control_test_id' => 434, 'control_ref_id' => 121, 'test_ref_id' => 30, 'mean' => 144, 'sd' => 14.5],
['control_test_id' => 583, 'control_ref_id' => 184, 'test_ref_id' => 3, 'mean' => 0.765, 'sd' => 0.0675],
['control_test_id' => 487, 'control_ref_id' => 144, 'test_ref_id' => 30, 'mean' => 220, 'sd' => 22],
['control_test_id' => 488, 'control_ref_id' => 143, 'test_ref_id' => 32, 'mean' => 8.07, 'sd' => 0.4],
['control_test_id' => 490, 'control_ref_id' => 145, 'test_ref_id' => 38, 'mean' => 1.15, 'sd' => 0.15],
['control_test_id' => 494, 'control_ref_id' => 145, 'test_ref_id' => 39, 'mean' => 6.69, 'sd' => 0.89],
['control_test_id' => 495, 'control_ref_id' => 146, 'test_ref_id' => 37, 'mean' => 23.219, 'sd' => 3.096],
['control_test_id' => 496, 'control_ref_id' => 146, 'test_ref_id' => 39, 'mean' => 15.24, 'sd' => 2.03],
['control_test_id' => 497, 'control_ref_id' => 146, 'test_ref_id' => 38, 'mean' => 3.81, 'sd' => 0.51],
['control_test_id' => 514, 'control_ref_id' => 153, 'test_ref_id' => 47, 'mean' => 0.08, 'sd' => 0.13],
['control_test_id' => 515, 'control_ref_id' => 154, 'test_ref_id' => 47, 'mean' => 4.91, 'sd' => 0.9],
['control_test_id' => 516, 'control_ref_id' => 158, 'test_ref_id' => 49, 'mean' => 22.34, 'sd' => 2.98],
['control_test_id' => 517, 'control_ref_id' => 159, 'test_ref_id' => 49, 'mean' => 63.25, 'sd' => 8.43],
['control_test_id' => 518, 'control_ref_id' => 155, 'test_ref_id' => 47, 'mean' => 7.6, 'sd' => 1.39],
['control_test_id' => 519, 'control_ref_id' => 156, 'test_ref_id' => 48, 'mean' => 0.01, 'sd' => 0.13],
['control_test_id' => 520, 'control_ref_id' => 157, 'test_ref_id' => 48, 'mean' => 4.62, 'sd' => 0.77],
['control_test_id' => 525, 'control_ref_id' => 163, 'test_ref_id' => 8, 'mean' => 5.16, 'sd' => 0.355],
['control_test_id' => 526, 'control_ref_id' => 163, 'test_ref_id' => 6, 'mean' => 41.1, 'sd' => 3.075],
['control_test_id' => 527, 'control_ref_id' => 163, 'test_ref_id' => 7, 'mean' => 1.07, 'sd' => 0.08],
['control_test_id' => 528, 'control_ref_id' => 163, 'test_ref_id' => 9, 'mean' => 101, 'sd' => 7.75],
['control_test_id' => 529, 'control_ref_id' => 163, 'test_ref_id' => 5, 'mean' => 3.16, 'sd' => 0.1575],
['control_test_id' => 530, 'control_ref_id' => 163, 'test_ref_id' => 32, 'mean' => 5.07, 'sd' => 0.2539],
['control_test_id' => 535, 'control_ref_id' => 163, 'test_ref_id' => 36, 'mean' => 2.2, 'sd' => 0.0838],
['control_test_id' => 536, 'control_ref_id' => 165, 'test_ref_id' => 17, 'mean' => 5.355, 'sd' => 0.15],
['control_test_id' => 537, 'control_ref_id' => 166, 'test_ref_id' => 17, 'mean' => 10.29, 'sd' => 0.25],
['control_test_id' => 548, 'control_ref_id' => 173, 'test_ref_id' => 7, 'mean' => 4.11, 'sd' => 0.305],
['control_test_id' => 549, 'control_ref_id' => 173, 'test_ref_id' => 8, 'mean' => 10.7, 'sd' => 0.75],
['control_test_id' => 550, 'control_ref_id' => 173, 'test_ref_id' => 9, 'mean' => 241, 'sd' => 18.75],
['control_test_id' => 551, 'control_ref_id' => 173, 'test_ref_id' => 5, 'mean' => 4.74, 'sd' => 0.36],
['control_test_id' => 553, 'control_ref_id' => 175, 'test_ref_id' => 19, 'mean' => 3.05, 'sd' => 0.457],
['control_test_id' => 554, 'control_ref_id' => 174, 'test_ref_id' => 19, 'mean' => 12.53, 'sd' => 1.88],
['control_test_id' => 555, 'control_ref_id' => 176, 'test_ref_id' => 43, 'mean' => 34.29, 'sd' => 2.8],
['control_test_id' => 559, 'control_ref_id' => 163, 'test_ref_id' => 53, 'mean' => 4.46, 'sd' => 0.22],
['control_test_id' => 560, 'control_ref_id' => 173, 'test_ref_id' => 53, 'mean' => 9.08, 'sd' => 0.47],
['control_test_id' => 577, 'control_ref_id' => 180, 'test_ref_id' => 32, 'mean' => 5.12, 'sd' => 0.256],
['control_test_id' => 584, 'control_ref_id' => 184, 'test_ref_id' => 4, 'mean' => 1.33, 'sd' => 0.115],
['control_test_id' => 585, 'control_ref_id' => 184, 'test_ref_id' => 11, 'mean' => 31.98, 'sd' => 2.325],
['control_test_id' => 586, 'control_ref_id' => 184, 'test_ref_id' => 30, 'mean' => 145, 'sd' => 14.5],
['control_test_id' => 587, 'control_ref_id' => 185, 'test_ref_id' => 17, 'mean' => 5.67, 'sd' => 0.15],
['control_test_id' => 588, 'control_ref_id' => 186, 'test_ref_id' => 17, 'mean' => 10.29, 'sd' => 0.25],
['control_test_id' => 477, 'control_ref_id' => 143, 'test_ref_id' => 8, 'mean' => 10.4, 'sd' => 0.7],
['control_test_id' => 478, 'control_ref_id' => 143, 'test_ref_id' => 6, 'mean' => 125, 'sd' => 9],
['control_test_id' => 499, 'control_ref_id' => 147, 'test_ref_id' => 40, 'mean' => 2.7, 'sd' => 0.36],
['control_test_id' => 500, 'control_ref_id' => 147, 'test_ref_id' => 41, 'mean' => 1.125, 'sd' => 0.15],
['control_test_id' => 501, 'control_ref_id' => 147, 'test_ref_id' => 42, 'mean' => 4.38, 'sd' => 0.58],
['control_test_id' => 502, 'control_ref_id' => 147, 'test_ref_id' => 43, 'mean' => 10.09, 'sd' => 1.35],
['control_test_id' => 503, 'control_ref_id' => 147, 'test_ref_id' => 44, 'mean' => 29.43, 'sd' => 3.92],
['control_test_id' => 592, 'control_ref_id' => 187, 'test_ref_id' => 3, 'mean' => 2.19, 'sd' => 0.19],
['control_test_id' => 362, 'control_ref_id' => 98, 'test_ref_id' => 3, 'mean' => 1.8, 'sd' => 0.232],
['control_test_id' => 363, 'control_ref_id' => 98, 'test_ref_id' => 4, 'mean' => 5.02, 'sd' => 0.652],
['control_test_id' => 469, 'control_ref_id' => 139, 'test_ref_id' => 20, 'mean' => 26.7, 'sd' => 3.75],
['control_test_id' => 593, 'control_ref_id' => 187, 'test_ref_id' => 4, 'mean' => 4.63, 'sd' => 0.403],
['control_test_id' => 594, 'control_ref_id' => 187, 'test_ref_id' => 10, 'mean' => 151, 'sd' => 12.5],
['control_test_id' => 595, 'control_ref_id' => 187, 'test_ref_id' => 11, 'mean' => 88.7, 'sd' => 6.58],
['control_test_id' => 596, 'control_ref_id' => 187, 'test_ref_id' => 30, 'mean' => 216, 'sd' => 21.5],
['control_test_id' => 470, 'control_ref_id' => 140, 'test_ref_id' => 20, 'mean' => 104.8, 'sd' => 14.6],
['control_test_id' => 531, 'control_ref_id' => 164, 'test_ref_id' => 13, 'mean' => 233, 'sd' => 11.65],
['control_test_id' => 532, 'control_ref_id' => 164, 'test_ref_id' => 14, 'mean' => 174, 'sd' => 8.7],
['control_test_id' => 557, 'control_ref_id' => 173, 'test_ref_id' => 36, 'mean' => 3.26, 'sd' => 0.18],
['control_test_id' => 533, 'control_ref_id' => 164, 'test_ref_id' => 15, 'mean' => 61, 'sd' => 3.05],
['control_test_id' => 534, 'control_ref_id' => 164, 'test_ref_id' => 16, 'mean' => 147, 'sd' => 7.35],
['control_test_id' => 384, 'control_ref_id' => 98, 'test_ref_id' => 11, 'mean' => 95.6, 'sd' => 10.625],
['control_test_id' => 581, 'control_ref_id' => 183, 'test_ref_id' => 18, 'mean' => 1.94, 'sd' => 0.26],
['control_test_id' => 579, 'control_ref_id' => 181, 'test_ref_id' => 17, 'mean' => 5.775, 'sd' => 0.15],
['control_test_id' => 365, 'control_ref_id' => 98, 'test_ref_id' => 10, 'mean' => 188, 'sd' => 23.5],
['control_test_id' => 367, 'control_ref_id' => 98, 'test_ref_id' => 21, 'mean' => 12.6, 'sd' => 0.675],
['control_test_id' => 368, 'control_ref_id' => 98, 'test_ref_id' => 29, 'mean' => 7.66, 'sd' => 0.69],
['control_test_id' => 369, 'control_ref_id' => 98, 'test_ref_id' => 31, 'mean' => 111, 'sd' => 11.025],
['control_test_id' => 370, 'control_ref_id' => 98, 'test_ref_id' => 30, 'mean' => 252, 'sd' => 25.25],
['control_test_id' => 505, 'control_ref_id' => 148, 'test_ref_id' => 40, 'mean' => 27, 'sd' => 3.6],
['control_test_id' => 506, 'control_ref_id' => 148, 'test_ref_id' => 41, 'mean' => 15.277, 'sd' => 2.037],
['control_test_id' => 507, 'control_ref_id' => 148, 'test_ref_id' => 42, 'mean' => 47.6, 'sd' => 6.35],
['control_test_id' => 508, 'control_ref_id' => 148, 'test_ref_id' => 43, 'mean' => 58.51, 'sd' => 7.8],
['control_test_id' => 509, 'control_ref_id' => 148, 'test_ref_id' => 44, 'mean' => 193.03, 'sd' => 25.74],
['control_test_id' => 510, 'control_ref_id' => 149, 'test_ref_id' => 45, 'mean' => 0.001, 'sd' => 0.017],
['control_test_id' => 511, 'control_ref_id' => 150, 'test_ref_id' => 45, 'mean' => 0.581, 'sd' => 0.077],
['control_test_id' => 512, 'control_ref_id' => 151, 'test_ref_id' => 46, 'mean' => 0, 'sd' => 1.67],
['control_test_id' => 513, 'control_ref_id' => 152, 'test_ref_id' => 46, 'mean' => 19.98, 'sd' => 3],
['control_test_id' => 538, 'control_ref_id' => 167, 'test_ref_id' => 52, 'mean' => 1.08, 'sd' => 0.1],
['control_test_id' => 539, 'control_ref_id' => 168, 'test_ref_id' => 39, 'mean' => 6.6485, 'sd' => 0.7125],
['control_test_id' => 540, 'control_ref_id' => 160, 'test_ref_id' => 50, 'mean' => 34.4, 'sd' => 3.51],
['control_test_id' => 552, 'control_ref_id' => 173, 'test_ref_id' => 32, 'mean' => 8.01, 'sd' => 0.6],
['control_test_id' => 572, 'control_ref_id' => 180, 'test_ref_id' => 9, 'mean' => 101.5, 'sd' => 7.75],
['control_test_id' => 578, 'control_ref_id' => 180, 'test_ref_id' => 5, 'mean' => 3.16, 'sd' => 0.158],
['control_test_id' => 571, 'control_ref_id' => 180, 'test_ref_id' => 7, 'mean' => 1.08, 'sd' => 0.0825],
['control_test_id' => 573, 'control_ref_id' => 180, 'test_ref_id' => 8, 'mean' => 5.225, 'sd' => 0.3525],
['control_test_id' => 574, 'control_ref_id' => 180, 'test_ref_id' => 6, 'mean' => 40.7, 'sd' => 3.05],
['control_test_id' => 590, 'control_ref_id' => 180, 'test_ref_id' => 53, 'mean' => 4.52, 'sd' => 0.22],
['control_test_id' => 591, 'control_ref_id' => 180, 'test_ref_id' => 36, 'mean' => 2.2, 'sd' => 0.083],
];
$this->db->table('control_tests')->insertBatch($data);
$data = [
['result_id' => 28, 'control_ref_id' => 1, 'test_ref_id' => 1, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '152.868220727426', 'rescomment' => null],
['result_id' => 29, 'control_ref_id' => 1, 'test_ref_id' => 2, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '118.684360070769', 'rescomment' => null],
['result_id' => 30, 'control_ref_id' => 1, 'test_ref_id' => 3, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '1.78676744546881', 'rescomment' => null],
['result_id' => 31, 'control_ref_id' => 1, 'test_ref_id' => 4, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '4.88788440216215', 'rescomment' => null],
['result_id' => 32, 'control_ref_id' => 1, 'test_ref_id' => 5, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '4.54649638396286', 'rescomment' => null],
['result_id' => 33, 'control_ref_id' => 1, 'test_ref_id' => 6, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '68.9277965359498', 'rescomment' => null],
['result_id' => 34, 'control_ref_id' => 1, 'test_ref_id' => 7, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '4.7616948568887', 'rescomment' => null],
['result_id' => 35, 'control_ref_id' => 1, 'test_ref_id' => 8, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '8.2805131289407', 'rescomment' => null],
['result_id' => 36, 'control_ref_id' => 1, 'test_ref_id' => 9, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '255.718750547819', 'rescomment' => null],
['result_id' => 37, 'control_ref_id' => 1, 'test_ref_id' => 10, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '185.86621742091', 'rescomment' => null],
['result_id' => 38, 'control_ref_id' => 1, 'test_ref_id' => 11, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '96.0370738667909', 'rescomment' => null],
['result_id' => 39, 'control_ref_id' => 1, 'test_ref_id' => 12, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '7.26130670682815', 'rescomment' => null],
['result_id' => 40, 'control_ref_id' => 2, 'test_ref_id' => 13, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '158.320085275538', 'rescomment' => null],
['result_id' => 41, 'control_ref_id' => 2, 'test_ref_id' => 14, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '123.642030022172', 'rescomment' => null],
['result_id' => 42, 'control_ref_id' => 2, 'test_ref_id' => 15, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '36.9505985250087', 'rescomment' => null],
['result_id' => 43, 'control_ref_id' => 2, 'test_ref_id' => 16, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '96.5175241884342', 'rescomment' => null],
['result_id' => 44, 'control_ref_id' => 2, 'test_ref_id' => 14, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '110.353601999312', 'rescomment' => null],
['result_id' => 45, 'control_ref_id' => 2, 'test_ref_id' => 15, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '40.9599586941077', 'rescomment' => null],
['result_id' => 46, 'control_ref_id' => 3, 'test_ref_id' => 17, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '1528049849.3', 'rescomment' => null],
['result_id' => 47, 'control_ref_id' => 3, 'test_ref_id' => 17, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '5.1', 'rescomment' => null],
['result_id' => 48, 'control_ref_id' => 4, 'test_ref_id' => 13, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '215.383147793402', 'rescomment' => null],
['result_id' => 49, 'control_ref_id' => 4, 'test_ref_id' => 14, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '183.063991935605', 'rescomment' => null],
['result_id' => 50, 'control_ref_id' => 4, 'test_ref_id' => 15, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '52.9496711121656', 'rescomment' => null],
['result_id' => 51, 'control_ref_id' => 4, 'test_ref_id' => 16, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '133.934963501757', 'rescomment' => null],
['result_id' => 52, 'control_ref_id' => 4, 'test_ref_id' => 14, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '158.484164984758', 'rescomment' => null],
['result_id' => 53, 'control_ref_id' => 4, 'test_ref_id' => 15, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '56.8818601408997', 'rescomment' => null],
['result_id' => 54, 'control_ref_id' => 5, 'test_ref_id' => 17, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '1518899850.3', 'rescomment' => null],
['result_id' => 55, 'control_ref_id' => 5, 'test_ref_id' => 17, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '10.5', 'rescomment' => null],
['result_id' => 56, 'control_ref_id' => 5, 'test_ref_id' => 17, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '10.5', 'rescomment' => null],
['result_id' => 57, 'control_ref_id' => 6, 'test_ref_id' => 1, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '48.9562924866303', 'rescomment' => null],
['result_id' => 58, 'control_ref_id' => 6, 'test_ref_id' => 2, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '52.1440402715625', 'rescomment' => null],
['result_id' => 59, 'control_ref_id' => 6, 'test_ref_id' => 3, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '0.827525226237078', 'rescomment' => null],
['result_id' => 60, 'control_ref_id' => 6, 'test_ref_id' => 4, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '1.43268122033837', 'rescomment' => null],
['result_id' => 61, 'control_ref_id' => 6, 'test_ref_id' => 5, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '3.32791020761993', 'rescomment' => null],
['result_id' => 62, 'control_ref_id' => 6, 'test_ref_id' => 6, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '20.1899916884103', 'rescomment' => null],
['result_id' => 63, 'control_ref_id' => 6, 'test_ref_id' => 7, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '0.942164469720469', 'rescomment' => null],
['result_id' => 64, 'control_ref_id' => 6, 'test_ref_id' => 8, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '4.92672863627875', 'rescomment' => null],
['result_id' => 65, 'control_ref_id' => 6, 'test_ref_id' => 9, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '97.9752041498986', 'rescomment' => null],
['result_id' => 66, 'control_ref_id' => 6, 'test_ref_id' => 10, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '82', 'rescomment' => null],
['result_id' => 67, 'control_ref_id' => 6, 'test_ref_id' => 11, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '55.6067623790927', 'rescomment' => null],
['result_id' => 68, 'control_ref_id' => 7, 'test_ref_id' => 18, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '1.3534129858017', 'rescomment' => null],
['result_id' => 69, 'control_ref_id' => 8, 'test_ref_id' => 18, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '2.92454624176025', 'rescomment' => null],
['result_id' => 70, 'control_ref_id' => 9, 'test_ref_id' => 1, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '41.9241811453099', 'rescomment' => null],
['result_id' => 71, 'control_ref_id' => 9, 'test_ref_id' => 2, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '50.2606047539432', 'rescomment' => null],
['result_id' => 72, 'control_ref_id' => 9, 'test_ref_id' => 3, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '0.618850628140718', 'rescomment' => null],
['result_id' => 73, 'control_ref_id' => 9, 'test_ref_id' => 4, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '1.64659256568823', 'rescomment' => null],
['result_id' => 74, 'control_ref_id' => 9, 'test_ref_id' => 5, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '3.37936528279952', 'rescomment' => null],
['result_id' => 75, 'control_ref_id' => 9, 'test_ref_id' => 6, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '19.9687141929191', 'rescomment' => null],
['result_id' => 76, 'control_ref_id' => 9, 'test_ref_id' => 7, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '1.25401465739322', 'rescomment' => null],
['result_id' => 77, 'control_ref_id' => 9, 'test_ref_id' => 8, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '5.20068765776226', 'rescomment' => null],
['result_id' => 78, 'control_ref_id' => 9, 'test_ref_id' => 9, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '85.8656412403935', 'rescomment' => null],
['result_id' => 79, 'control_ref_id' => 9, 'test_ref_id' => 10, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '73.7679469620088', 'rescomment' => null],
['result_id' => 80, 'control_ref_id' => 9, 'test_ref_id' => 11, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '32.6032920741649', 'rescomment' => null],
['result_id' => 81, 'control_ref_id' => 9, 'test_ref_id' => 12, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '4.60016347657495', 'rescomment' => null],
['result_id' => 82, 'control_ref_id' => 10, 'test_ref_id' => 19, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '3.07556349669703', 'rescomment' => null],
['result_id' => 83, 'control_ref_id' => 11, 'test_ref_id' => 19, 'resdate' => '2022-01-03 00:00:00', 'resvalue' => '14.2416023954004', 'rescomment' => null],
['result_id' => 84, 'control_ref_id' => 1, 'test_ref_id' => 1, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '151.987144896542', 'rescomment' => null],
['result_id' => 85, 'control_ref_id' => 1, 'test_ref_id' => 2, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '118.037436722317', 'rescomment' => null],
['result_id' => 86, 'control_ref_id' => 1, 'test_ref_id' => 3, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '1.7947151556153', 'rescomment' => null],
['result_id' => 87, 'control_ref_id' => 1, 'test_ref_id' => 4, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '4.73906612533412', 'rescomment' => null],
['result_id' => 88, 'control_ref_id' => 1, 'test_ref_id' => 5, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '4.60361234521986', 'rescomment' => null],
['result_id' => 89, 'control_ref_id' => 1, 'test_ref_id' => 6, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '72.6643107473908', 'rescomment' => null],
['result_id' => 90, 'control_ref_id' => 1, 'test_ref_id' => 7, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '4.63980962604914', 'rescomment' => null],
['result_id' => 91, 'control_ref_id' => 1, 'test_ref_id' => 8, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '8.38141623646488', 'rescomment' => null],
['result_id' => 92, 'control_ref_id' => 1, 'test_ref_id' => 9, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '264.978406417832', 'rescomment' => null],
['result_id' => 93, 'control_ref_id' => 1, 'test_ref_id' => 10, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '181.555224534248', 'rescomment' => null],
['result_id' => 94, 'control_ref_id' => 1, 'test_ref_id' => 11, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '96.2859292539928', 'rescomment' => null],
['result_id' => 95, 'control_ref_id' => 1, 'test_ref_id' => 12, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '6.9194402361869', 'rescomment' => null],
['result_id' => 96, 'control_ref_id' => 1, 'test_ref_id' => 2, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '110.231586595837', 'rescomment' => null],
['result_id' => 97, 'control_ref_id' => 1, 'test_ref_id' => 8, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '7.83563261140536', 'rescomment' => null],
['result_id' => 98, 'control_ref_id' => 2, 'test_ref_id' => 13, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '162.230018707428', 'rescomment' => null],
['result_id' => 99, 'control_ref_id' => 2, 'test_ref_id' => 14, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '130.52137139783', 'rescomment' => null],
['result_id' => 100, 'control_ref_id' => 2, 'test_ref_id' => 15, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '40.1447263115978', 'rescomment' => null],
['result_id' => 101, 'control_ref_id' => 2, 'test_ref_id' => 16, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '101.152360213402', 'rescomment' => null],
['result_id' => 102, 'control_ref_id' => 2, 'test_ref_id' => 14, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '102.256365254374', 'rescomment' => null],
['result_id' => 103, 'control_ref_id' => 2, 'test_ref_id' => 14, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '104.629167708808', 'rescomment' => null],
['result_id' => 104, 'control_ref_id' => 2, 'test_ref_id' => 14, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '82.6663002141555', 'rescomment' => null],
['result_id' => 105, 'control_ref_id' => 2, 'test_ref_id' => 14, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '116.848292060971', 'rescomment' => null],
['result_id' => 106, 'control_ref_id' => 3, 'test_ref_id' => 17, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '6', 'rescomment' => null],
['result_id' => 107, 'control_ref_id' => 3, 'test_ref_id' => 17, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '4.9', 'rescomment' => null],
['result_id' => 108, 'control_ref_id' => 4, 'test_ref_id' => 13, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '225.968857118997', 'rescomment' => null],
['result_id' => 109, 'control_ref_id' => 4, 'test_ref_id' => 14, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '174.601513698878', 'rescomment' => null],
['result_id' => 110, 'control_ref_id' => 4, 'test_ref_id' => 15, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '60.5069551717738', 'rescomment' => null],
['result_id' => 111, 'control_ref_id' => 4, 'test_ref_id' => 16, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '139.447942846202', 'rescomment' => null],
['result_id' => 112, 'control_ref_id' => 4, 'test_ref_id' => 14, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '143.31010007369', 'rescomment' => null],
['result_id' => 113, 'control_ref_id' => 4, 'test_ref_id' => 14, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '144.761372351536', 'rescomment' => null],
['result_id' => 114, 'control_ref_id' => 4, 'test_ref_id' => 14, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '115.071814150515', 'rescomment' => null],
['result_id' => 115, 'control_ref_id' => 4, 'test_ref_id' => 14, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '161.317934555282', 'rescomment' => null],
['result_id' => 116, 'control_ref_id' => 5, 'test_ref_id' => 17, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '10.2', 'rescomment' => null],
['result_id' => 117, 'control_ref_id' => 6, 'test_ref_id' => 1, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '49.5516111762261', 'rescomment' => null],
['result_id' => 118, 'control_ref_id' => 6, 'test_ref_id' => 2, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '52.3007836764823', 'rescomment' => null],
['result_id' => 119, 'control_ref_id' => 6, 'test_ref_id' => 3, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '0.826492230714472', 'rescomment' => null],
['result_id' => 120, 'control_ref_id' => 6, 'test_ref_id' => 4, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '1.4488869704763', 'rescomment' => null],
['result_id' => 121, 'control_ref_id' => 6, 'test_ref_id' => 5, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '3.44673661899837', 'rescomment' => null],
['result_id' => 122, 'control_ref_id' => 6, 'test_ref_id' => 6, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '20.730181610828', 'rescomment' => null],
['result_id' => 123, 'control_ref_id' => 6, 'test_ref_id' => 7, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '0.931063374396039', 'rescomment' => null],
['result_id' => 124, 'control_ref_id' => 6, 'test_ref_id' => 8, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '4.90811693843901', 'rescomment' => null],
['result_id' => 125, 'control_ref_id' => 6, 'test_ref_id' => 9, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '101.930759918121', 'rescomment' => null],
['result_id' => 126, 'control_ref_id' => 6, 'test_ref_id' => 10, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '75.5729386133723', 'rescomment' => null],
['result_id' => 127, 'control_ref_id' => 6, 'test_ref_id' => 11, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '56.5900268982851', 'rescomment' => null],
['result_id' => 128, 'control_ref_id' => 6, 'test_ref_id' => 10, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '102.528037735786', 'rescomment' => null],
['result_id' => 129, 'control_ref_id' => 12, 'test_ref_id' => 20, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '51.0905355215073', 'rescomment' => null],
['result_id' => 130, 'control_ref_id' => 12, 'test_ref_id' => 20, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '37.0758876204491', 'rescomment' => null],
['result_id' => 131, 'control_ref_id' => 13, 'test_ref_id' => 20, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '104.115432500839', 'rescomment' => null],
['result_id' => 132, 'control_ref_id' => 13, 'test_ref_id' => 20, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '126.745772361755', 'rescomment' => null],
['result_id' => 133, 'control_ref_id' => 9, 'test_ref_id' => 1, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '44.2766951686621', 'rescomment' => null],
['result_id' => 134, 'control_ref_id' => 9, 'test_ref_id' => 2, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '52.3172542490699', 'rescomment' => null],
['result_id' => 135, 'control_ref_id' => 9, 'test_ref_id' => 3, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '0.611564331171467', 'rescomment' => null],
['result_id' => 136, 'control_ref_id' => 9, 'test_ref_id' => 4, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '1.61071374749995', 'rescomment' => null],
['result_id' => 137, 'control_ref_id' => 9, 'test_ref_id' => 5, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '3.40677107339127', 'rescomment' => null],
['result_id' => 138, 'control_ref_id' => 9, 'test_ref_id' => 6, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '20.3040445233696', 'rescomment' => null],
['result_id' => 139, 'control_ref_id' => 9, 'test_ref_id' => 7, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '1.18843758036108', 'rescomment' => null],
['result_id' => 140, 'control_ref_id' => 9, 'test_ref_id' => 8, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '5.30604556512929', 'rescomment' => null],
['result_id' => 141, 'control_ref_id' => 9, 'test_ref_id' => 9, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '89.4499417480113', 'rescomment' => null],
['result_id' => 142, 'control_ref_id' => 9, 'test_ref_id' => 10, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '74.0960687372425', 'rescomment' => null],
['result_id' => 143, 'control_ref_id' => 9, 'test_ref_id' => 11, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '32.4566384751244', 'rescomment' => null],
['result_id' => 144, 'control_ref_id' => 9, 'test_ref_id' => 12, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '4.48437909776323', 'rescomment' => null],
['result_id' => 145, 'control_ref_id' => 9, 'test_ref_id' => 2, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '50.9667682640676', 'rescomment' => null],
['result_id' => 146, 'control_ref_id' => 9, 'test_ref_id' => 8, 'resdate' => '2022-01-04 00:00:00', 'resvalue' => '4.93310398630639', 'rescomment' => null],
['result_id' => 147, 'control_ref_id' => 1, 'test_ref_id' => 1, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '145.49911151053', 'rescomment' => null],
['result_id' => 148, 'control_ref_id' => 1, 'test_ref_id' => 2, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '113.383579655159', 'rescomment' => null],
['result_id' => 149, 'control_ref_id' => 1, 'test_ref_id' => 3, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '1.78951109759924', 'rescomment' => null],
['result_id' => 150, 'control_ref_id' => 1, 'test_ref_id' => 4, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '4.93788942107251', 'rescomment' => null],
['result_id' => 151, 'control_ref_id' => 1, 'test_ref_id' => 5, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '4.6838893149483', 'rescomment' => null],
['result_id' => 152, 'control_ref_id' => 1, 'test_ref_id' => 6, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '71.2844487736166', 'rescomment' => null],
['result_id' => 153, 'control_ref_id' => 1, 'test_ref_id' => 7, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '4.66921401448631', 'rescomment' => null],
['result_id' => 154, 'control_ref_id' => 1, 'test_ref_id' => 8, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '7.75508570982025', 'rescomment' => null],
['result_id' => 155, 'control_ref_id' => 1, 'test_ref_id' => 9, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '259.337398768362', 'rescomment' => null],
['result_id' => 156, 'control_ref_id' => 1, 'test_ref_id' => 10, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '237.342516597661', 'rescomment' => null],
['result_id' => 157, 'control_ref_id' => 1, 'test_ref_id' => 11, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '95.377689131424', 'rescomment' => null],
['result_id' => 158, 'control_ref_id' => 1, 'test_ref_id' => 12, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '6.33840481787399', 'rescomment' => null],
['result_id' => 159, 'control_ref_id' => 1, 'test_ref_id' => 10, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '254.058423430058', 'rescomment' => null],
['result_id' => 160, 'control_ref_id' => 1, 'test_ref_id' => 12, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '7.61797516866643', 'rescomment' => null],
['result_id' => 161, 'control_ref_id' => 1, 'test_ref_id' => 10, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '243.755676108857', 'rescomment' => null],
['result_id' => 162, 'control_ref_id' => 1, 'test_ref_id' => 10, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '193.063562956371', 'rescomment' => null],
['result_id' => 163, 'control_ref_id' => 2, 'test_ref_id' => 13, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '156.243798489035', 'rescomment' => null],
['result_id' => 164, 'control_ref_id' => 2, 'test_ref_id' => 14, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '119.907027927493', 'rescomment' => null],
['result_id' => 165, 'control_ref_id' => 2, 'test_ref_id' => 15, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '40.447059699393', 'rescomment' => null],
['result_id' => 166, 'control_ref_id' => 2, 'test_ref_id' => 16, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '102.231291242292', 'rescomment' => null],
['result_id' => 167, 'control_ref_id' => 3, 'test_ref_id' => 17, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '5.3', 'rescomment' => null],
['result_id' => 168, 'control_ref_id' => 3, 'test_ref_id' => 17, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '1921499810', 'rescomment' => null],
['result_id' => 169, 'control_ref_id' => 3, 'test_ref_id' => 17, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '5.1', 'rescomment' => null],
['result_id' => 170, 'control_ref_id' => 4, 'test_ref_id' => 13, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '219.038828305261', 'rescomment' => null],
['result_id' => 171, 'control_ref_id' => 4, 'test_ref_id' => 14, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '166.155678322747', 'rescomment' => null],
['result_id' => 172, 'control_ref_id' => 4, 'test_ref_id' => 15, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '56.8925909121002', 'rescomment' => null],
['result_id' => 173, 'control_ref_id' => 4, 'test_ref_id' => 16, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '140.823342734069', 'rescomment' => null],
['result_id' => 174, 'control_ref_id' => 5, 'test_ref_id' => 17, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '10.2', 'rescomment' => null],
['result_id' => 175, 'control_ref_id' => 6, 'test_ref_id' => 1, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '48.9587059015567', 'rescomment' => null],
['result_id' => 176, 'control_ref_id' => 6, 'test_ref_id' => 2, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '49.1468267441428', 'rescomment' => null],
['result_id' => 177, 'control_ref_id' => 6, 'test_ref_id' => 3, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '0.799690212662777', 'rescomment' => null],
['result_id' => 178, 'control_ref_id' => 6, 'test_ref_id' => 4, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '1.45552498470141', 'rescomment' => null],
['result_id' => 179, 'control_ref_id' => 6, 'test_ref_id' => 5, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '3.41160793396446', 'rescomment' => null],
['result_id' => 180, 'control_ref_id' => 6, 'test_ref_id' => 6, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '20.2910405834124', 'rescomment' => null],
['result_id' => 181, 'control_ref_id' => 6, 'test_ref_id' => 7, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '0.94094934133914', 'rescomment' => null],
['result_id' => 182, 'control_ref_id' => 6, 'test_ref_id' => 8, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '4.49152415745592', 'rescomment' => null],
['result_id' => 183, 'control_ref_id' => 6, 'test_ref_id' => 9, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '100.024475839182', 'rescomment' => null],
['result_id' => 184, 'control_ref_id' => 6, 'test_ref_id' => 10, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '85.9679935713317', 'rescomment' => null],
['result_id' => 185, 'control_ref_id' => 6, 'test_ref_id' => 11, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '53.8509856427683', 'rescomment' => null],
['result_id' => 186, 'control_ref_id' => 9, 'test_ref_id' => 1, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '42.7806264740846', 'rescomment' => null],
['result_id' => 187, 'control_ref_id' => 9, 'test_ref_id' => 2, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '51.9985050454933', 'rescomment' => null],
['result_id' => 188, 'control_ref_id' => 9, 'test_ref_id' => 3, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '0.649795363795779', 'rescomment' => null],
['result_id' => 189, 'control_ref_id' => 9, 'test_ref_id' => 4, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '1.80260045112525', 'rescomment' => null],
['result_id' => 190, 'control_ref_id' => 9, 'test_ref_id' => 5, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '3.35288773545917', 'rescomment' => null],
['result_id' => 191, 'control_ref_id' => 9, 'test_ref_id' => 6, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '20.6904621653494', 'rescomment' => null],
['result_id' => 192, 'control_ref_id' => 9, 'test_ref_id' => 7, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '1.19703451489628', 'rescomment' => null],
['result_id' => 193, 'control_ref_id' => 9, 'test_ref_id' => 8, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '4.89184138396658', 'rescomment' => null],
['result_id' => 194, 'control_ref_id' => 9, 'test_ref_id' => 9, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '91.9123811518993', 'rescomment' => null],
['result_id' => 195, 'control_ref_id' => 9, 'test_ref_id' => 10, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '100.544617296457', 'rescomment' => null],
['result_id' => 196, 'control_ref_id' => 9, 'test_ref_id' => 11, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '31.390065068111', 'rescomment' => null],
['result_id' => 197, 'control_ref_id' => 9, 'test_ref_id' => 12, 'resdate' => '2022-01-05 00:00:00', 'resvalue' => '4.17808071506795', 'rescomment' => null],
];
$this->db->table('results')->insertBatch($data);
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class ControlModel extends BaseModel
{
protected $table = 'dict_controls';
protected $primaryKey = 'control_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['dept_ref_id', 'name', 'lot', 'producer', 'expdate'];
public function getByDept($deptId)
{
return $this->where('dept_ref_id', $deptId)->findAll();
}
public function getWithDept()
{
$builder = $this->db->table('dict_controls c');
$builder->select('c.*, d.name as dept_name');
$builder->join('dict_depts d', 'd.dept_id = c.dept_ref_id', 'left');
return $builder->get()->getResultArray();
}
public function getActiveByDate($date, $deptId = null)
{
$builder = $this->db->table('dict_controls c');
$builder->select('c.*');
$builder->where('c.expdate >=', $date);
if ($deptId) {
$builder->where('c.dept_ref_id', $deptId);
}
$builder->orderBy('c.name', 'ASC');
return $builder->get()->getResultArray();
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class ControlTestModel extends BaseModel
{
protected $table = 'control_tests';
protected $primaryKey = 'control_test_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['control_ref_id', 'test_ref_id', 'mean', 'sd'];
public function getByControl($controlId)
{
$builder = $this->db->table('control_tests ct');
$builder->select('ct.*, t.name as test_name, t.unit');
$builder->join('dict_tests t', 't.test_id = ct.test_ref_id');
$builder->where('ct.control_ref_id', $controlId);
return $builder->get()->getResultArray();
}
public function getByControlAndTest($controlId, $testId)
{
return $this->where('control_ref_id', $controlId)
->where('test_ref_id', $testId)
->first();
}
}

View File

@ -1,61 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class DailyResultModel extends BaseModel
{
protected $table = 'daily_result';
protected $primaryKey = 'daily_result_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['control_ref_id', 'test_ref_id', 'resdate', 'resvalue', 'rescomment'];
public function getByMonth($controlId, $testId, $yearMonth)
{
$startDate = $yearMonth . '-01';
$endDate = $yearMonth . '-31';
$builder = $this->db->table('daily_result');
$builder->select('*');
$builder->where('control_ref_id', $controlId);
$builder->where('test_ref_id', $testId);
$builder->where('resdate >=', $startDate);
$builder->where('resdate <=', $endDate);
$builder->orderBy('resdate', 'ASC');
return $builder->get()->getResultArray();
}
public function getByControlMonth($controlId, $yearMonth)
{
$startDate = $yearMonth . '-01';
$endDate = $yearMonth . '-31';
$builder = $this->db->table('daily_result');
$builder->select('*');
$builder->where('control_ref_id', $controlId);
$builder->where('resdate >=', $startDate);
$builder->where('resdate <=', $endDate);
return $builder->get()->getResultArray();
}
public function saveResult($data)
{
$builder = $this->db->table('daily_result');
$existing = $builder->select('*')
->where('control_ref_id', $data['control_ref_id'])
->where('test_ref_id', $data['test_ref_id'])
->where('resdate', $data['resdate'])
->get()
->getRowArray();
if ($existing) {
return $builder->where('daily_result_id', $existing['daily_result_id'])->update($data);
} else {
return $builder->insert($data);
}
}
}

View File

@ -1,16 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class DeptModel extends BaseModel
{
protected $table = 'dict_depts';
protected $primaryKey = 'dept_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['name'];
}

View File

@ -1,53 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class DictControlModel extends BaseModel
{
protected $table = 'dict_controls';
protected $primaryKey = 'control_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['dept_ref_id', 'name', 'lot', 'producer', 'expdate'];
public function getByDept($deptId)
{
return $this->where('dept_ref_id', $deptId)->findAll();
}
public function getWithDept($keyword = null, $deptId = null)
{
$builder = $this->db->table('dict_controls c');
$builder->select('c.*, d.name as dept_name');
$builder->join('dict_depts d', 'd.dept_id = c.dept_ref_id', 'left');
if ($keyword) {
$builder->groupStart();
$builder->like('c.name', $keyword);
$builder->orLike('c.lot', $keyword);
$builder->groupEnd();
}
if ($deptId) {
$builder->where('c.dept_ref_id', $deptId);
}
return $builder->get()->getResultArray();
}
public function getActiveByDate($date, $deptId = null)
{
$builder = $this->db->table('dict_controls c');
$builder->select('c.*');
$builder->where('c.expdate >=', $date);
if ($deptId) {
$builder->where('c.dept_ref_id', $deptId);
}
$builder->orderBy('c.name', 'ASC');
return $builder->get()->getResultArray();
}
}

View File

@ -1,16 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class DictDeptModel extends BaseModel
{
protected $table = 'dict_depts';
protected $primaryKey = 'dept_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['name'];
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class DictTestModel extends BaseModel
{
protected $table = 'dict_tests';
protected $primaryKey = 'test_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['dept_ref_id', 'name', 'unit', 'method', 'cva', 'ba', 'tea'];
public function getByDept($deptId)
{
return $this->where('dept_ref_id', $deptId)->findAll();
}
public function getWithDept()
{
$builder = $this->db->table('dict_tests t');
$builder->select('t.*, d.name as dept_name');
$builder->join('dict_depts d', 'd.dept_id = t.dept_ref_id', 'left');
return $builder->get()->getResultArray();
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models\Master;
use App\Models\BaseModel;
class MasterControlsModel extends BaseModel {
protected $table = 'master_controls';
protected $primaryKey = 'control_id';
protected $allowedFields = [
'dept_id',
'name',
'lot',
'producer',
'exp_date',
'created_at',
'updated_at',
'deleted_at'
];
protected $useTimestamps = true;
protected $useSoftDeletes = true;
public function search($keyword = null) {
if ($keyword) {
return $this->groupStart()
->like('name', $keyword)
->orLike('lot', $keyword)
->groupEnd()
->findAll();
}
return $this->findAll();
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models\Master;
use App\Models\BaseModel;
class MasterDeptsModel extends BaseModel {
protected $table = 'master_depts';
protected $primaryKey = 'dept_id';
protected $allowedFields = [
'name',
'created_at',
'updated_at',
'deleted_at'
];
protected $useTimestamps = true;
protected $useSoftDeletes = true;
public function search($keyword = null) {
if ($keyword) {
return $this->groupStart()
->like('name', $keyword)
->groupEnd()
->findAll();
}
return $this->findAll();
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models\Master;
use App\Models\BaseModel;
class MasterTestsModel extends BaseModel {
protected $table = 'master_tests';
protected $primaryKey = 'test_id';
protected $allowedFields = [
'dept_id',
'name',
'unit',
'method',
'cva',
'ba',
'tea',
'created_at',
'updated_at',
'deleted_at'
];
protected $useTimestamps = true;
protected $useSoftDeletes = true;
public function search($keyword = null) {
if ($keyword) {
return $this->groupStart()
->like('name', $keyword)
->groupEnd()
->findAll();
}
return $this->findAll();
}
}

View File

@ -1,38 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class MonthlyCommentModel extends BaseModel
{
protected $table = 'monthly_comment';
protected $primaryKey = 'monthly_comment_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['control_ref_id', 'test_ref_id', 'commonth', 'comtext'];
public function getByControlTestMonth($controlId, $testId, $yearMonth)
{
return $this->where('control_ref_id', $controlId)
->where('test_ref_id', $testId)
->where('commonth', $yearMonth)
->first();
}
public function saveComment($data)
{
$existing = $this->where('control_ref_id', $data['control_ref_id'])
->where('test_ref_id', $data['test_ref_id'])
->where('commonth', $data['commonth'])
->first();
if ($existing) {
return $this->update($existing['monthly_comment_id'], $data);
} else {
return $this->insert($data);
}
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models\Qc;
use App\Models\BaseModel;
class ControlTestsModel extends BaseModel {
protected $table = 'control_tests';
protected $primaryKey = 'control_test_id';
protected $allowedFields = [
'control_id',
'test_id',
'mean',
'sd',
'created_at',
'updated_at',
'deleted_at'
];
protected $useTimestamps = true;
protected $useSoftDeletes = true;
public function search($keyword = null) {
if ($keyword) {
return $this->groupStart()
->like('mean', $keyword)
->groupEnd()
->findAll();
}
return $this->findAll();
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models\Qc;
use App\Models\BaseModel;
class ResultCommentsModel extends BaseModel {
protected $table = 'result_comments';
protected $primaryKey = 'result_comment_id';
protected $allowedFields = [
'control_id',
'test_id',
'comment_month',
'com_text',
'created_at',
'updated_at',
'deleted_at'
];
protected $useTimestamps = true;
protected $useSoftDeletes = true;
public function search($keyword = null) {
if ($keyword) {
return $this->groupStart()
->like('comment_month', $keyword)
->orLike('com_text', $keyword)
->groupEnd()
->findAll();
}
return $this->findAll();
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models\Qc;
use App\Models\BaseModel;
class ResultsModel extends BaseModel {
protected $table = 'results';
protected $primaryKey = 'result_id';
protected $allowedFields = [
'control_id',
'test_id',
'res_date',
'res_value',
'res_comment',
'created_at',
'updated_at',
'deleted_at'
];
protected $useTimestamps = true;
protected $useSoftDeletes = true;
public function search($keyword = null) {
if ($keyword) {
return $this->groupStart()
->like('res_value', $keyword)
->groupEnd()
->findAll();
}
return $this->findAll();
}
}

View File

@ -1,38 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class ResultCommentModel extends BaseModel
{
protected $table = 'result_comments';
protected $primaryKey = 'result_comment_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['control_ref_id', 'test_ref_id', 'commonth', 'comtext'];
public function getByControlTestMonth($controlId, $testId, $yearMonth)
{
return $this->where('control_ref_id', $controlId)
->where('test_ref_id', $testId)
->where('commonth', $yearMonth)
->first();
}
public function saveComment($data)
{
$existing = $this->where('control_ref_id', $data['control_ref_id'])
->where('test_ref_id', $data['test_ref_id'])
->where('commonth', $data['commonth'])
->first();
if ($existing) {
return $this->update($existing['result_comment_id'], $data);
} else {
return $this->insert($data);
}
}
}

View File

@ -1,72 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class ResultModel extends BaseModel
{
protected $table = 'results';
protected $primaryKey = 'result_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['control_ref_id', 'test_ref_id', 'resdate', 'resvalue', 'rescomment'];
public function getByMonth($controlId, $testId, $yearMonth)
{
$startDate = $yearMonth . '-01';
$endDate = $yearMonth . '-31';
$builder = $this->db->table('results');
$builder->select('*');
$builder->where('control_ref_id', $controlId);
$builder->where('test_ref_id', $testId);
$builder->where('resdate >=', $startDate);
$builder->where('resdate <=', $endDate);
$builder->orderBy('resdate', 'ASC');
return $builder->get()->getResultArray();
}
public function getByControlMonth($controlId, $yearMonth)
{
$startDate = $yearMonth . '-01';
$endDate = $yearMonth . '-31';
$builder = $this->db->table('results');
$builder->select('*');
$builder->where('control_ref_id', $controlId);
$builder->where('resdate >=', $startDate);
$builder->where('resdate <=', $endDate);
return $builder->get()->getResultArray();
}
public function saveResult($data)
{
$builder = $this->db->table('results');
$existing = $builder->select('*')
->where('control_ref_id', $data['control_ref_id'])
->where('test_ref_id', $data['test_ref_id'])
->where('resdate', $data['resdate'])
->get()
->getRowArray();
if ($existing) {
return $builder->where('result_id', $existing['result_id'])->update($data);
} else {
return $builder->insert($data);
}
}
public function checkExisting($controlId, $testId, $resdate)
{
$builder = $this->db->table('results');
return $builder->select('result_id')
->where('control_ref_id', $controlId)
->where('test_ref_id', $testId)
->where('resdate', $resdate)
->get()
->getRowArray();
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class TestModel extends BaseModel
{
protected $table = 'dict_tests';
protected $primaryKey = 'test_id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $allowedFields = ['dept_ref_id', 'name', 'unit', 'method', 'cva', 'ba', 'tea'];
public function getByDept($deptId)
{
return $this->where('dept_ref_id', $deptId)->findAll();
}
public function getWithDept()
{
$builder = $this->db->table('dict_tests t');
$builder->select('t.*, d.name as dept_name');
$builder->join('dict_depts d', 'd.dept_id = t.dept_ref_id', 'left');
return $builder->get()->getResultArray();
}
}

View File

@ -1,95 +0,0 @@
<!-- Backdrop -->
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-40"
@click="closeModal()">
</div>
<!-- Modal Panel -->
<div x-show="showModal"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl" @click.stop>
<!-- Header -->
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-800" x-text="form.control_id ? 'Edit Control' : 'Add Control'"></h3>
<button @click="closeModal()" class="text-slate-400 hover:text-slate-600">
<i class="fa-solid fa-xmark text-xl"></i>
</button>
</div>
<!-- Form -->
<form @submit.prevent="save()">
<div class="p-6">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Department <span class="text-red-500">*</span></label>
<select x-model="form.dept_ref_id" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" :class="{'border-red-300 bg-red-50': errors.dept_ref_id}" required>
<option value="">Select Department</option>
<template x-for="dept in depts" :key="dept.dept_id">
<option :value="dept.dept_id" x-text="dept.name"></option>
</template>
</select>
<p x-show="errors.dept_ref_id" x-text="errors.dept_ref_id" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Control Name <span class="text-red-500">*</span></label>
<input type="text" x-model="form.name" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" :class="{'border-red-300 bg-red-50': errors.name}" placeholder="Enter control name" required>
<p x-show="errors.name" x-text="errors.name" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Lot Number</label>
<input type="text" x-model="form.lot" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Enter lot number">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Producer</label>
<input type="text" x-model="form.producer" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Enter producer">
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">Expiry Date</label>
<input type="date" x-model="form.expdate" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">Assigned Tests</label>
<div class="border border-slate-200 rounded-lg p-3 bg-slate-50 max-h-48 overflow-y-auto">
<div class="grid grid-cols-2 gap-2">
<template x-for="test in tests" :key="test.test_id">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" :value="test.test_id" x-model="form.test_ids" class="w-4 h-4 text-blue-600 rounded border-slate-300 focus:ring-blue-500">
<span class="text-sm text-slate-700" x-text="test.name + ' (' + (test.dept_name || '') + ')'"></span>
</label>
</template>
</div>
</div>
<p class="text-xs text-slate-500 mt-1">Select tests to associate with this control</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-100 bg-slate-50/50 rounded-b-2xl">
<button type="button" @click="closeModal()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 transition-colors">Cancel</button>
<button type="submit" :disabled="loading" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<template x-if="!loading">
<span><i class="fa-solid fa-check mr-1"></i> <span x-text="form.control_id ? 'Update' : 'Save'"></span></span>
</template>
<template x-if="loading">
<span><i class="fa-solid fa-spinner fa-spin mr-1"></i> Saving...</span>
</template>
</button>
</div>
</form>
</div>
</div>

View File

@ -1,46 +0,0 @@
<div class="max-w-2xl mx-auto">
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-6"><?= isset($control) ? 'Edit Control' : 'Add New Control' ?></h2>
<form action="<?= isset($control) ? base_url('/control/update/' . $control['control_id']) : base_url('/control/save') ?>" method="post">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Department</label>
<select name="deptid" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="">Select Department</option>
<?php foreach ($depts as $dept): ?>
<option value="<?= $dept['dept_id'] ?>" <?= (isset($control) && $control['dept_ref_id'] == $dept['dept_id']) ? 'selected' : '' ?>>
<?= $dept['name'] ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Control Name</label>
<input type="text" name="name" value="<?= $control['name'] ?? '' ?>" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Lot Number</label>
<input type="text" name="lot" value="<?= $control['lot'] ?? '' ?>" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Expiry Date</label>
<input type="date" name="expdate" value="<?= $control['expdate'] ?? '' ?>" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Producer</label>
<textarea name="producer" rows="3" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"><?= $control['producer'] ?? '' ?></textarea>
</div>
<div class="flex justify-end space-x-4 pt-4">
<a href="<?= base_url('/control') ?>" class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded-lg">Cancel</a>
<button type="submit" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg">Save</button>
</div>
</div>
</form>
</div>
</div>

View File

@ -1,261 +0,0 @@
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<main x-data="controlIndex()">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Control Dictionary</h1>
<p class="text-sm text-slate-500 mt-1">Manage control materials and lot numbers</p>
</div>
<button @click="showForm()" class="btn btn-primary">
<i class="fa-solid fa-plus mr-2"></i>Add Control
</button>
</div>
<!-- Error Alert -->
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
<div class="flex items-center gap-2">
<i class="fa-solid fa-circle-exclamation"></i>
<span x-text="error"></span>
</div>
</div>
<!-- Search Card -->
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-4 mb-6">
<div class="flex gap-3">
<div class="flex-1 relative">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
<input type="text" x-model="keyword" @keyup.enter="fetchList()" class="w-full pl-10 pr-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Search controls...">
</div>
<select x-model="deptId" @change="fetchList()" class="select select-bordered select-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">All Departments</option>
<template x-for="dept in depts" :key="dept.dept_id">
<option :value="dept.dept_id" x-text="dept.name"></option>
</template>
</select>
<button @click="fetchList()" class="btn btn-primary">
<i class="fa-solid fa-magnifying-glass mr-2"></i>Search
</button>
</div>
</div>
<!-- Data Table Card -->
<div class="bg-white rounded-xl border border-slate-100 shadow-sm overflow-hidden">
<!-- Loading State -->
<template x-if="loading">
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center mx-auto mb-4">
<i class="fa-solid fa-spinner fa-spin text-blue-500 text-xl"></i>
</div>
<p class="text-slate-500 text-sm">Loading controls...</p>
</div>
</template>
<!-- Empty State -->
<template x-if="!loading && (!list || list.length === 0)">
<div class="flex-1 flex items-center justify-center p-8">
<div class="text-center">
<div class="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mx-auto mb-3">
<i class="fa-solid fa-sliders text-slate-400 text-xl"></i>
</div>
<p class="text-slate-500 text-sm">No controls found</p>
</div>
</div>
</template>
<!-- Data Table -->
<template x-if="!loading && list && list.length > 0">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wider">
<tr>
<th class="py-3 px-5 font-semibold">#</th>
<th class="py-3 px-5 font-semibold">Name</th>
<th class="py-3 px-5 font-semibold">Lot</th>
<th class="py-3 px-5 font-semibold">Department</th>
<th class="py-3 px-5 font-semibold">Status</th>
<th class="py-3 px-5 font-semibold">Expiry Date</th>
<th class="py-3 px-5 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-for="(item, index) in list" :key="item.control_id">
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="py-3 px-5" x-text="index + 1"></td>
<td class="py-3 px-5 font-medium text-slate-800" x-text="item.name"></td>
<td class="py-3 px-5 text-slate-600">
<span class="font-mono text-xs bg-slate-100 text-slate-600 px-2 py-1 rounded" x-text="item.lot || '-'"></span>
</td>
<td class="py-3 px-5 text-slate-600" x-text="item.dept_name || '-'"></td>
<td class="py-3 px-5">
<span :class="isExpired(item.expdate) ? 'bg-red-100 text-red-700' : 'bg-emerald-100 text-emerald-700'" class="px-2 py-1 text-xs font-medium rounded-full" x-text="isExpired(item.expdate) ? 'Expired' : 'Active'"></span>
</td>
<td class="py-3 px-5 text-slate-600" x-text="item.expdate ? new Date(item.expdate).toLocaleDateString() : '-'"></td>
<td class="py-3 px-5 text-right">
<button @click="showForm(item.control_id)" class="text-blue-600 hover:text-blue-800 mr-3">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button @click="deleteItem(item.control_id)" class="text-red-600 hover:text-red-800">
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Dialog Include -->
<?= $this->include('control/dialog_form'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script") ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("controlIndex", () => ({
loading: false,
showModal: false,
list: [],
form: {},
errors: {},
error: '',
keyword: '',
deptId: '',
depts: <?= json_encode($depts ?? []) ?>,
tests: <?= json_encode($tests ?? []) ?>,
init() {
this.fetchList();
},
async fetchList() {
this.loading = true;
this.error = '';
try {
const params = new URLSearchParams();
if (this.keyword) params.append('keyword', this.keyword);
if (this.deptId) params.append('deptId', this.deptId);
const res = await fetch(`${window.BASEURL}/api/control?${params}`);
const data = await res.json();
if (data.status === 'success') {
this.list = data.data || [];
} else {
this.error = data.message || 'Failed to load controls';
}
} catch (err) {
console.error(err);
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
},
isExpired(expdate) {
if (!expdate) return false;
return new Date(expdate) < new Date();
},
async showForm(id = null) {
this.errors = {};
if (id) {
const item = this.list.find(x => x.control_id === id);
if (item) {
this.form = {
control_id: item.control_id,
dept_ref_id: item.dept_ref_id,
name: item.name,
lot: item.lot || '',
producer: item.producer || '',
expdate: item.expdate || '',
test_ids: []
};
// Fetch assigned tests
try {
const res = await fetch(`${window.BASEURL}/api/control/${id}/tests`);
const data = await res.json();
if (data.status === 'success') {
this.form.test_ids = data.data || [];
}
} catch (err) {
console.error('Failed to fetch assigned tests', err);
}
}
} else {
this.form = {
dept_ref_id: '',
name: '',
lot: '',
producer: '',
expdate: '',
test_ids: []
};
}
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errors = {};
this.form = {};
},
validate() {
this.errors = {};
if (!this.form.dept_ref_id) this.errors.dept_ref_id = 'Department is required';
if (!this.form.name) this.errors.name = 'Control name is required';
return Object.keys(this.errors).length === 0;
},
async save() {
if (!this.validate()) return;
this.loading = true;
try {
const url = this.form.control_id
? `${window.BASEURL}/api/control/${this.form.control_id}`
: `${window.BASEURL}/api/control`;
const res = await fetch(url, {
method: this.form.control_id ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
const data = await res.json();
if (data.status === 'success') {
this.closeModal();
this.fetchList();
} else {
this.error = data.message || 'Failed to save control';
}
} catch (err) {
console.error(err);
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
},
async deleteItem(id) {
if (!confirm('Are you sure you want to delete this control?')) return;
this.loading = true;
try {
const res = await fetch(`${window.BASEURL}/api/control/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.status === 'success') {
this.fetchList();
} else {
this.error = data.message || 'Failed to delete control';
}
} catch (err) {
console.error(err);
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}));
});
</script>
<?= $this->endSection(); ?>

View File

@ -1,142 +1,67 @@
<?= $this->extend("layout/main_layout"); ?> <?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<main x-data="dashboard()"> <?= $this->section("content"); ?>
<div class="space-y-8"> <main class="flex-1 p-6 overflow-auto">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6 gap-4">
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow duration-200 group"> <div>
<h1 class="text-2xl font-bold tracking-tight text-base-content">Dashboard</h1>
<p class="text-sm mt-1 opacity-70">Quality Control Overview</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-slate-500 mb-1">Total Tests</p> <p class="text-sm text-base-content/60">Total Controls</p>
<h3 class="text-3xl font-bold text-slate-800 group-hover:text-primary-600 transition-colors"><?= count($tests) ?></h3> <p class="text-2xl font-bold text-base-content mt-1">24</p>
</div> </div>
<div class="h-12 w-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-200"> <div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="fa-solid fa-file-medical text-2xl"></i> <i class="fa-solid fa-vial text-primary text-xl"></i>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center text-sm text-slate-500">
<span class="text-green-600 font-medium flex items-center mr-2">
<i class="fa-solid fa-check-circle mr-1"></i>
Active
</span>
<span>in library</span>
</div>
</div> </div>
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow duration-200 group"> <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-slate-500 mb-1">Total Controls</p> <p class="text-sm text-base-content/60">Tests Today</p>
<h3 class="text-3xl font-bold text-slate-800 group-hover:text-primary-600 transition-colors"><?= count($controls) ?></h3> <p class="text-2xl font-bold text-base-content mt-1">156</p>
</div> </div>
<div class="h-12 w-12 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-200"> <div class="w-12 h-12 rounded-lg bg-success/10 flex items-center justify-center">
<i class="fa-solid fa-vial text-2xl"></i> <i class="fa-solid fa-check text-success text-xl"></i>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center text-sm text-slate-500">
<span class="text-slate-600 font-medium flex items-center mr-2">Reference</span>
<span>definitions</span>
</div>
</div> </div>
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow duration-200 group"> <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-slate-500 mb-1">Departments</p> <p class="text-sm text-base-content/60">Pass Rate</p>
<h3 class="text-3xl font-bold text-slate-800 group-hover:text-primary-600 transition-colors"><?= count($depts) ?></h3> <p class="text-2xl font-bold text-base-content mt-1">98.5%</p>
</div> </div>
<div class="h-12 w-12 rounded-xl bg-violet-50 text-violet-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-200"> <div class="w-12 h-12 rounded-lg bg-warning/10 flex items-center justify-center">
<i class="fa-solid fa-building text-2xl"></i> <i class="fa-solid fa-chart-line text-warning text-xl"></i>
</div>
</div>
<div class="mt-4 flex items-center text-sm text-slate-500">
<span class="text-slate-600 font-medium flex items-center mr-2">Active</span>
<span>units</span>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
<div class="px-6 py-5 border-b border-slate-100 bg-white"> <div class="flex items-center justify-between">
<h3 class="text-lg font-bold text-slate-800">Menu</h3>
<p class="text-sm text-slate-500">Quick access to all features</p>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a href="<?= base_url('/test') ?>" class="flex items-start p-4 rounded-xl border border-slate-200 hover:border-primary-300 hover:bg-primary-50/50 transition-all duration-200 group">
<div class="h-10 w-10 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center mr-4 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-vial"></i>
</div>
<div> <div>
<h4 class="font-semibold text-slate-800 group-hover:text-primary-600">Test Definitions</h4> <p class="text-sm text-base-content/60">Alerts</p>
<p class="text-sm text-slate-500 mt-1">Manage test types, methods, and units</p> <p class="text-2xl font-bold text-base-content mt-1">3</p>
</div>
<div class="w-12 h-12 rounded-lg bg-error/10 flex items-center justify-center">
<i class="fa-solid fa-triangle-exclamation text-error text-xl"></i>
</div>
</div>
</div>
</div> </div>
</a>
<a href="<?= base_url('/control') ?>" class="flex items-start p-4 rounded-xl border border-slate-200 hover:border-primary-300 hover:bg-primary-50/50 transition-all duration-200 group"> <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
<div class="h-10 w-10 rounded-lg bg-emerald-100 text-emerald-600 flex items-center justify-center mr-4 group-hover:scale-110 transition-transform"> <h2 class="text-lg font-semibold text-base-content mb-4">Recent QC Results</h2>
<i class="fa-solid fa-sliders"></i> <p class="text-base-content/60 text-center py-8">Dashboard content coming soon...</p>
</div>
<div>
<h4 class="font-semibold text-slate-800 group-hover:text-primary-600">Control Dictionary</h4>
<p class="text-sm text-slate-500 mt-1">Manage control materials and lot numbers</p>
</div>
</a>
<a href="<?= base_url('/dept') ?>" class="flex items-start p-4 rounded-xl border border-slate-200 hover:border-primary-300 hover:bg-primary-50/50 transition-all duration-200 group">
<div class="h-10 w-10 rounded-lg bg-violet-100 text-violet-600 flex items-center justify-center mr-4 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-building"></i>
</div>
<div>
<h4 class="font-semibold text-slate-800 group-hover:text-primary-600">Department Dictionary</h4>
<p class="text-sm text-slate-500 mt-1">Manage departments and units</p>
</div>
</a>
<a href="<?= base_url('/entry') ?>" class="flex items-start p-4 rounded-xl border border-slate-200 hover:border-primary-300 hover:bg-primary-50/50 transition-all duration-200 group">
<div class="h-10 w-10 rounded-lg bg-amber-100 text-amber-600 flex items-center justify-center mr-4 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-calendar-check"></i>
</div>
<div>
<h4 class="font-semibold text-slate-800 group-hover:text-primary-600">Monthly Entry</h4>
<p class="text-sm text-slate-500 mt-1">Enter monthly QC results</p>
</div>
</a>
<a href="<?= base_url('/entry/daily') ?>" class="flex items-start p-4 rounded-xl border border-slate-200 hover:border-primary-300 hover:bg-primary-50/50 transition-all duration-200 group">
<div class="h-10 w-10 rounded-lg bg-rose-100 text-rose-600 flex items-center justify-center mr-4 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-calendar-day"></i>
</div>
<div>
<h4 class="font-semibold text-slate-800 group-hover:text-primary-600">Daily Entry</h4>
<p class="text-sm text-slate-500 mt-1">Enter daily QC results</p>
</div>
</a>
<a href="<?= base_url('/report') ?>" class="flex items-start p-4 rounded-xl border border-slate-200 hover:border-primary-300 hover:bg-primary-50/50 transition-all duration-200 group">
<div class="h-10 w-10 rounded-lg bg-indigo-100 text-indigo-600 flex items-center justify-center mr-4 group-hover:scale-110 transition-transform">
<i class="fa-solid fa-chart-column"></i>
</div>
<div>
<h4 class="font-semibold text-slate-800 group-hover:text-primary-600">Reports</h4>
<p class="text-sm text-slate-500 mt-1">View and generate QC reports</p>
</div>
</a>
</div>
</div>
</div>
</div> </div>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section("script") ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("dashboard", () => ({
init() {
// Dashboard init
}
}));
});
</script>
<?= $this->endSection(); ?>

View File

@ -1,60 +0,0 @@
<!-- Backdrop -->
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-40"
@click="closeModal()">
</div>
<!-- Modal Panel -->
<div x-show="showModal"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md" @click.stop>
<!-- Header -->
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-800" x-text="form.dept_id ? 'Edit Department' : 'Add Department'"></h3>
<button @click="closeModal()" class="text-slate-400 hover:text-slate-600">
<i class="fa-solid fa-xmark text-xl"></i>
</button>
</div>
<!-- Form -->
<form @submit.prevent="save()">
<div class="p-6">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Department Name <span class="text-red-500">*</span></label>
<input type="text" x-model="form.name"
class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
:class="{'border-red-300 bg-red-50': errors.name}"
placeholder="Enter department name" required>
<p x-show="errors.name" x-text="errors.name" class="text-red-500 text-xs mt-1"></p>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-100 bg-slate-50/50 rounded-b-2xl">
<button type="button" @click="closeModal()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 transition-colors">Cancel</button>
<button type="submit" :disabled="loading" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<template x-if="!loading">
<span><i class="fa-solid fa-check mr-1"></i> <span x-text="form.dept_id ? 'Update' : 'Save'"></span></span>
</template>
<template x-if="loading">
<span><i class="fa-solid fa-spinner fa-spin mr-1"></i> Saving...</span>
</template>
</button>
</div>
</form>
</div>
</div>

View File

@ -1,187 +0,0 @@
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<main x-data="deptIndex()">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Department Dictionary</h1>
<p class="text-sm text-slate-500 mt-1">Manage department entries</p>
</div>
<button @click="showForm()" class="btn btn-primary">
<i class="fa-solid fa-plus mr-2"></i>Add Department
</button>
</div>
<!-- Error Alert -->
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
<div class="flex items-center gap-2">
<i class="fa-solid fa-circle-exclamation"></i>
<span x-text="error"></span>
</div>
</div>
<!-- Data Table Card -->
<div class="bg-white rounded-xl border border-slate-100 shadow-sm overflow-hidden">
<!-- Loading State -->
<template x-if="loading">
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center mx-auto mb-4">
<i class="fa-solid fa-spinner fa-spin text-blue-500 text-xl"></i>
</div>
<p class="text-slate-500 text-sm">Loading departments...</p>
</div>
</template>
<!-- Empty State -->
<template x-if="!loading && (!list || list.length === 0)">
<div class="flex-1 flex items-center justify-center p-8">
<div class="text-center">
<div class="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mx-auto mb-3">
<i class="fa-solid fa-inbox text-slate-400"></i>
</div>
<p class="text-slate-500 text-sm">No departments found</p>
</div>
</div>
</template>
<!-- Data Table -->
<template x-if="!loading && list && list.length > 0">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wider">
<tr>
<th class="py-3 px-5 font-semibold">#</th>
<th class="py-3 px-5 font-semibold">Name</th>
<th class="py-3 px-5 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-for="(item, index) in list" :key="item.dept_id">
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="py-3 px-5" x-text="index + 1"></td>
<td class="py-3 px-5 font-medium text-slate-800" x-text="item.name"></td>
<td class="py-3 px-5 text-right">
<button @click="showForm(item.dept_id)" class="text-blue-600 hover:text-blue-800 mr-3">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button @click="deleteItem(item.dept_id)" class="text-red-600 hover:text-red-800">
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Dialog Include -->
<?= $this->include('dept/dialog_form'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script") ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("deptIndex", () => ({
loading: false,
showModal: false,
list: [],
form: {},
errors: {},
error: '',
init() {
this.fetchList();
},
async fetchList() {
this.loading = true;
this.error = '';
try {
const res = await fetch(`${window.BASEURL}/api/dept`);
const data = await res.json();
if (data.status === 'success') {
this.list = data.data || [];
} else {
this.error = data.message || 'Failed to load departments';
}
} catch (err) {
console.error(err);
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
},
showForm(id = null) {
this.form = id ? JSON.parse(JSON.stringify(this.list.find(x => x.dept_id === id))) : {};
this.errors = {};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errors = {};
this.form = {};
},
validate() {
this.errors = {};
if (!this.form.name) {
this.errors.name = 'Department name is required';
}
return Object.keys(this.errors).length === 0;
},
async save() {
if (!this.validate()) return;
this.loading = true;
try {
const url = this.form.dept_id
? `${window.BASEURL}/api/dept/${this.form.dept_id}`
: `${window.BASEURL}/api/dept`;
const res = await fetch(url, {
method: this.form.dept_id ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: this.form.name })
});
const data = await res.json();
if (data.status === 'success') {
this.closeModal();
this.fetchList();
} else {
this.error = data.message || 'Failed to save department';
}
} catch (err) {
console.error(err);
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
},
async deleteItem(id) {
if (!confirm('Are you sure you want to delete this department?')) return;
this.loading = true;
try {
const res = await fetch(`${window.BASEURL}/api/dept/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.status === 'success') {
this.fetchList();
} else {
this.error = data.message || 'Failed to delete department';
}
} catch (err) {
console.error(err);
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}));
});
</script>
<?= $this->endSection(); ?>

View File

@ -1,256 +1,16 @@
<?= $this->extend("layout/main_layout"); ?> <?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<main x-data="dailyEntry()"> <?= $this->section("content"); ?>
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-6"> <main class="flex-1 p-6 overflow-auto">
<div class="flex items-center justify-between mb-6"> <div class="flex justify-between items-center mb-6">
<div> <div>
<h1 class="text-2xl font-bold text-slate-800">Daily Entry</h1> <h1 class="text-2xl font-bold text-base-content tracking-tight">Daily Entry</h1>
<p class="text-sm text-slate-500 mt-1">Enter daily QC results</p> <p class="text-sm mt-1 opacity-70">Record daily QC test results</p>
</div>
<div class="flex items-center gap-2 text-sm text-slate-500">
<i class="fa-solid fa-clock"></i>
<span>Press Ctrl+S to save</span>
</div> </div>
</div> </div>
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6"> <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
<div class="flex items-center gap-2"> <p class="text-base-content/60 text-center py-8">Daily entry form coming soon...</p>
<i class="fa-solid fa-circle-exclamation"></i>
<span x-text="error"></span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Department <span class="text-red-500">*</span></label>
<select x-model="dept" @change="loadControls()" :class="errors.dept ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">Select Department</option>
<?php foreach ($depts as $d): ?>
<option value="<?= $d['dept_id'] ?>"><?= $d['name'] ?></option>
<?php endforeach; ?>
</select>
<p x-show="errors.dept" x-text="errors.dept" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Date <span class="text-red-500">*</span></label>
<input type="date" x-model="date" :class="errors.date ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<p x-show="errors.date" x-text="errors.date" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Control <span class="text-red-500">*</span></label>
<select x-model="control" @change="loadTests()" :class="errors.control ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">Select Control</option>
<template x-for="c in controls" :key="c.control_id">
<option :value="c.control_id" x-text="c.name + ' (' + (c.lot || 'N/A') + ')'"></option>
</template>
</select>
<p x-show="errors.control" x-text="errors.control" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Test <span class="text-red-500">*</span></label>
<select x-model="test" :class="errors.test ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">Select Test</option>
<template x-for="t in tests" :key="t.test_ref_id">
<option :value="t.test_ref_id" x-text="t.test_name"></option>
</template>
</select>
<p x-show="errors.test" x-text="errors.test" class="text-red-500 text-xs mt-1"></p>
</div>
</div>
<div class="mt-6" x-show="control && test" x-transition>
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-6">
<h3 class="text-lg font-semibold text-slate-800 mb-4">Result</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Value <span class="text-red-500">*</span></label>
<input type="number" step="0.01" x-model="resvalue" :class="errors.resvalue ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Enter value">
<p x-show="errors.resvalue" x-text="errors.resvalue" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Comment</label>
<input type="text" x-model="rescomment" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Optional comment">
</div>
</div>
<div class="mt-4">
<button @click="saveResult()" data-save-btn :disabled="loading" class="btn btn-primary">
<template x-if="!loading">
<span class="flex items-center">
<i class="fa-solid fa-check mr-2"></i>
Save Result
</span>
</template>
<template x-if="loading">
<span class="flex items-center">
<i class="fa-solid fa-spinner fa-spin mr-2"></i>
Saving...
</span>
</template>
</button>
</div>
</div>
</div>
</div> </div>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section("script") ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("dailyEntry", () => ({
dept: '',
date: new Date().toISOString().split('T')[0],
control: '',
test: '',
resvalue: '',
rescomment: '',
controls: [],
tests: [],
loading: false,
errors: {},
error: '',
init() {
this.loadDraft();
},
async loadControls() {
this.errors = {};
this.controls = [];
this.control = '';
this.tests = [];
this.test = '';
if (!this.dept || !this.date) {
return;
}
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}/api/entry/controls?date=${this.date}&deptid=${this.dept}`);
const data = await response.json();
if (data.status === 'success') {
this.controls = data.data || [];
if (this.controls.length === 0) {
App.showToast('No controls found for selected criteria', 'info');
}
} else {
this.error = data.message || 'Failed to load controls';
}
} catch (error) {
console.error(error);
this.error = 'Failed to load controls';
} finally {
this.loading = false;
}
},
async loadTests() {
this.errors = {};
this.tests = [];
this.test = '';
if (!this.control) {
return;
}
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}/api/entry/tests?controlid=${this.control}`);
const data = await response.json();
if (data.status === 'success') {
this.tests = data.data || [];
if (this.tests.length === 0) {
App.showToast('No tests found for this control', 'info');
}
} else {
this.error = data.message || 'Failed to load tests';
}
} catch (error) {
console.error(error);
this.error = 'Failed to load tests';
} finally {
this.loading = false;
}
},
validate() {
this.errors = {};
if (!this.dept) this.errors.dept = 'Department is required';
if (!this.date) this.errors.date = 'Date is required';
if (!this.control) this.errors.control = 'Control is required';
if (!this.test) this.errors.test = 'Test is required';
if (!this.resvalue) this.errors.resvalue = 'Value is required';
return Object.keys(this.errors).length === 0;
},
async saveResult() {
if (!this.validate()) {
App.showToast('Please fill all required fields', 'error');
return;
}
if (!App.confirmSave('Save result?')) return;
this.loading = true;
this.error = '';
const formData = new FormData();
formData.append('controlid', this.control);
formData.append('testid', this.test);
formData.append('resdate', this.date);
formData.append('resvalue', this.resvalue);
formData.append('rescomment', this.rescomment);
try {
const response = await fetch(`${window.BASEURL}/api/entry/daily`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.status === 'success') {
App.showToast('Result saved successfully!');
this.clearForm();
this.saveDraft();
} else {
this.error = data.message || 'Failed to save result';
}
} catch (error) {
console.error(error);
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
},
clearForm() {
this.resvalue = '';
this.rescomment = '';
},
saveDraft() {
localStorage.setItem('dailyEntry', JSON.stringify({
dept: this.dept,
date: this.date,
control: this.control,
test: this.test
}));
},
loadDraft() {
const draft = localStorage.getItem('dailyEntry');
if (draft) {
const data = JSON.parse(draft);
this.dept = data.dept || '';
this.date = data.date || new Date().toISOString().split('T')[0];
this.control = data.control || '';
this.test = data.test || '';
if (this.dept && this.date) this.loadControls();
if (this.control) this.loadTests();
}
}
}));
});
</script>
<?= $this->endSection(); ?>

40
app/Views/entry/index.php Normal file
View File

@ -0,0 +1,40 @@
<?= $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">QC Entry</h1>
<p class="text-sm mt-1 opacity-70">Record quality control results</p>
</div>
</div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<a href="<?= base_url('/entry/daily') ?>" class="block p-6 rounded-xl border-2 border-dashed border-base-300 hover:border-blue-500 hover:bg-blue-50 transition-all group">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-xl bg-blue-100 flex items-center justify-center group-hover:bg-blue-200 transition-colors">
<i class="fa-solid fa-calendar-day text-2xl text-blue-600"></i>
</div>
<div>
<h3 class="font-semibold text-base-content">Daily Entry</h3>
<p class="text-sm text-base-content/60 mt-1">Record daily QC test results</p>
</div>
</div>
</a>
<div class="block p-6 rounded-xl border-2 border-dashed border-base-300 hover:border-green-500 hover:bg-green-50 transition-all group cursor-not-allowed opacity-60">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-xl bg-green-100 flex items-center justify-center group-hover:bg-green-200 transition-colors">
<i class="fa-solid fa-chart-column text-2xl text-green-600"></i>
</div>
<div>
<h3 class="font-semibold text-base-content">Monthly Review</h3>
<p class="text-sm text-base-content/60 mt-1">Monthly QC analysis & comments</p>
</div>
</div>
</div>
</div>
</div>
</main>
<?= $this->endSection(); ?>

View File

@ -1,498 +0,0 @@
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<main x-data="monthlyEntry()" @keydown.window.ctrl.s.prevent="saveData()">
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Monthly Entry</h1>
<p class="text-sm text-slate-500 mt-1">Enter monthly QC results</p>
</div>
<div class="flex items-center gap-2 text-sm text-slate-500">
<i class="fa-solid fa-clock"></i>
<span>Press Ctrl+S to save</span>
</div>
</div>
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
<div class="flex items-center gap-2">
<i class="fa-solid fa-circle-exclamation"></i>
<span x-text="error"></span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Department <span class="text-red-500">*</span></label>
<select x-model="dept" @change="loadControls()" :class="errors.dept ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">Select Department</option>
<?php foreach ($depts as $d): ?>
<option value="<?= $d['dept_id'] ?>"><?= $d['name'] ?></option>
<?php endforeach; ?>
</select>
<p x-show="errors.dept" x-text="errors.dept" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Month <span class="text-red-500">*</span></label>
<input type="month" x-model="date" @change="loadControls(); loadMonthData()" :class="errors.date ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<p x-show="errors.date" x-text="errors.date" class="text-red-500 text-xs mt-1"></p>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">Control <span class="text-red-500">*</span></label>
<select x-model="control" @change="loadTests(); loadMonthData()" :class="errors.control ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">Select Control</option>
<template x-for="c in controls" :key="c.control_id">
<option :value="c.control_id" x-text="c.name + ' (' + (c.lot || 'N/A') + ')'"></option>
</template>
</select>
<p x-show="errors.control" x-text="errors.control" class="text-red-500 text-xs mt-1"></p>
</div>
</div>
<div x-show="control && tests.length > 0" x-transition>
<div class="mb-4">
<label class="block text-sm font-medium text-slate-700 mb-2">Select Tests <span class="text-red-500">*</span></label>
<div class="flex flex-wrap gap-2">
<template x-for="t in tests" :key="t.test_ref_id">
<label class="inline-flex items-center px-3 py-1.5 rounded-lg border cursor-pointer transition-all"
:class="selectedTests.includes(t.test_ref_id) ? 'bg-blue-50 border-blue-500 text-blue-700' : 'bg-slate-50 border-slate-200 hover:border-slate-300'">
<input type="checkbox" :value="t.test_ref_id" x-model="selectedTests" @change="loadMonthData()" class="hidden">
<span class="text-sm font-medium" x-text="t.test_name"></span>
</label>
</template>
</div>
</div>
</div>
<div x-show="control && selectedTests.length > 0" x-transition>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-slate-800">
<span x-text="monthName"></span> <span x-text="year"></span>
</h3>
<div class="flex items-center gap-4 text-sm">
<div class="flex items-center gap-2">
<span class="w-4 h-4 rounded bg-slate-100 border border-slate-200"></span>
<span class="text-slate-600">Weekday</span>
</div>
<div class="flex items-center gap-2">
<span class="w-4 h-4 rounded bg-red-50 border border-red-200"></span>
<span class="text-slate-600">Weekend</span>
</div>
</div>
</div>
<template x-if="loading">
<div class="flex items-center justify-center py-12">
<i class="fa-solid fa-spinner fa-spin text-blue-500 text-3xl"></i>
</div>
</template>
<template x-if="!loading">
<div class="border border-slate-200 rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-slate-50 border-b border-slate-200">
<th class="py-3 px-3 text-left font-semibold text-slate-600 w-32 sticky left-0 bg-slate-50">Test</th>
<template x-for="day in daysInMonth" :key="day">
<th class="py-3 px-2 text-center font-medium text-slate-600 min-w-[60px]"
:class="isWeekend(day) ? 'bg-red-50 text-red-600' : ''">
<span x-text="day"></span>
</th>
</template>
</tr>
</thead>
<tbody>
<template x-for="testId in selectedTests" :key="testId">
<tr class="border-b border-slate-100 hover:bg-slate-50/50">
<td class="py-2 px-3 font-medium text-slate-700 sticky left-0 bg-white border-r border-slate-100">
<span x-text="getTestName(testId)"></span>
</td>
<template x-for="day in daysInMonth" :key="day">
<td class="py-1 px-1 relative" :class="isWeekend(day) ? 'bg-red-50' : ''">
<div class="flex items-center gap-1">
<input
type="number"
step="0.01"
class="w-full px-2 py-1.5 text-center text-sm bg-white border border-slate-200 rounded focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
:placeholder="'-'"
x-model="monthlyData[testId + '_' + day]"
@change="markDirty(testId, day)"
>
<button
class="p-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
:title="monthlyData[testId + '_' + day + '_comment'] || 'Add comment'"
@click.stop="toggleComment(testId, day)"
>
<i class="fa-solid fa-comment text-xs" :class="monthlyData[testId + '_' + day + '_comment'] ? 'text-blue-500' : ''"></i>
</button>
</div>
<div
x-show="showCommentId === testId + '_' + day"
x-transition
class="absolute z-10 top-full left-0 mt-1 w-48"
@click.outside="showCommentId = null"
>
<textarea
class="w-full px-2 py-1.5 text-xs bg-white border border-slate-200 rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 resize-none"
rows="2"
placeholder="Add comment..."
x-model="monthlyData[testId + '_' + day + '_comment']"
@keydown.enter.prevent
></textarea>
</div>
</td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<div class="mt-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Monthly Comment</label>
<textarea x-model="comment" rows="2" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Optional monthly comment"></textarea>
</div>
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-sm font-medium text-slate-700 mb-1">Operation Mode</label>
<select x-model="operation" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="replace">Replace All</option>
<option value="add">Add Only (Skip Existing)</option>
</select>
</div>
<button @click="saveData()" data-save-btn :disabled="loading || selectedTests.length === 0" class="btn btn-primary flex-shrink-0">
<template x-if="!loading">
<span class="flex items-center justify-center">
<i class="fa-solid fa-check mr-2"></i>
Save All Data
</span>
</template>
<template x-if="loading">
<span class="flex items-center justify-center">
<i class="fa-solid fa-spinner fa-spin mr-2"></i>
Saving...
</span>
</template>
</button>
</div>
</div>
</div>
</div>
<div x-show="control && (!selectedTests || selectedTests.length === 0)" x-transition class="text-center py-12 text-slate-500">
<i class="fa-solid fa-clipboard-list text-4xl mb-3 text-slate-300"></i>
<p>Select one or more tests above to view and enter monthly data</p>
</div>
<div x-show="!control" x-transition class="text-center py-12 text-slate-500">
<i class="fa-solid fa-list-check text-4xl mb-3 text-slate-300"></i>
<p>Select a control to view available tests</p>
</div>
</div>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script") ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("monthlyEntry", () => ({
dept: '',
date: new Date().toISOString().slice(0, 7),
control: '',
tests: [],
selectedTests: [],
controls: [],
loading: false,
errors: {},
error: '',
monthlyData: {},
comment: '',
dirtyCells: new Set(),
operation: 'replace',
saveResult: null,
showCommentId: null,
init() {
this.loadDraft();
},
get year() {
return this.date ? this.date.split('-')[0] : '';
},
get monthName() {
if (!this.date) return '';
const month = parseInt(this.date.split('-')[1]);
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return months[month - 1] || '';
},
get daysInMonth() {
if (!this.date) return 31;
const [year, month] = this.date.split('-').map(Number);
return new Date(year, month, 0).getDate();
},
isWeekend(day) {
if (!this.date) return false;
const [year, month] = this.date.split('-').map(Number);
const date = new Date(year, month - 1, day);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6;
},
getTestName(testId) {
const test = this.tests.find(t => t.test_ref_id === testId);
return test ? test.test_name : '';
},
markDirty(testId, day) {
this.dirtyCells.add(testId + '_' + day);
},
toggleComment(testId, day) {
const id = testId + '_' + day;
if (this.showCommentId === id) {
this.showCommentId = null;
} else {
this.showCommentId = id;
}
},
async loadControls() {
this.errors = {};
this.controls = [];
this.control = '';
this.tests = [];
this.selectedTests = [];
this.monthlyData = {};
this.comment = '';
if (!this.dept || !this.date) {
return;
}
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}/api/entry/controls?date=${this.date}&deptid=${this.dept}`);
const data = await response.json();
if (data.status === 'success') {
this.controls = data.data || [];
if (this.controls.length === 0) {
App.showToast('No controls found for selected criteria', 'info');
}
} else {
this.error = data.message || 'Failed to load controls';
}
} catch (error) {
console.error(error);
this.error = 'Failed to load controls';
} finally {
this.loading = false;
}
},
async loadTests() {
this.errors = {};
this.tests = [];
this.selectedTests = [];
this.monthlyData = {};
this.comment = '';
if (!this.control) {
return;
}
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}/api/entry/tests?controlid=${this.control}`);
const data = await response.json();
if (data.status === 'success') {
this.tests = data.data || [];
if (this.tests.length === 0) {
App.showToast('No tests found for this control', 'info');
} else {
this.selectedTests = this.tests.map(t => t.test_ref_id);
}
} else {
this.error = data.message || 'Failed to load tests';
}
} catch (error) {
console.error(error);
this.error = 'Failed to load tests';
} finally {
this.loading = false;
}
},
async loadMonthData() {
if (!this.control || this.selectedTests.length === 0 || !this.date) {
return;
}
this.loading = true;
this.monthlyData = {};
this.comment = '';
try {
for (const testId of this.selectedTests) {
const response = await fetch(`${window.BASEURL}/api/entry/monthly?controlid=${this.control}&testid=${testId}&yearmonth=${this.date}`);
const data = await response.json();
if (data.status === 'success') {
const formValues = data.data.formValues || {};
const comments = data.data.comments || {};
for (const [day, value] of Object.entries(formValues)) {
this.monthlyData[testId + '_' + day] = value;
}
for (const [day, comment] of Object.entries(comments)) {
this.monthlyData[testId + '_' + day + '_comment'] = comment;
}
if (!this.comment && data.data.comment) {
this.comment = data.data.comment;
}
}
}
} catch (error) {
console.error(error);
this.error = 'Failed to load monthly data';
} finally {
this.loading = false;
}
},
validate() {
this.errors = {};
if (!this.dept) this.errors.dept = 'Department is required';
if (!this.date) this.errors.date = 'Date is required';
if (!this.control) this.errors.control = 'Control is required';
if (this.selectedTests.length === 0) {
App.showToast('Please select at least one test', 'error');
return false;
}
return true;
},
async saveData() {
if (!this.validate()) {
return;
}
if (!App.confirmSave('Save monthly entry data?')) return;
this.loading = true;
this.error = '';
this.saveResult = null;
try {
const tests = [];
for (const testId of this.selectedTests) {
const resvalue = {};
const rescomment = {};
let hasData = false;
for (let day = 1; day <= this.daysInMonth; day++) {
const key = testId + '_' + day;
const value = this.monthlyData[key];
const commentKey = testId + '_' + day + '_comment';
const comment = this.monthlyData[commentKey];
if (value !== undefined && value !== null && value !== '') {
resvalue[day] = value;
hasData = true;
}
if (comment !== undefined && comment !== null && comment !== '') {
rescomment[day] = comment;
}
}
if (hasData) {
tests.push({ testId, resvalue, rescomment });
}
}
const response = await fetch(`${window.BASEURL}/api/entry/monthly`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
controlId: this.control,
yearMonth: this.date,
operation: this.operation,
tests: tests
})
});
const data = await response.json();
if (data.status === 'success') {
this.saveResult = data.data;
let message = 'Data saved successfully!';
if (data.data.statistics && data.data.statistics.length > 0) {
message += `\n\nStatistics:\n`;
for (const stat of data.data.statistics) {
const testName = this.getTestName(stat.testId);
message += `${testName}: n=${stat.n}, Mean=${stat.mean}, SD=${stat.sd}, CV=${stat.cv}%\n`;
}
}
if (data.data.validations && data.data.validations.length > 0) {
message += `\n⚠ ${data.data.validations.length} value(s) out of control limits!`;
}
App.showToast(message, 'success');
this.dirtyCells.clear();
this.saveDraft();
} else {
throw new Error(data.message || 'Failed to save data');
}
} catch (error) {
console.error(error);
this.error = error.message || 'Network error. Please try again.';
} finally {
this.loading = false;
}
},
async saveComment() {
const formData = new FormData();
formData.append('controlid', this.control);
formData.append('testid', this.selectedTests[0]);
formData.append('commonth', this.date);
formData.append('comtext', this.comment);
try {
await fetch(`${window.BASEURL}/api/entry/comment`, {
method: 'POST',
body: formData
});
} catch (error) {
console.error('Failed to save comment:', error);
}
},
saveDraft() {
localStorage.setItem('monthlyEntry', JSON.stringify({
dept: this.dept,
date: this.date,
control: this.control,
test: this.test
}));
},
loadDraft() {
const draft = localStorage.getItem('monthlyEntry');
if (draft) {
const data = JSON.parse(draft);
this.dept = data.dept || '';
this.date = data.date || new Date().toISOString().slice(0, 7);
this.control = data.control || '';
this.test = data.test || '';
if (this.dept && this.date) this.loadControls();
if (this.control) this.loadTests();
}
}
}));
});
</script>
<?= $this->endSection(); ?>

View File

@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-slate-50">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $page_title ?? 'QC Application' ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
colors: {
primary: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
950: '#1e1b4b',
}
}
}
}
};
const baseUrl = '<?= base_url() ?>';
window.BASEURL = '<?= base_url() ?>';
</script>
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body class="bg-slate-50 min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-slate-800"><?= $page_title ?? 'QC Application' ?></h1>
</div>
<?= $this->renderSection("content"); ?>
</div>
</div>
<script src="<?= base_url('js/app.js') ?>"></script>
<?= $this->renderSection("script"); ?>
</body>
</html>

View File

@ -1,252 +1,171 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="h-full bg-slate-50"> <html lang="en" data-theme="autumn">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $page_title ?? 'QC Application' ?></title> <title><?= $pageData['title'] ?? 'TinyQC - QC Management System' ?></title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script src="https://cdn.tailwindcss.com"></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">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
<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.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
<script> <script>
tailwind.config = { const BASEURL = '<?= base_url('/') ?>';
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
colors: {
primary: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
950: '#1e1b4b',
},
surface: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
}
},
boxShadow: {
'glass': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(255, 255, 255, 0.5) inset',
'card': '0 0 0 1px rgba(226, 232, 240, 1), 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
}
}
}
};
const baseUrl = '<?= base_url() ?>';
window.BASEURL = '<?= base_url() ?>';
</script> </script>
<style>
[x-cloak] {
display: none !important;
}
.glass-panel {
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
</head> </head>
<body class="bg-base-200 text-base-content" x-data="appData()">
<div class="drawer lg:drawer-open">
<input id="sidebar-drawer" type="checkbox" class="drawer-toggle" x-ref="sidebarDrawer">
<body x-data="layoutManager()" :class="{ 'overflow-hidden': $store.appState.sidebarOpen && isMobile() }" class="bg-gray-50 h-full text-slate-800 antialiased"> <div class="drawer-content flex flex-col min-h-screen">
<div x-cloak x-show="App.loading" x-transition.opacity <nav class="navbar bg-base-200/80 backdrop-blur-md border-b border-base-300 sticky top-0 z-30 w-full shadow-sm">
class="fixed inset-0 z-[100] flex items-center justify-center bg-white/80 backdrop-blur-sm"> <div class="flex-none lg:hidden">
<div class="bg-white shadow-xl rounded-2xl p-6 flex items-center space-x-4 border border-slate-100"> <label for="sidebar-drawer" class="btn btn-square btn-ghost text-primary">
<div class="animate-spin h-6 w-6 border-[3px] border-primary-600 border-t-transparent rounded-full"></div> <i class="fa-solid fa-bars text-xl"></i>
<span class="text-slate-600 font-medium">Loading application...</span> </label>
</div> </div>
</div> <div class="flex-1 px-4">
<div class="flex items-center gap-3">
<div class="flex h-screen bg-slate-50"> <div class="w-10 h-10 rounded-lg bg-primary flex items-center justify-center shadow-lg shadow-primary/20">
<div id="sidebar-backdrop" x-show="$store.appState.sidebarOpen" @click="$store.appState.sidebarOpen = false" x-transition.opacity aria-hidden="true" <i class="fa-solid fa-flask text-white text-lg"></i>
class="fixed inset-0 z-40 bg-slate-900/50 backdrop-blur-sm lg:hidden"></div>
<aside id="sidebar"
x-data="{
resultsOpen: <?= in_array($active_menu, ['entry', 'entry_daily']) ? 'true' : 'false' ?>,
masterOpen: <?= in_array($active_menu, ['test', 'control', 'dept']) ? 'true' : 'false' ?>
}"
:class="$store.appState.sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:-translate-x-full'"
class="fixed inset-y-0 left-0 z-50 w-64 transition-transform duration-300 ease-in-out flex flex-col shadow-lg">
<div class="flex h-16 items-center px-6 border-b border-slate-100 bg-white/50">
<div class="flex items-center space-x-3">
<div class="h-8 w-8 rounded-lg bg-gradient-to-br from-primary-600 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/30">
<i class="fa-solid fa-flask text-white text-sm"></i>
</div> </div>
<div> <div>
<h1 class="text-lg font-bold text-slate-800 tracking-tight">TinyQC</h1> <h1 class="text-xl font-bold text-base-content tracking-tight">tinyqc</h1>
<p class="text-xs text-slate-500 font-medium tracking-wide">LABORATORY</p> <p class="text-xs opacity-70">QC Management System</p>
</div> </div>
</div> </div>
</div> </div>
<div class="flex-none">
<nav class="flex-1 overflow-y-auto px-4 py-4 space-y-1"> <button class="btn btn-ghost rounded-full mr-2" @click="toggleTheme()">
<p class="px-2 text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Main Menu</p> <i class="fa-solid fa-sun text-warning" x-show="currentTheme === themeConfig.light"></i>
<i class="fa-solid fa-moon text-neutral-content" x-show="currentTheme === themeConfig.dark"></i>
<?php $active_menu = $active_menu ?? ''; ?>
<?php $navClass = 'group flex items-center px-3 py-2 text-sm font-medium rounded-xl transition-all duration-200 w-full'; ?>
<?php $activeClass = 'bg-primary-50 text-primary-700 shadow-sm ring-1 ring-primary-100'; ?>
<?php $inactiveClass = 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'; ?>
<?php $subActiveClass = 'text-primary-700 font-semibold bg-primary-50/50'; ?>
<?php $subInactiveClass = 'text-slate-500 hover:text-slate-800 hover:bg-slate-50'; ?>
<a href="<?= base_url('/') ?>"
class="<?= $navClass ?> <?= $active_menu == 'dashboard' ? $activeClass : $inactiveClass ?>">
<i class="fa-solid fa-house h-5 w-5 mr-3 flex items-center justify-center <?= $active_menu == 'dashboard' ? 'text-primary-600' : 'text-slate-400 group-hover:text-slate-600' ?> transition-colors"></i>
Dashboard
</a>
<!-- Results Dropdown -->
<div class="space-y-1">
<button @click="resultsOpen = !resultsOpen"
class="<?= $navClass ?> <?= in_array($active_menu, ['entry', 'entry_daily']) ? 'text-primary-700' : 'text-slate-600 hover:bg-slate-50' ?>">
<i class="fa-solid fa-square-poll-vertical h-5 w-5 mr-3 flex items-center justify-center <?= in_array($active_menu, ['entry', 'entry_daily']) ? 'text-primary-600' : 'text-slate-400' ?>"></i>
<span class="flex-1 text-left">Results</span>
<i class="fa-solid fa-chevron-right text-[10px] transition-transform duration-200" :class="resultsOpen ? 'rotate-90' : ''"></i>
</button> </button>
<div class="dropdown dropdown-end" x-data="{ dropdownOpen: false }">
<div x-show="resultsOpen" x-cloak x-collapse class="pl-11 space-y-1"> <div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar placeholder" @click="dropdownOpen = !dropdownOpen">
<a href="<?= base_url('/entry') ?>" class="block px-3 py-1.5 text-sm rounded-lg transition-colors <?= $active_menu == 'entry' ? $subActiveClass : $subInactiveClass ?>"> <div class="bg-primary text-primary-content rounded-full w-10 h-10 flex items-center justify-center">
Monthly Entry <span><?= $pageData['userInitials'] ?? 'DR' ?></span>
</a>
<a href="<?= base_url('/entry/daily') ?>" class="block px-3 py-1.5 text-sm rounded-lg transition-colors <?= $active_menu == 'entry_daily' ? $subActiveClass : $subInactiveClass ?>">
Daily Entry
</a>
</div> </div>
</div> </div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow-xl bg-base-100 rounded-box w-52 border border-base-300" x-show="dropdownOpen" @click.outside="dropdownOpen = false" x-transition>
<a href="<?= base_url('/report') ?>" <li class="menu-title px-4 py-2">
class="<?= $navClass ?> <?= $active_menu == 'report' ? $activeClass : $inactiveClass ?>"> <span class="text-base-content font-bold"><?= $pageData['userName'] ?? 'Lab User' ?></span>
<i class="fa-solid fa-chart-column h-5 w-5 mr-3 flex items-center justify-center <?= $active_menu == 'report' ? 'text-primary-600' : 'text-slate-400 group-hover:text-slate-600' ?> transition-colors"></i> <span class="text-xs text-primary font-medium"><?= $pageData['userRole'] ?? 'Administrator' ?></span>
Reports </li>
</a> <div class="divider my-0 h-px opacity-10"></div>
<li><a class="hover:bg-base-200"><i class="fa-solid fa-user text-primary"></i> Profile</a></li>
<p class="px-2 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-6 mb-2">Settings</p> <li><a class="hover:bg-base-200"><i class="fa-solid fa-gear text-primary"></i> Settings</a></li>
<li><a class="hover:bg-base-200"><i class="fa-solid fa-question-circle text-primary"></i> Help</a></li>
<!-- Master Data Dropdown --> <div class="divider my-0 h-px opacity-10"></div>
<div class="space-y-1"> <li><a href="<?= base_url('/logout') ?>" class="text-error hover:bg-error/10"><i class="fa-solid fa-sign-out-alt"></i> Logout</a></li>
<button @click="masterOpen = !masterOpen" </ul>
class="<?= $navClass ?> <?= in_array($active_menu, ['test', 'control', 'dept']) ? 'text-primary-700' : 'text-slate-600 hover:bg-slate-50' ?>">
<i class="fa-solid fa-database h-5 w-5 mr-3 flex items-center justify-center <?= in_array($active_menu, ['test', 'control', 'dept']) ? 'text-primary-600' : 'text-slate-400' ?>"></i>
<span class="flex-1 text-left">Master Data</span>
<i class="fa-solid fa-chevron-right text-[10px] transition-transform duration-200" :class="masterOpen ? 'rotate-90' : ''"></i>
</button>
<div x-show="masterOpen" x-cloak x-collapse class="pl-11 space-y-1">
<a href="<?= base_url('/test') ?>" class="block px-3 py-1.5 text-sm rounded-lg transition-colors <?= $active_menu == 'test' ? $subActiveClass : $subInactiveClass ?>">
Test Definitions
</a>
<a href="<?= base_url('/control') ?>" class="block px-3 py-1.5 text-sm rounded-lg transition-colors <?= $active_menu == 'control' ? $subActiveClass : $subInactiveClass ?>">
Control Dictionary
</a>
<a href="<?= base_url('/dept') ?>" class="block px-3 py-1.5 text-sm rounded-lg transition-colors <?= $active_menu == 'dept' ? $subActiveClass : $subInactiveClass ?>">
Department Dictionary
</a>
</div> </div>
</div> </div>
</nav> </nav>
<?= $this->renderSection('content') ?>
</div>
<div class="drawer-side z-40">
<label for="sidebar-drawer" class="drawer-overlay"></label>
<aside class="bg-base-300 border-r border-base-300 w-64 min-h-full flex flex-col">
<ul class="menu p-4 text-base-content flex-1 w-full">
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= uri_string() === '' ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full" href="<?= base_url('/') ?>">
<i class="fa-solid fa-chart-line w-5"></i>
Dashboard
</a>
</li>
<div class="px-6 py-4 border-t border-slate-100"> <li class="mt-6 mb-2 min-h-0">
<div class="bg-gradient-to-br from-primary-800 to-indigo-900 rounded-xl p-4 text-white shadow-lg overflow-hidden relative"> <p class="px-4 text-xs font-semibold opacity-40 uppercase tracking-wider">Master Data</p>
<div class="absolute -right-4 -top-4 bg-white/10 w-24 h-24 rounded-full blur-xl"></div> </li>
<h4 class="font-medium text-sm relative z-10">Need Help?</h4> <li class="mb-1 min-h-0">
<p class="text-primary-200 text-xs mt-1 relative z-10">Check the documentation or contact support.</p> <a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= str_contains(uri_string(), 'master/dept') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full" href="<?= base_url('/master/dept') ?>">
<i class="fa-solid fa-building w-5"></i>
Departments
</a>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= str_contains(uri_string(), 'master/test') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full" href="<?= base_url('/master/test') ?>">
<i class="fa-solid fa-flask-vial w-5"></i>
Tests
</a>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= str_contains(uri_string(), 'master/control') ? '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') ?>">
<i class="fa-solid fa-vial w-5"></i>
Controls
</a>
</li>
<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>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= str_contains(uri_string(), 'entry') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full" href="<?= base_url('/entry') ?>">
<i class="fa-solid fa-pen-to-square w-5"></i>
QC Entry
</a>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg <?= str_contains(uri_string(), 'report') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full" href="<?= base_url('/report') ?>">
<i class="fa-solid fa-chart-bar w-5"></i>
Reports
</a>
</li>
</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>
</div> </div>
</aside> </aside>
<div class="flex-1 flex flex-col overflow-hidden bg-slate-50/50 transition-all duration-300"
:class="$store.appState.sidebarOpen ? 'lg:ml-64' : 'lg:ml-0'">
<header class="bg-white/80 backdrop-blur-md sticky top-0 z-30 border-b border-slate-200/60 supports-backdrop-blur:bg-white/60">
<div class="flex items-center justify-between h-16 px-8">
<div class="flex items-center">
<button id="sidebar-toggle" @click="$store.appState.sidebarOpen = !$store.appState.sidebarOpen" class="p-2 -ml-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
<i class="fa-solid fa-bars h-6 w-6 flex items-center justify-center"></i>
</button>
<h2 class="ml-2 lg:ml-0 text-xl font-bold text-slate-800 tracking-tight"><?= $page_title ?? 'Dashboard' ?></h2>
</div>
<div class="flex items-center space-x-4">
<div class="hidden md:flex items-center px-3 py-1 bg-slate-100 rounded-full border border-slate-200">
<i class="fa-solid fa-calendar-days text-slate-400 mr-2 text-xs"></i>
<span class="text-sm font-medium text-slate-600"><?= date('F d, Y') ?></span>
</div>
<div class="h-8 w-8 rounded-full bg-primary-100 border border-primary-200 flex items-center justify-center text-primary-700 font-bold text-sm">AD</div>
</div>
</div>
</header>
<main class="flex-1 overflow-y-auto p-4 md:p-6">
<div class="w-full">
<?= $this->renderSection("content"); ?>
</div>
</main>
</div> </div>
</div> </div>
<script src="<?= base_url('js/app.js') ?>"></script>
<script src="<?= base_url('js/tables.js') ?>"></script>
<script src="<?= base_url('js/charts.js') ?>"></script>
<script> <script>
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
Alpine.data('layoutManager', () => ({ Alpine.data('appData', () => ({
isMobile() { showModal: false,
return window.innerWidth < 1024; themeConfig: {
light: 'autumn',
dark: 'dracula'
},
get currentTheme() {
return localStorage.getItem('theme') || this.themeConfig.light;
},
set currentTheme(value) {
localStorage.setItem('theme', value);
document.documentElement.setAttribute('data-theme', value);
},
get isDark() {
return this.currentTheme === this.themeConfig.dark;
},
init() {
document.documentElement.setAttribute('data-theme', this.currentTheme);
},
toggleSidebar() {
this.$refs.sidebarDrawer.checked = !this.$refs.sidebarDrawer.checked;
},
toggleTheme() {
this.currentTheme = this.isDark ? this.themeConfig.light : this.themeConfig.dark;
},
openModal() {
this.showModal = true;
},
closeModal() {
this.showModal = false;
} }
})); }));
}); });
</script> </script>
<?= $this->renderSection("script"); ?>
</body>
<?= $this->renderSection('script') ?>
</body>
</html> </html>

View File

@ -0,0 +1,83 @@
<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">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2 text-base-content">
<i class="fa-solid fa-vial text-primary"></i>
<span x-text="form.controlId ? 'Edit Control' : 'New Control'"></span>
</h3>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Control Name</span>
</label>
<input
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="{'border-error': errors.controlName}"
x-model="form.controlName"
placeholder="Enter control name"
/>
<template x-if="errors.controlName">
<label class="label">
<span class="label-text-alt text-error" x-text="errors.controlName"></span>
</label>
</template>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Lot Number</span>
</label>
<input
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"
x-model="form.lotNumber"
placeholder="e.g., LOT12345"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Expiry Date</span>
</label>
<input
type="date"
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"
/>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Producer</span>
</label>
<input
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"
x-model="form.producer"
placeholder="Enter producer name"
/>
</div>
</div>
<div class="modal-action mt-6">
<button class="btn btn-ghost opacity-70" @click="closeModal()">Cancel</button>
<button
class="btn btn-primary gap-2 shadow-lg shadow-primary/20 font-medium"
:class="{'loading': loading}"
@click="save()"
:disabled="loading"
>
<template x-if="!loading">
<span><i class="fa-solid fa-save"></i> Save</span>
</template>
<template x-if="loading">
<span>Saving...</span>
</template>
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop bg-black/60" @click="closeModal()"></form>
</dialog>

View File

@ -0,0 +1,242 @@
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto" x-data="controls()">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Controls</h1>
<p class="text-sm mt-1 opacity-70">Manage QC control standards</p>
</div>
<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"
@click="showForm()"
>
<i class="fa-solid fa-plus"></i> New Control
</button>
</div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6">
<div class="flex items-center gap-3">
<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..."
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>
<button
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
</button>
</div>
</div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
<template x-if="loading">
<div class="p-8 text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-2 text-base-content/60">Loading...</p>
</div>
</template>
<template x-if="!loading && error">
<div class="p-8 text-center">
<i class="fa-solid fa-triangle-exclamation text-4xl text-error mb-2"></i>
<p class="text-error" x-text="error"></p>
</div>
</template>
<template x-if="!loading && !error && list">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="uppercase tracking-wider font-semibold bg-base-200 text-base-content/70 text-xs">
<tr>
<th class="py-3 px-5 font-semibold">Control Name</th>
<th class="py-3 px-5 font-semibold">Lot Number</th>
<th class="py-3 px-5 font-semibold">Producer</th>
<th class="py-3 px-5 font-semibold">Expiry Date</th>
<th class="py-3 px-5 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="text-base-content/80 divide-y divide-base-300">
<template x-for="item in list" :key="item.controlId">
<tr class="hover:bg-base-200 transition-colors">
<td class="py-3 px-5 font-medium text-base-content" x-text="item.controlName"></td>
<td class="py-3 px-5">
<span class="font-mono text-xs bg-base-200 text-base-content/70 px-2 py-1 rounded" x-text="item.lotNumber"></span>
</td>
<td class="py-3 px-5" x-text="item.producer"></td>
<td class="py-3 px-5" x-text="item.expDate"></td>
<td class="py-3 px-5 text-right">
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors"
@click="showForm(item.controlId)"
>
<i class="fa-solid fa-pencil"></i> Edit
</button>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-error bg-error/10 hover:bg-error/20 rounded-lg transition-colors ml-1"
@click="deleteData(item.controlId)"
>
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</template>
<template x-if="list.length === 0">
<tr>
<td colspan="5" class="py-8 text-center text-base-content/60">No data available</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
<?= $this->include('master/control/dialog_control_form'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script"); ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("controls", () => ({
loading: false,
showModal: false,
errors: {},
error: null,
keyword: "",
list: null,
form: {
controlId: null,
controlName: "",
lotNumber: "",
producer: "",
expDate: "",
},
async fetchList() {
this.loading = true;
this.error = null;
this.list = null;
try {
const params = new URLSearchParams({ keyword: this.keyword });
const response = await fetch(`${window.BASEURL}api/master/controls?${params}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load data");
const data = await response.json();
this.list = data.data;
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
async loadData(id) {
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}api/master/controls/${id}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load item");
const data = await response.json();
this.form = data.data[0];
} catch (err) {
this.error = err.message;
this.form = {};
} finally {
this.loading = false;
}
},
async showForm(id = null) {
this.showModal = true;
this.errors = {};
if (id) {
await this.loadData(id);
} else {
this.form = { controlId: null, controlName: "", lotNumber: "", producer: "", expDate: "" };
}
},
closeModal() {
this.showModal = false;
this.form = { controlId: null, controlName: "", lotNumber: "", producer: "", expDate: "" };
},
validate() {
this.errors = {};
if (!this.form.controlName) this.errors.controlName = "Name is required.";
return Object.keys(this.errors).length === 0;
},
async save() {
if (!this.validate()) return;
this.loading = true;
let method = '';
let url = '';
if (this.form.controlId) {
method = 'PATCH';
url = `${window.BASEURL}api/master/controls/${this.form.controlId}`;
} else {
method = 'POST';
url = `${window.BASEURL}api/master/controls`;
}
try {
const res = await fetch(url, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const data = await res.json();
if (data.status === 'success') {
alert("Data saved successfully!");
this.closeModal();
this.fetchList();
} else {
alert(data.message || "Something went wrong.");
}
} catch (err) {
console.error(err);
alert("Failed to save data.");
} finally {
this.loading = false;
}
},
async deleteData(id) {
if (!confirm("Are you sure you want to delete this item?")) return;
this.loading = true;
try {
const res = await fetch(`${window.BASEURL}api/master/controls/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" }
});
const data = await res.json();
if (data.status === 'success') {
alert("Data deleted successfully!");
this.fetchList();
} else {
alert(data.message || "Failed to delete.");
}
} catch (err) {
console.error(err);
alert("Failed to delete data.");
} finally {
this.loading = false;
}
}
}));
});
</script>
<?= $this->endSection(); ?>

View File

@ -0,0 +1,44 @@
<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">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2 text-base-content">
<i class="fa-solid fa-building text-primary"></i>
<span x-text="form.deptId ? 'Edit Department' : 'New Department'"></span>
</h3>
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Department Name</span>
</label>
<input
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="{'border-error': errors.deptName}"
x-model="form.deptName"
placeholder="Enter department name"
/>
<template x-if="errors.deptName">
<label class="label">
<span class="label-text-alt text-error" x-text="errors.deptName"></span>
</label>
</template>
</div>
<div class="modal-action mt-6">
<button class="btn btn-ghost opacity-70" @click="closeModal()">Cancel</button>
<button
class="btn btn-primary gap-2 shadow-lg shadow-primary/20 font-medium"
:class="{'loading': loading}"
@click="save()"
:disabled="loading"
>
<template x-if="!loading">
<span><i class="fa-solid fa-save"></i> Save</span>
</template>
<template x-if="loading">
<span>Saving...</span>
</template>
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop bg-black/60" @click="closeModal()"></form>
</dialog>

View File

@ -0,0 +1,231 @@
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto" x-data="departments()">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Departments</h1>
<p class="text-sm mt-1 opacity-70">Manage laboratory departments</p>
</div>
<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"
@click="showForm()"
>
<i class="fa-solid fa-plus"></i> New Department
</button>
</div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6">
<div class="flex items-center gap-3">
<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..."
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>
<button
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
</button>
</div>
</div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
<template x-if="loading">
<div class="p-8 text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-2 text-base-content/60">Loading...</p>
</div>
</template>
<template x-if="!loading && error">
<div class="p-8 text-center">
<i class="fa-solid fa-triangle-exclamation text-4xl text-error mb-2"></i>
<p class="text-error" x-text="error"></p>
</div>
</template>
<template x-if="!loading && !error && list">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="uppercase tracking-wider font-semibold bg-base-200 text-base-content/70 text-xs">
<tr>
<th class="py-3 px-5 font-semibold">Name</th>
<th class="py-3 px-5 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="text-base-content/80 divide-y divide-base-300">
<template x-for="item in list" :key="item.deptId">
<tr class="hover:bg-base-200 transition-colors">
<td class="py-3 px-5 font-medium text-base-content" x-text="item.deptName"></td>
<td class="py-3 px-5 text-right">
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors"
@click="showForm(item.deptId)"
>
<i class="fa-solid fa-pencil"></i> Edit
</button>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-error bg-error/10 hover:bg-error/20 rounded-lg transition-colors ml-1"
@click="deleteData(item.deptId)"
>
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</template>
<template x-if="list.length === 0">
<tr>
<td colspan="2" class="py-8 text-center text-base-content/60">No data available</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
<?= $this->include('master/dept/dialog_dept_form'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script"); ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("departments", () => ({
loading: false,
showModal: false,
errors: {},
error: null,
keyword: "",
list: null,
form: {
deptId: null,
deptName: "",
},
async fetchList() {
this.loading = true;
this.error = null;
this.list = null;
try {
const params = new URLSearchParams({ keyword: this.keyword });
const response = await fetch(`${window.BASEURL}api/master/depts?${params}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load data");
const data = await response.json();
this.list = data.data;
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
async loadData(id) {
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}api/master/depts/${id}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load item");
const data = await response.json();
this.form = data.data[0];
} catch (err) {
this.error = err.message;
this.form = {};
} finally {
this.loading = false;
}
},
async showForm(id = null) {
this.showModal = true;
this.errors = {};
if (id) {
await this.loadData(id);
} else {
this.form = { deptId: null, deptName: "" };
}
},
closeModal() {
this.showModal = false;
this.form = { deptId: null, deptName: "" };
},
validate() {
this.errors = {};
if (!this.form.deptName) this.errors.deptName = "Name is required.";
return Object.keys(this.errors).length === 0;
},
async save() {
if (!this.validate()) return;
this.loading = true;
let method = '';
let url = '';
if (this.form.deptId) {
method = 'PATCH';
url = `${window.BASEURL}api/master/depts/${this.form.deptId}`;
} else {
method = 'POST';
url = `${window.BASEURL}api/master/depts`;
}
try {
const res = await fetch(url, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const data = await res.json();
if (data.status === 'success') {
alert("Data saved successfully!");
this.closeModal();
this.fetchList();
} else {
alert(data.message || "Something went wrong.");
}
} catch (err) {
console.error(err);
alert("Failed to save data.");
} finally {
this.loading = false;
}
},
async deleteData(id) {
if (!confirm("Are you sure you want to delete this item?")) return;
this.loading = true;
try {
const res = await fetch(`${window.BASEURL}api/master/depts/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" }
});
const data = await res.json();
if (data.status === 'success') {
alert("Data deleted successfully!");
this.fetchList();
} else {
alert(data.message || "Failed to delete.");
}
} catch (err) {
console.error(err);
alert("Failed to delete data.");
} finally {
this.loading = false;
}
}
}));
});
</script>
<?= $this->endSection(); ?>

View File

@ -0,0 +1,113 @@
<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">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2 text-base-content">
<i class="fa-solid fa-flask-vial text-primary"></i>
<span x-text="form.testId ? 'Edit Test' : 'New Test'"></span>
</h3>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Test Name</span>
</label>
<input
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="{'border-error': errors.testName}"
x-model="form.testName"
placeholder="Enter test name"
/>
<template x-if="errors.testName">
<label class="label">
<span class="label-text-alt text-error" x-text="errors.testName"></span>
</label>
</template>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Unit</span>
</label>
<input
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"
x-model="form.testUnit"
placeholder="e.g., mg/dL"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Method</span>
</label>
<input
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"
x-model="form.testMethod"
placeholder="e.g., Enzymatic"
/>
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">CVa</span>
</label>
<input
type="number"
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"
x-model="form.cva"
placeholder="0.00"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">BA</span>
</label>
<input
type="number"
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"
x-model="form.ba"
placeholder="0.00"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">TEa</span>
</label>
<input
type="number"
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"
x-model="form.tea"
placeholder="0.00"
/>
</div>
</div>
</div>
<div class="modal-action mt-6">
<button class="btn btn-ghost opacity-70" @click="closeModal()">Cancel</button>
<button
class="btn btn-primary gap-2 shadow-lg shadow-primary/20 font-medium"
:class="{'loading': loading}"
@click="save()"
:disabled="loading"
>
<template x-if="!loading">
<span><i class="fa-solid fa-save"></i> Save</span>
</template>
<template x-if="loading">
<span>Saving...</span>
</template>
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop bg-black/60" @click="closeModal()"></form>
</dialog>

View File

@ -0,0 +1,240 @@
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto" x-data="tests()">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Tests</h1>
<p class="text-sm mt-1 opacity-70">Manage laboratory tests and parameters</p>
</div>
<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"
@click="showForm()"
>
<i class="fa-solid fa-plus"></i> New Test
</button>
</div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6">
<div class="flex items-center gap-3">
<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..."
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>
<button
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
</button>
</div>
</div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
<template x-if="loading">
<div class="p-8 text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-2 text-base-content/60">Loading...</p>
</div>
</template>
<template x-if="!loading && error">
<div class="p-8 text-center">
<i class="fa-solid fa-triangle-exclamation text-4xl text-error mb-2"></i>
<p class="text-error" x-text="error"></p>
</div>
</template>
<template x-if="!loading && !error && list">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="uppercase tracking-wider font-semibold bg-base-200 text-base-content/70 text-xs">
<tr>
<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">Method</th>
<th class="py-3 px-5 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="text-base-content/80 divide-y divide-base-300">
<template x-for="item in list" :key="item.testId">
<tr class="hover:bg-base-200 transition-colors">
<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.testMethod"></td>
<td class="py-3 px-5 text-right">
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors"
@click="showForm(item.testId)"
>
<i class="fa-solid fa-pencil"></i> Edit
</button>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-error bg-error/10 hover:bg-error/20 rounded-lg transition-colors ml-1"
@click="deleteData(item.testId)"
>
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</template>
<template x-if="list.length === 0">
<tr>
<td colspan="4" class="py-8 text-center text-base-content/60">No data available</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
<?= $this->include('master/test/dialog_test_form'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script"); ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("tests", () => ({
loading: false,
showModal: false,
errors: {},
error: null,
keyword: "",
list: null,
form: {
testId: null,
testName: "",
testUnit: "",
testMethod: "",
cva: "",
ba: "",
tea: "",
},
async fetchList() {
this.loading = true;
this.error = null;
this.list = null;
try {
const params = new URLSearchParams({ keyword: this.keyword });
const response = await fetch(`${window.BASEURL}api/master/tests?${params}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load data");
const data = await response.json();
this.list = data.data;
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
async loadData(id) {
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}api/master/tests/${id}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load item");
const data = await response.json();
this.form = data.data[0];
} catch (err) {
this.error = err.message;
this.form = {};
} finally {
this.loading = false;
}
},
async showForm(id = null) {
this.showModal = true;
this.errors = {};
if (id) {
await this.loadData(id);
} else {
this.form = { testId: null, testName: "", testUnit: "", testMethod: "", cva: "", ba: "", tea: "" };
}
},
closeModal() {
this.showModal = false;
this.form = { testId: null, testName: "", testUnit: "", testMethod: "", cva: "", ba: "", tea: "" };
},
validate() {
this.errors = {};
if (!this.form.testName) this.errors.testName = "Name is required.";
return Object.keys(this.errors).length === 0;
},
async save() {
if (!this.validate()) return;
this.loading = true;
let method = '';
let url = '';
if (this.form.testId) {
method = 'PATCH';
url = `${window.BASEURL}api/master/tests/${this.form.testId}`;
} else {
method = 'POST';
url = `${window.BASEURL}api/master/tests`;
}
try {
const res = await fetch(url, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const data = await res.json();
if (data.status === 'success') {
alert("Data saved successfully!");
this.closeModal();
this.fetchList();
} else {
alert(data.message || "Something went wrong.");
}
} catch (err) {
console.error(err);
alert("Failed to save data.");
} finally {
this.loading = false;
}
},
async deleteData(id) {
if (!confirm("Are you sure you want to delete this item?")) return;
this.loading = true;
try {
const res = await fetch(`${window.BASEURL}api/master/tests/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" }
});
const data = await res.json();
if (data.status === 'success') {
alert("Data deleted successfully!");
this.fetchList();
} else {
alert(data.message || "Failed to delete.");
}
} catch (err) {
console.error(err);
alert("Failed to delete data.");
} finally {
this.loading = false;
}
}
}));
});
</script>
<?= $this->endSection(); ?>

View File

@ -1,141 +1,16 @@
<?= $this->extend("layout/main_layout"); ?> <?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<main x-data="reportIndex()">
<div class="max-w-2xl mx-auto">
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-6">
<div class="mb-6">
<h1 class="text-2xl font-bold text-slate-800">Generate Report</h1>
<p class="text-sm text-slate-500 mt-1">Select parameters to generate QC report</p>
</div>
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6"> <?= $this->section("content"); ?>
<div class="flex items-center gap-2"> <main class="flex-1 p-6 overflow-auto">
<i class="fa-solid fa-circle-exclamation"></i> <div class="flex justify-between items-center mb-6">
<span x-text="error"></span> <div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Reports</h1>
<p class="text-sm mt-1 opacity-70">Generate and view QC reports</p>
</div> </div>
</div> </div>
<form @submit.prevent="generateReport()"> <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
<div class="space-y-4"> <p class="text-base-content/60 text-center py-8">Reports module coming soon...</p>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Month <span class="text-red-500">*</span></label>
<input type="month" name="dates" x-model="dates" :class="errors.dates ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" required>
<p x-show="errors.dates" x-text="errors.dates" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Test <span class="text-red-500">*</span></label>
<select name="test" x-model="test" :class="errors.test ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" required>
<option value="">Select Test</option>
<?php if (isset($tests)): ?>
<?php foreach ($tests as $t): ?>
<option value="<?= $t['test_id'] ?>"><?= $t['name'] ?></option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<p x-show="errors.test" x-text="errors.test" class="text-red-500 text-xs mt-1"></p>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Control 1 <span class="text-red-500">*</span></label>
<select name="control1" x-model="control1" :class="errors.control1 ? 'border-red-300 bg-red-50' : ''" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" required>
<option value="">Select Control</option>
<?php if (isset($controls)): ?>
<?php foreach ($controls as $c): ?>
<option value="<?= $c['control_id'] ?>"><?= $c['name'] ?> (<?= $c['lot'] ?? 'N/A' ?>)</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<p x-show="errors.control1" x-text="errors.control1" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Control 2</label>
<select name="control2" x-model="control2" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">Optional</option>
<?php if (isset($controls)): ?>
<?php foreach ($controls as $c): ?>
<option value="<?= $c['control_id'] ?>"><?= $c['name'] ?> (<?= $c['lot'] ?? 'N/A' ?>)</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Control 3</label>
<select name="control3" x-model="control3" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
<option value="">Optional</option>
<?php if (isset($controls)): ?>
<?php foreach ($controls as $c): ?>
<option value="<?= $c['control_id'] ?>"><?= $c['name'] ?> (<?= $c['lot'] ?? 'N/A' ?>)</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" :disabled="loading" class="btn btn-primary">
<template x-if="!loading">
<span class="flex items-center">
<i class="fa-solid fa-chart-column mr-2"></i>
Generate Report
</span>
</template>
<template x-if="loading">
<span class="flex items-center">
<i class="fa-solid fa-spinner fa-spin mr-2"></i>
Generating...
</span>
</template>
</button>
</div>
</div>
</form>
</div>
</div> </div>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section("script") ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("reportIndex", () => ({
loading: false,
error: '',
dates: '<?= date('Y-m') ?>',
test: '',
control1: '',
control2: '',
control3: '',
errors: {},
validate() {
this.errors = {};
if (!this.dates) this.errors.dates = 'Month is required';
if (!this.test) this.errors.test = 'Test is required';
if (!this.control1) this.errors.control1 = 'Control 1 is required';
return Object.keys(this.errors).length === 0;
},
generateReport() {
if (!this.validate()) {
App.showToast('Please fill all required fields', 'error');
return;
}
const params = new URLSearchParams({
dates: this.dates,
test: this.test,
control1: this.control1
});
if (this.control2) params.append('control2', this.control2);
if (this.control3) params.append('control3', this.control3);
window.location.href = `${window.BASEURL}/report/view?${params.toString()}`;
}
}));
});
</script>
<?= $this->endSection(); ?>

View File

@ -1,286 +1,16 @@
<?= $this->extend("layout/main_layout"); ?> <?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold text-slate-800">QC Report - <?= $dates ?></h2>
<div class="flex items-center space-x-4">
<button onclick="window.print()" class="btn btn-secondary">
<i class="fa-solid fa-print mr-2"></i>
Print Report
</button>
</div>
</div>
<?php if (count($reportData) > 1): ?> <?= $this->section("content"); ?>
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-6"> <main class="flex-1 p-6 overflow-auto">
<h3 class="text-lg font-semibold text-slate-800 mb-4">QC Trend Overview</h3> <div class="flex justify-between items-center mb-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="h-80">
<canvas id="trend-chart"></canvas>
</div>
<div class="h-80">
<canvas id="comparison-chart"></canvas>
</div>
</div>
</div>
<?php endif; ?>
<?php foreach ($reportData as $index => $data): ?>
<div class="bg-white rounded-xl border border-slate-100 shadow-sm overflow-hidden page-break" id="report-<?= $index ?>">
<div class="px-6 py-4 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
<div> <div>
<h3 class="text-lg font-semibold text-slate-800"><?= $data['control']['name'] ?> (Lot: <?= $data['control']['lot'] ?? 'N/A' ?>)</h3> <h1 class="text-2xl font-bold text-base-content tracking-tight">Report View</h1>
<p class="text-sm text-slate-500"><?= $data['test']['name'] ?? 'N/A' ?></p> <p class="text-sm mt-1 opacity-70">View detailed QC report</p>
</div>
<div class="flex items-center space-x-2">
<span class="px-3 py-1 text-sm rounded-full <?= $data['outOfRange'] > 0 ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' ?>">
<?= $data['outOfRange'] ?> Out of Range
</span>
</div> </div>
</div> </div>
<?php if ($data['controlTest']): ?> <div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
<?php <p class="text-base-content/60 text-center py-8">Report viewer coming soon...</p>
$mean = $data['controlTest']['mean'];
$sd = $data['controlTest']['sd'];
$results = $data['results'];
$daysInMonth = date('t', strtotime($dates . '-01'));
$chartLabels = [];
$chartValues = [];
$upperLimit = $mean + 2 * $sd;
$lowerLimit = $mean - 2 * $sd;
for ($day = 1; $day <= $daysInMonth; $day++) {
$chartLabels[] = $day;
$value = null;
foreach ($results as $result) {
if (date('j', strtotime($result['resdate'])) == $day) {
$value = $result['resvalue'];
break;
}
}
$chartValues[] = $value !== null ? $value : 'null';
}
?>
<div class="p-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="text-center p-4 bg-slate-50 rounded-xl">
<p class="text-sm text-slate-500">Mean</p>
<p class="text-xl font-bold text-slate-800"><?= number_format($mean, 3) ?></p>
</div> </div>
<div class="text-center p-4 bg-slate-50 rounded-xl"> </main>
<p class="text-sm text-slate-500">SD</p>
<p class="text-xl font-bold text-slate-800"><?= number_format($sd, 3) ?></p>
</div>
<div class="text-center p-4 bg-green-50 rounded-xl">
<p class="text-sm text-green-600">+2SD</p>
<p class="text-xl font-bold text-green-700"><?= number_format($upperLimit, 3) ?></p>
</div>
<div class="text-center p-4 bg-red-50 rounded-xl">
<p class="text-sm text-red-600">-2SD</p>
<p class="text-xl font-bold text-red-700"><?= number_format($lowerLimit, 3) ?></p>
</div>
</div>
<div class="mb-6">
<h4 class="text-sm font-medium text-slate-700 mb-2">Trend Chart</h4>
<div class="h-64">
<canvas id="chart-<?= $index ?>"></canvas>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wider">
<tr>
<th class="py-3 px-4 font-semibold">Day</th>
<th class="py-3 px-4 font-semibold">Value</th>
<th class="py-3 px-4 font-semibold">Z-Score</th>
<th class="py-3 px-4 font-semibold">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<?php
for ($day = 1; $day <= $daysInMonth; $day++):
$value = null;
foreach ($results as $result) {
if (date('j', strtotime($result['resdate'])) == $day) {
$value = $result['resvalue'];
break;
}
}
$zScore = ($value && $sd > 0) ? ($value - $mean) / $sd : null;
$status = $zScore !== null ? (abs($zScore) > 2 ? 'Out' : (abs($zScore) > 1 ? 'Warn' : 'OK')) : '-';
$statusClass = $status == 'Out' ? 'text-red-600 font-bold bg-red-50' : ($status == 'Warn' ? 'text-yellow-600 bg-yellow-50' : 'text-green-600');
?>
<tr class="hover:bg-slate-50/50">
<td class="py-3 px-4 text-slate-800"><?= $day ?></td>
<td class="py-3 px-4 font-medium text-slate-800"><?= $value ?? '-' ?></td>
<td class="py-3 px-4 text-slate-600"><?= $zScore !== null ? number_format($zScore, 2) : '-' ?></td>
<td class="py-3 px-4 <?= $statusClass ?>"><?= $status ?></td>
</tr>
<?php endfor; ?>
</tbody>
</table>
</div>
<?php if ($data['comment']): ?>
<div class="mt-4 p-4 bg-amber-50 rounded-xl">
<p class="text-sm font-medium text-amber-800">Monthly Comment:</p>
<p class="text-slate-700"><?= $data['comment']['comtext'] ?></p>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<div class="p-6 text-center text-slate-500">
No test data found for this control.
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section("script") ?>
<script>
const reportData = <?= json_encode(array_map(function($data) use ($dates) {
$mean = $data['controlTest']['mean'] ?? 0;
$sd = $data['controlTest']['sd'] ?? 1;
$daysInMonth = date('t', strtotime($dates . '-01'));
$values = [];
for ($day = 1; $day <= $daysInMonth; $day++) {
$value = null;
foreach ($data['results'] as $result) {
if (date('j', strtotime($result['resdate'])) == $day) {
$value = $result['resvalue'];
break;
}
}
$values[] = $value;
}
return [
'name' => $data['control']['name'],
'lot' => $data['control']['lot'],
'mean' => $mean,
'sd' => $sd,
'values' => $values
];
}, $reportData)) ?>;
const dates = '<?= $dates ?>';
const daysInMonth = Array.from({length: 31}, (_, i) => i + 1);
function initReportCharts() {
if (typeof ChartManager === 'undefined') {
setTimeout(initReportCharts, 100);
return;
}
if (reportData.length > 1) {
const trendDatasets = reportData.map((data, i) => ({
label: data.name,
data: data.values,
color: ChartManager.getColor(i)
}));
ChartManager.createTrendChart('trend-chart', daysInMonth, trendDatasets);
const comparisonDatasets = reportData.map((data, i) => ({
label: data.name,
data: data.values,
color: ChartManager.getColor(i)
}));
ChartManager.createComparisonChart('comparison-chart', daysInMonth, comparisonDatasets);
}
reportData.forEach((data, index) => {
const ctx = document.getElementById(`chart-${index}`);
if (ctx) {
new Chart(ctx, {
type: 'line',
data: {
labels: daysInMonth,
datasets: [{
label: data.name,
data: data.values,
borderColor: ChartManager.getColor(index),
backgroundColor: ChartManager.getColor(index, 0.1),
tension: 0.3,
fill: true,
pointRadius: 4,
pointBackgroundColor: data.values.map(v => {
if (v === null) return 'transparent';
if (data.sd === 0) return ChartManager.getColor(index);
const z = (v - data.mean) / data.sd;
return Math.abs(z) > 2 ? '#ef4444' : (Math.abs(z) > 1 ? '#f59e0b' : ChartManager.getColor(index));
})
}, {
label: '+2SD',
data: Array(31).fill(data.mean + 2 * data.sd),
borderColor: '#22c55e',
borderDash: [5, 5],
pointRadius: 0,
fill: false
}, {
label: '-2SD',
data: Array(31).fill(data.mean - 2 * data.sd),
borderColor: '#ef4444',
borderDash: [5, 5],
pointRadius: 0,
fill: false
}, {
label: 'Mean',
data: Array(31).fill(data.mean),
borderColor: '#3b82f6',
borderDash: [2, 2],
pointRadius: 0,
fill: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top'
},
title: {
display: true,
text: `${data.name} (Mean: ${data.mean.toFixed(3)}, SD: ${data.sd.toFixed(3)})`
}
},
scales: {
y: {
title: {
display: true,
text: 'Value'
}
},
x: {
title: {
display: true,
text: 'Day'
}
}
}
}
});
}
});
}
document.addEventListener('DOMContentLoaded', initReportCharts);
</script>
<style>
@media print {
.page-break {
page-break-inside: avoid;
}
.bg-white {
box-shadow: none !important;
}
}
</style>
<?= $this->endSection(); ?>

View File

@ -1,89 +0,0 @@
<!-- Backdrop -->
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-40"
@click="closeModal()">
</div>
<!-- Modal Panel -->
<div x-show="showModal"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl" @click.stop>
<!-- Header -->
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-800" x-text="form.test_id ? 'Edit Test' : 'Add Test'"></h3>
<button @click="closeModal()" class="text-slate-400 hover:text-slate-600">
<i class="fa-solid fa-xmark text-xl"></i>
</button>
</div>
<!-- Form -->
<form @submit.prevent="save()">
<div class="p-6">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">Department <span class="text-red-500">*</span></label>
<select x-model="form.dept_ref_id" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" :class="{'border-red-300 bg-red-50': errors.dept_ref_id}" required>
<option value="">Select Department</option>
<template x-for="dept in depts" :key="dept.dept_id">
<option :value="dept.dept_id" x-text="dept.name"></option>
</template>
</select>
<p x-show="errors.dept_ref_id" x-text="errors.dept_ref_id" class="text-red-500 text-xs mt-1"></p>
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">Test Name <span class="text-red-500">*</span></label>
<input type="text" x-model="form.name" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" :class="{'border-red-300 bg-red-50': errors.name}" placeholder="Enter test name" required>
<p x-show="errors.name" x-text="errors.name" class="text-red-500 text-xs mt-1"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Unit</label>
<input type="text" x-model="form.unit" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="e.g., mg/dL">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Method</label>
<input type="text" x-model="form.method" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="e.g., Spectrophotometry">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">CVA</label>
<input type="text" x-model="form.cva" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Coefficient of Variation A">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">BA</label>
<input type="text" x-model="form.ba" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Bias A">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">TEA</label>
<input type="text" x-model="form.tea" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Total Error Allowable">
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-100 bg-slate-50/50 rounded-b-2xl">
<button type="button" @click="closeModal()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 transition-colors">Cancel</button>
<button type="submit" :disabled="loading" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<template x-if="!loading">
<span><i class="fa-solid fa-check mr-1"></i> <span x-text="form.test_id ? 'Update' : 'Save'"></span></span>
</template>
<template x-if="loading">
<span><i class="fa-solid fa-spinner fa-spin mr-1"></i> Saving...</span>
</template>
</button>
</div>
</form>
</div>
</div>

View File

@ -1,231 +0,0 @@
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<main x-data="testIndex()">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Test Dictionary</h1>
<p class="text-sm text-slate-500 mt-1">Manage test types, methods, and units</p>
</div>
<button @click="showForm()" class="btn btn-primary">
<i class="fa-solid fa-plus mr-2"></i>Add Test
</button>
</div>
<!-- Error Alert -->
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
<div class="flex items-center gap-2">
<i class="fa-solid fa-circle-exclamation"></i>
<span x-text="error"></span>
</div>
</div>
<!-- Search Card -->
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-4 mb-6">
<div class="flex gap-3">
<div class="flex-1 relative">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
<input type="text" x-model="keyword" @keyup.enter="fetchList()" class="w-full pl-10 pr-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Search tests...">
</div>
<button @click="fetchList()" class="btn btn-primary">
<i class="fa-solid fa-magnifying-glass mr-2"></i>Search
</button>
</div>
</div>
<!-- Data Table Card -->
<div class="bg-white rounded-xl border border-slate-100 shadow-sm overflow-hidden">
<!-- Loading State -->
<template x-if="loading">
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center mx-auto mb-4">
<i class="fa-solid fa-spinner fa-spin text-blue-500 text-xl"></i>
</div>
<p class="text-slate-500 text-sm">Loading tests...</p>
</div>
</template>
<!-- Empty State -->
<template x-if="!loading && (!list || list.length === 0)">
<div class="flex-1 flex items-center justify-center p-8">
<div class="text-center">
<div class="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mx-auto mb-3">
<i class="fa-solid fa-vial text-slate-400 text-xl"></i>
</div>
<p class="text-slate-500 text-sm">No tests found</p>
</div>
</div>
</template>
<!-- Data Table -->
<template x-if="!loading && list && list.length > 0">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wider">
<tr>
<th class="py-3 px-5 font-semibold">#</th>
<th class="py-3 px-5 font-semibold">Name</th>
<th class="py-3 px-5 font-semibold">Department</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 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-for="(item, index) in list" :key="item.test_id">
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="py-3 px-5" x-text="index + 1"></td>
<td class="py-3 px-5 font-medium text-slate-800" x-text="item.name"></td>
<td class="py-3 px-5 text-slate-600" x-text="item.dept_name || '-'"></td>
<td class="py-3 px-5 text-slate-600" x-text="item.unit || '-'"></td>
<td class="py-3 px-5 text-slate-600" x-text="item.method || '-'"></td>
<td class="py-3 px-5 text-right">
<button @click="showForm(item.test_id)" class="text-blue-600 hover:text-blue-800 mr-3">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button @click="deleteItem(item.test_id)" class="text-red-600 hover:text-red-800">
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Dialog Include -->
<?= $this->include('test/dialog_form'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script") ?>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data("testIndex", () => ({
loading: false,
showModal: false,
list: [],
form: {},
errors: {},
error: '',
keyword: '',
depts: <?= json_encode($depts ?? []) ?>,
init() {
this.fetchList();
},
async fetchList() {
this.loading = true;
this.error = '';
try {
const res = await fetch(`${window.BASEURL}/api/test`);
const data = await res.json();
if (data.status === 'success') {
this.list = data.data || [];
} else {
this.error = data.message || 'Failed to load tests';
}
} catch (err) {
console.error(err);
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
},
showForm(id = null) {
this.errors = {};
if (id) {
const item = this.list.find(x => x.test_id === id);
if (item) {
this.form = {
test_id: item.test_id,
dept_ref_id: item.dept_ref_id,
name: item.name,
unit: item.unit || '',
method: item.method || '',
cva: item.cva || '',
ba: item.ba || '',
tea: item.tea || ''
};
}
} else {
this.form = {
dept_ref_id: '',
name: '',
unit: '',
method: '',
cva: '',
ba: '',
tea: ''
};
}
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errors = {};
this.form = {};
},
validate() {
this.errors = {};
if (!this.form.dept_ref_id) this.errors.dept_ref_id = 'Department is required';
if (!this.form.name) this.errors.name = 'Test name is required';
return Object.keys(this.errors).length === 0;
},
async save() {
if (!this.validate()) return;
this.loading = true;
try {
const url = this.form.test_id
? `${window.BASEURL}/api/test/${this.form.test_id}`
: `${window.BASEURL}/api/test`;
const res = await fetch(url, {
method: this.form.test_id ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
const data = await res.json();
if (data.status === 'success') {
this.closeModal();
this.fetchList();
} else {
this.error = data.message || 'Failed to save test';
}
} catch (err) {
console.error(err);
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
},
async deleteItem(id) {
if (!confirm('Are you sure you want to delete this test?')) return;
this.loading = true;
try {
const res = await fetch(`${window.BASEURL}/api/test/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.status === 'success') {
this.fetchList();
} else {
this.error = data.message || 'Failed to delete test';
}
} catch (err) {
console.error(err);
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}));
});
</script>
<?= $this->endSection(); ?>

BIN
backup/qc20260114.bak Normal file

Binary file not shown.

BIN
backup/script.sql Normal file

Binary file not shown.

170
backup/script_utf8.sql Normal file
View File

@ -0,0 +1,170 @@
USE [master]
GO
/****** Object: Database [cmod_qc] Script Date: 17/01/2026 18:26:32 ******/
CREATE DATABASE [cmod_qc]
CONTAINMENT = NONE
ON PRIMARY
( NAME = N'cmod_qc', FILENAME = N'C:\db\cmod_qc.mdf' , SIZE = 8192KB , MAXSIZE = UNLIMITED, FILEGROWTH = 1024KB )
LOG ON
( NAME = N'cmod_qc_log', FILENAME = N'C:\db\cmod_qc_log.ldf' , SIZE = 32448KB , MAXSIZE = 2048GB , FILEGROWTH = 10%)
WITH CATALOG_COLLATION = DATABASE_DEFAULT, LEDGER = OFF
GO
ALTER DATABASE [cmod_qc] SET COMPATIBILITY_LEVEL = 110
GO
IF (1 = FULLTEXTSERVICEPROPERTY('IsFullTextInstalled'))
begin
EXEC [cmod_qc].[dbo].[sp_fulltext_database] @action = 'enable'
end
GO
ALTER DATABASE [cmod_qc] SET ANSI_NULL_DEFAULT OFF
GO
ALTER DATABASE [cmod_qc] SET ANSI_NULLS OFF
GO
ALTER DATABASE [cmod_qc] SET ANSI_PADDING OFF
GO
ALTER DATABASE [cmod_qc] SET ANSI_WARNINGS OFF
GO
ALTER DATABASE [cmod_qc] SET ARITHABORT OFF
GO
ALTER DATABASE [cmod_qc] SET AUTO_CLOSE OFF
GO
ALTER DATABASE [cmod_qc] SET AUTO_SHRINK OFF
GO
ALTER DATABASE [cmod_qc] SET AUTO_UPDATE_STATISTICS ON
GO
ALTER DATABASE [cmod_qc] SET CURSOR_CLOSE_ON_COMMIT OFF
GO
ALTER DATABASE [cmod_qc] SET CURSOR_DEFAULT GLOBAL
GO
ALTER DATABASE [cmod_qc] SET CONCAT_NULL_YIELDS_NULL OFF
GO
ALTER DATABASE [cmod_qc] SET NUMERIC_ROUNDABORT OFF
GO
ALTER DATABASE [cmod_qc] SET QUOTED_IDENTIFIER OFF
GO
ALTER DATABASE [cmod_qc] SET RECURSIVE_TRIGGERS OFF
GO
ALTER DATABASE [cmod_qc] SET DISABLE_BROKER
GO
ALTER DATABASE [cmod_qc] SET AUTO_UPDATE_STATISTICS_ASYNC OFF
GO
ALTER DATABASE [cmod_qc] SET DATE_CORRELATION_OPTIMIZATION OFF
GO
ALTER DATABASE [cmod_qc] SET TRUSTWORTHY OFF
GO
ALTER DATABASE [cmod_qc] SET ALLOW_SNAPSHOT_ISOLATION OFF
GO
ALTER DATABASE [cmod_qc] SET PARAMETERIZATION SIMPLE
GO
ALTER DATABASE [cmod_qc] SET READ_COMMITTED_SNAPSHOT OFF
GO
ALTER DATABASE [cmod_qc] SET HONOR_BROKER_PRIORITY OFF
GO
ALTER DATABASE [cmod_qc] SET RECOVERY SIMPLE
GO
ALTER DATABASE [cmod_qc] SET MULTI_USER
GO
ALTER DATABASE [cmod_qc] SET PAGE_VERIFY CHECKSUM
GO
ALTER DATABASE [cmod_qc] SET DB_CHAINING OFF
GO
ALTER DATABASE [cmod_qc] SET FILESTREAM( NON_TRANSACTED_ACCESS = OFF )
GO
ALTER DATABASE [cmod_qc] SET TARGET_RECOVERY_TIME = 0 SECONDS
GO
ALTER DATABASE [cmod_qc] SET DELAYED_DURABILITY = DISABLED
GO
ALTER DATABASE [cmod_qc] SET ACCELERATED_DATABASE_RECOVERY = OFF
GO
ALTER DATABASE [cmod_qc] SET QUERY_STORE = OFF
GO
USE [cmod_qc]
GO
/****** Object: Table [dbo].[CONTROL_TEST] Script Date: 17/01/2026 18:26:33 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[CONTROL_TEST](
[id] [int] IDENTITY(1,1) NOT NULL,
[controlid] [int] NULL,
[testid] [int] NULL,
[mean] [float] NULL,
[sd] [float] NULL
) ON [PRIMARY]
GO
/****** Object: Table [dbo].[DAILY_RESULT] Script Date: 17/01/2026 18:26:33 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[DAILY_RESULT](
[id] [int] IDENTITY(1,1) NOT NULL,
[controlid] [int] NULL,
[testid] [int] NULL,
[resdate] [datetime] NULL,
[resvalue] [varchar](50) NULL,
[rescomment] [text] NULL
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
/****** Object: Table [dbo].[DICT_CONTROL] Script Date: 17/01/2026 18:26:33 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[DICT_CONTROL](
[id] [int] IDENTITY(1,1) NOT NULL,
[deptid] [int] NULL,
[name] [varchar](50) NULL,
[lot] [varchar](50) NULL,
[producer] [text] NULL,
[expdate] [date] NULL
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
/****** Object: Table [dbo].[DICT_DEPT] Script Date: 17/01/2026 18:26:33 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[DICT_DEPT](
[id] [int] IDENTITY(1,1) NOT NULL,
[name] [varchar](50) NULL,
CONSTRAINT [PK_DICT_DEPT] PRIMARY KEY CLUSTERED
(
[id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
/****** Object: Table [dbo].[DICT_TEST] Script Date: 17/01/2026 18:26:33 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[DICT_TEST](
[id] [int] IDENTITY(1,1) NOT NULL,
[deptid] [int] NULL,
[name] [varchar](50) NULL,
[unit] [varchar](50) NULL,
[method] [varchar](50) NULL,
[cva] [varchar](50) NULL,
[ba] [varchar](50) NULL,
[tea] [varchar](50) NULL
) ON [PRIMARY]
GO
/****** Object: Table [dbo].[MONTHLY_COMMENT] Script Date: 17/01/2026 18:26:33 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[MONTHLY_COMMENT](
[id] [int] IDENTITY(1,1) NOT NULL,
[controlid] [int] NOT NULL,
[testid] [int] NOT NULL,
[commonth] [varchar](7) NOT NULL,
[comtext] [text] NULL
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
USE [master]
GO
ALTER DATABASE [cmod_qc] SET READ_WRITE
GO

1009
docs/PRD.md Normal file

File diff suppressed because it is too large Load Diff

1852
docs/llms.txt Normal file

File diff suppressed because it is too large Load Diff

221
docs/prd.json Normal file
View File

@ -0,0 +1,221 @@
{
"name": "TinyQC Product Requirements Document (PRD)",
"description": "**TinyQC** is a lightweight, web-based Laboratory Quality Control (QC) Management System designed to help laboratories record, track, and analyze quality control test results with statistical analysis capabilities.",
"branchName": "main",
"userStories": [
{
"id": "US-001",
"title": "View Departments",
"description": "View Departments",
"acceptanceCriteria": [],
"priority": 1,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-002",
"title": "Create Department",
"description": "Create Department",
"acceptanceCriteria": [],
"priority": 2,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-003",
"title": "Edit Department",
"description": "Edit Department",
"acceptanceCriteria": [],
"priority": 3,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-004",
"title": "Delete Department",
"description": "Delete Department",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-005",
"title": "View Tests",
"description": "View Tests",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-006",
"title": "Create Test",
"description": "Create Test",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-007",
"title": "Edit Test",
"description": "Edit Test",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-008",
"title": "Delete Test",
"description": "Delete Test",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-009",
"title": "View Controls",
"description": "View Controls",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-010",
"title": "Create Control",
"description": "Create Control",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-011",
"title": "Edit Control",
"description": "Edit Control",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-012",
"title": "Delete Control",
"description": "Delete Control",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-013",
"title": "Daily Entry Interface",
"description": "Daily Entry Interface",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-014",
"title": "Save Daily Result",
"description": "Save Daily Result",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-015",
"title": "Monthly Entry Interface",
"description": "Monthly Entry Interface",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-016",
"title": "Save Monthly Results",
"description": "Save Monthly Results",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-017",
"title": "Daily Comments",
"description": "User can entry comment per day, per test.",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-018",
"title": "Report Generation Form",
"description": "Report Generation Form",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
},
{
"id": "US-019",
"title": "Report Display",
"description": "Report Display",
"acceptanceCriteria": [],
"priority": 4,
"passes": true,
"labels": [],
"dependsOn": [],
"completionNotes": "Completed by agent"
}
],
"metadata": {
"createdAt": "2026-01-16T09:22:41.726Z",
"version": "1.0.0",
"updatedAt": "2026-01-16T10:27:48.837Z"
}
}

238
index.json Normal file
View File

@ -0,0 +1,238 @@
{
"generatedAt": "2026-01-18T10:00:00Z",
"techStack": {
"backend": "CodeIgniter 4 (PHP 8.1+)",
"frontend": "Alpine.js + TailwindCSS + daisyUI",
"database": "MySQL/MariaDB"
},
"summary": {
"controllers": 7,
"models": 7,
"migrations": 1,
"apiEndpoints": 45,
"views": 18,
"tables": 6
},
"agentsMdSummary": {
"codingConventions": [
"Controllers extend BaseController + ResponseTrait",
"Models extend App\\Models\\BaseModel (auto snake/camel conversion)",
"All tables use soft deletes (deleted_at)",
"API response format: { status, message, data }",
"Frontend uses camelCase, DB uses snake_case"
],
"namingPatterns": {
"controllers": "PascalCase + Controller",
"models": "PascalCase + Model",
"tables": "snake_case, prefix master_ for master data",
"primaryKeys": "{table_singular}_id"
},
"directoryStructure": {
"app/Controllers/": "API & page controllers",
"app/Controllers/Master/": "Master data CRUD",
"app/Controllers/Qc/": "QC operations",
"app/Models/": "Eloquent-style models",
"app/Models/Master/": "Master data models",
"app/Models/Qc/": "QC operation models",
"app/Database/Migrations/": "Schema definitions",
"app/Config/Routes.php": "All routes",
"app/Helpers/": "Utility functions",
"app/Views/": "PHP views with Alpine.js"
},
"apiResponseFormat": {
"success": "{ status: 'success', message: 'fetch success', data: rows }",
"created": "{ status: 'success', message: id }",
"error": "failServerError or failValidationErrors"
}
},
"tables": [
{
"name": "master_depts",
"pk": "dept_id",
"columns": ["dept_id", "name", "created_at", "updated_at", "deleted_at"],
"softDeletes": true,
"description": "Departments/laboratory sections"
},
{
"name": "master_controls",
"pk": "control_id",
"columns": ["control_id", "dept_id", "name", "lot", "producer", "exp_date", "created_at", "updated_at", "deleted_at"],
"softDeletes": true,
"description": "QC control materials"
},
{
"name": "master_tests",
"pk": "test_id",
"columns": ["test_id", "dept_id", "name", "unit", "method", "cva", "ba", "tea", "created_at", "updated_at", "deleted_at"],
"softDeletes": true,
"description": "Available tests/methods"
},
{
"name": "control_tests",
"pk": "control_test_id",
"columns": ["control_test_id", "control_id", "test_id", "mean", "sd", "created_at", "updated_at", "deleted_at"],
"softDeletes": true,
"description": "Junction: controls linked to tests with mean/sd"
},
{
"name": "results",
"pk": "result_id",
"columns": ["result_id", "control_id", "test_id", "res_date", "res_value", "res_comment", "created_at", "updated_at", "deleted_at"],
"softDeletes": true,
"description": "Individual QC test results"
},
{
"name": "result_comments",
"pk": "result_comment_id",
"columns": ["result_comment_id", "control_id", "test_id", "comment_month", "com_text", "created_at", "updated_at", "deleted_at"],
"softDeletes": true,
"description": "Monthly comments per control/test"
}
],
"controllers": [
{
"name": "BaseController",
"namespace": "App\\Controllers",
"file": "app/Controllers/BaseController.php",
"methods": ["initController"],
"abstract": true
},
{
"name": "PageController",
"namespace": "App\\Controllers",
"file": "app/Controllers/PageController.php",
"methods": ["dashboard", "masterDept", "masterTest", "masterControl", "entry", "entryDaily", "report", "reportView"]
},
{
"name": "MasterDeptsController",
"namespace": "App\\Controllers\\Master",
"file": "app/Controllers/Master/MasterDeptsController.php",
"methods": ["index", "show", "create", "update", "delete"]
},
{
"name": "MasterTestsController",
"namespace": "App\\Controllers\\Master",
"file": "app/Controllers/Master/MasterTestsController.php",
"methods": ["index", "show", "create", "update", "delete"]
},
{
"name": "MasterControlsController",
"namespace": "App\\Controllers\\Master",
"file": "app/Controllers/Master/MasterControlsController.php",
"methods": ["index", "show", "create", "update", "delete"]
},
{
"name": "ResultsController",
"namespace": "App\\Controllers\\Qc",
"file": "app/Controllers/Qc/ResultsController.php",
"methods": ["index", "show", "create", "update", "delete"]
},
{
"name": "ControlTestsController",
"namespace": "App\\Controllers\\Qc",
"file": "app/Controllers/Qc/ControlTestsController.php",
"methods": ["index", "show", "create", "update", "delete"]
},
{
"name": "ResultCommentsController",
"namespace": "App\\Controllers\\Qc",
"file": "app/Controllers/Qc/ResultCommentsController.php",
"methods": ["index", "show", "create", "update", "delete"]
}
],
"models": [
{
"name": "BaseModel",
"namespace": "App\\Models",
"file": "app/Models/BaseModel.php",
"table": null,
"pk": null,
"abstract": true,
"features": ["auto snake/camel conversion", "soft deletes", "timestamps"]
},
{
"name": "MasterDeptsModel",
"namespace": "App\\Models\\Master",
"file": "app/Models/Master/MasterDeptsModel.php",
"table": "master_depts",
"pk": "dept_id"
},
{
"name": "MasterTestsModel",
"namespace": "App\\Models\\Master",
"file": "app/Models/Master/MasterTestsModel.php",
"table": "master_tests",
"pk": "test_id"
},
{
"name": "MasterControlsModel",
"namespace": "App\\Models\\Master",
"file": "app/Models/Master/MasterControlsModel.php",
"table": "master_controls",
"pk": "control_id"
},
{
"name": "ResultsModel",
"namespace": "App\\Models\\Qc",
"file": "app/Models/Qc/ResultsModel.php",
"table": "results",
"pk": "result_id"
},
{
"name": "ControlTestsModel",
"namespace": "App\\Models\\Qc",
"file": "app/Models/Qc/ControlTestsModel.php",
"table": "control_tests",
"pk": "control_test_id"
},
{
"name": "ResultCommentsModel",
"namespace": "App\\Models\\Qc",
"file": "app/Models/Qc/ResultCommentsModel.php",
"table": "result_comments",
"pk": "result_comment_id"
}
],
"apiEndpoints": [
{ "method": "GET", "path": "/api/master/depts", "controller": "Master\\MasterDeptsController::index" },
{ "method": "GET", "path": "/api/master/depts/(:num)", "controller": "Master\\MasterDeptsController::show/$1" },
{ "method": "POST", "path": "/api/master/depts", "controller": "Master\\MasterDeptsController::create" },
{ "method": "PATCH", "path": "/api/master/depts/(:num)", "controller": "Master\\MasterDeptsController::update/$1" },
{ "method": "DELETE", "path": "/api/master/depts/(:num)", "controller": "Master\\MasterDeptsController::delete/$1" },
{ "method": "GET", "path": "/api/master/tests", "controller": "Master\\MasterTestsController::index" },
{ "method": "GET", "path": "/api/master/tests/(:num)", "controller": "Master\\MasterTestsController::show/$1" },
{ "method": "POST", "path": "/api/master/tests", "controller": "Master\\MasterTestsController::create" },
{ "method": "PATCH", "path": "/api/master/tests/(:num)", "controller": "Master\\MasterTestsController::update/$1" },
{ "method": "DELETE", "path": "/api/master/tests/(:num)", "controller": "Master\\MasterTestsController::delete/$1" },
{ "method": "GET", "path": "/api/master/controls", "controller": "Master\\MasterControlsController::index" },
{ "method": "GET", "path": "/api/master/controls/(:num)", "controller": "Master\\MasterControlsController::show/$1" },
{ "method": "POST", "path": "/api/master/controls", "controller": "Master\\MasterControlsController::create" },
{ "method": "PATCH", "path": "/api/master/controls/(:num)", "controller": "Master\\MasterControlsController::update/$1" },
{ "method": "DELETE", "path": "/api/master/controls/(:num)", "controller": "Master\\MasterControlsController::delete/$1" },
{ "method": "GET", "path": "/api/qc/control-tests", "controller": "Qc\\ControlTestsController::index" },
{ "method": "GET", "path": "/api/qc/control-tests/(:num)", "controller": "Qc\\ControlTestsController::show/$1" },
{ "method": "POST", "path": "/api/qc/control-tests", "controller": "Qc\\ControlTestsController::create" },
{ "method": "PATCH", "path": "/api/qc/control-tests/(:num)", "controller": "Qc\\ControlTestsController::update/$1" },
{ "method": "DELETE", "path": "/api/qc/control-tests/(:num)", "controller": "Qc\\ControlTestsController::delete/$1" },
{ "method": "GET", "path": "/api/qc/results", "controller": "Qc\\ResultsController::index" },
{ "method": "GET", "path": "/api/qc/results/(:num)", "controller": "Qc\\ResultsController::show/$1" },
{ "method": "POST", "path": "/api/qc/results", "controller": "Qc\\ResultsController::create" },
{ "method": "PATCH", "path": "/api/qc/results/(:num)", "controller": "Qc\\ResultsController::update/$1" },
{ "method": "DELETE", "path": "/api/qc/results/(:num)", "controller": "Qc\\ResultsController::delete/$1" },
{ "method": "GET", "path": "/api/qc/result-comments", "controller": "Qc\\ResultCommentsController::index" },
{ "method": "GET", "path": "/api/qc/result-comments/(:num)", "controller": "Qc\\ResultCommentsController::show/$1" },
{ "method": "POST", "path": "/api/qc/result-comments", "controller": "Qc\\ResultCommentsController::create" },
{ "method": "PATCH", "path": "/api/qc/result-comments/(:num)", "controller": "Qc\\ResultCommentsController::update/$1" },
{ "method": "DELETE", "path": "/api/qc/result-comments/(:num)", "controller": "Qc\\ResultCommentsController::delete/$1" }
],
"routes": [
{ "method": "GET", "path": "/", "controller": "PageController::dashboard" },
{ "method": "GET", "path": "/master/dept", "controller": "PageController::masterDept" },
{ "method": "GET", "path": "/master/test", "controller": "PageController::masterTest" },
{ "method": "GET", "path": "/master/control", "controller": "PageController::masterControl" },
{ "method": "GET", "path": "/entry", "controller": "PageController::entry" },
{ "method": "GET", "path": "/entry/daily", "controller": "PageController::entryDaily" },
{ "method": "GET", "path": "/report", "controller": "PageController::report" },
{ "method": "GET", "path": "/report/view", "controller": "PageController::reportView" }
]
}

BIN
public/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

18
public/images/favicon.svg Normal file
View File

@ -0,0 +1,18 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#10B981" />
<stop offset="100%" stop-color="#3B82F6" />
</linearGradient>
</defs>
<!-- Main shape: A rounded vial shape -->
<path d="M180 100 H332 V320 C332 362 298 396 256 396 C214 396 180 362 180 320 V100 Z" fill="url(#grad)" />
<rect x="160" y="80" width="192" height="24" rx="12" fill="url(#grad)" />
<!-- Content: Bar chart and Checkmark -->
<rect x="210" y="280" width="24" height="60" rx="12" fill="white" />
<rect x="244" y="240" width="24" height="100" rx="12" fill="white" />
<rect x="278" y="210" width="24" height="130" rx="12" fill="white" />
<path d="M200 200 L240 240 L340 140" stroke="white" stroke-width="32" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 956 B

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View File

@ -1,88 +0,0 @@
document.addEventListener('alpine:init', () => {
Alpine.store('appState', {
loading: false,
sidebarOpen: false
});
});
window.App = {
loading: false,
sidebarOpen: false,
init() {
this.setupSidebar();
this.setupKeyboardShortcuts();
},
setupSidebar() {
const sidebar = document.getElementById('sidebar');
const backdrop = document.getElementById('sidebar-backdrop');
const toggleBtn = document.getElementById('sidebar-toggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
this.sidebarOpen = !this.sidebarOpen;
if (window.Alpine) {
Alpine.store('appState').sidebarOpen = this.sidebarOpen;
}
});
}
if (backdrop) {
backdrop.addEventListener('click', () => {
this.sidebarOpen = false;
if (window.Alpine) {
Alpine.store('appState').sidebarOpen = false;
}
});
}
},
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
const saveBtn = document.querySelector('[data-save-btn]');
if (saveBtn && confirm('Save changes?')) {
saveBtn.click();
}
}
if (e.key === 'Escape') {
this.closeAllModals();
}
});
},
closeAllModals() {
document.querySelectorAll('[x-data]').forEach(el => {
if (el._x_dataStack) {
const data = el._x_dataStack[0];
if (typeof data.open !== 'undefined') data.open = false;
if (typeof data.showModal !== 'undefined') data.showModal = false;
}
});
},
showToast(message, type = 'success') {
Toastify({
text: message,
duration: 3000,
gravity: 'top',
position: 'right',
className: type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500',
stopOnFocus: true
}).showToast();
},
confirmAction(message = 'Are you sure?') {
return confirm(message);
},
confirmSave(message = 'Save changes?') {
return confirm(message);
}
};
document.addEventListener('DOMContentLoaded', () => {
App.init();
});

View File

@ -1,128 +0,0 @@
window.ChartManager = {
charts: {},
init(containerId, chartData, options = {}) {
const container = document.getElementById(containerId);
if (!container) return;
const ctx = container.getContext('2d');
if (this.charts[containerId]) {
this.charts[containerId].destroy();
}
const defaultOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
}
}
};
this.charts[containerId] = new Chart(ctx, {
type: options.type || 'line',
data: chartData,
options: { ...defaultOptions, ...options }
});
return this.charts[containerId];
},
createTrendChart(containerId, labels, datasets) {
return this.init(containerId, {
labels,
datasets: datasets.map((ds, i) => ({
label: ds.label || `Control ${i + 1}`,
data: ds.data,
borderColor: ds.color || this.getColor(i),
backgroundColor: this.getColor(i, 0.1),
tension: 0.3,
fill: true,
pointRadius: 4,
pointHoverRadius: 6
}))
}, {
type: 'line',
plugins: {
title: {
display: true,
text: 'Monthly QC Trend'
}
}
});
},
createComparisonChart(containerId, labels, datasets) {
return this.init(containerId, {
labels,
datasets: datasets.map((ds, i) => ({
label: ds.label || `Control ${i + 1}`,
data: ds.data,
backgroundColor: this.getColor(i, 0.6),
borderColor: this.getColor(i),
borderWidth: 1
}))
}, {
type: 'bar',
plugins: {
title: {
display: true,
text: 'Control Comparison'
}
}
});
},
createViolationChart(containerId, labels, datasets) {
return this.init(containerId, {
labels,
datasets: datasets.map((ds, i) => ({
label: ds.label || `Control ${i + 1}`,
data: ds.data,
backgroundColor: ds.data.map(v => v > 100 ? 'rgba(239, 68, 68, 0.6)' : 'rgba(34, 197, 94, 0.6)'),
borderColor: ds.data.map(v => v > 100 ? 'rgb(239, 68, 68)' : 'rgb(34, 197, 94)'),
borderWidth: 1
}))
}, {
type: 'bar',
plugins: {
title: {
display: true,
text: 'QC Violations (Values > 100)'
}
}
});
},
getColor(index, alpha = 1) {
const colors = [
`rgba(59, 130, 246, ${alpha})`,
`rgba(16, 185, 129, ${alpha})`,
`rgba(245, 158, 11, ${alpha})`,
`rgba(239, 68, 68, ${alpha})`,
`rgba(139, 92, 246, ${alpha})`,
`rgba(236, 72, 153, ${alpha})`
];
return colors[index % colors.length];
},
destroy(containerId) {
if (this.charts[containerId]) {
this.charts[containerId].destroy();
delete this.charts[containerId];
}
},
destroyAll() {
Object.keys(this.charts).forEach(id => this.destroy(id));
}
};
document.addEventListener('DOMContentLoaded', () => {
const chartsContainer = document.getElementById('charts-container');
if (chartsContainer && typeof initReportCharts === 'function') {
initReportCharts();
}
});

View File

@ -1,186 +0,0 @@
window.TableEnhancer = {
init(selector, options = {}) {
const defaults = {
perPage: 10,
searchable: true,
sortable: true,
persistKey: null
};
const config = { ...defaults, ...options };
document.querySelectorAll(selector).forEach(table => this.enhance(table, config));
},
enhance(table, config) {
const container = table.closest('.table-container') || table.parentElement;
if (!container.querySelector('.table-search')) {
this.addControls(container, table, config);
}
this.setupSearch(container, table, config);
this.setupSort(container, table, config);
this.setupPagination(container, table, config);
},
addControls(container, table, config) {
const controls = document.createElement('div');
controls.className = 'table-controls flex justify-between items-center mb-4 p-4 bg-gray-50 rounded-t-lg';
controls.innerHTML = `
${config.searchable ? `
<div class="relative">
<input type="text" class="table-search pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 w-64" placeholder="Search...">
<svg class="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
` : ''}
<div class="table-info text-sm text-gray-500"></div>
`;
container.insertBefore(controls, table);
const pagination = document.createElement('div');
pagination.className = 'table-pagination flex justify-center items-center space-x-2 mt-4';
container.appendChild(pagination);
},
setupSearch(container, table, config) {
const searchInput = container.querySelector('.table-search');
if (!searchInput) return;
const key = config.persistKey ? `table_search_${config.persistKey}` : null;
if (key && localStorage.getItem(key)) {
searchInput.value = localStorage.getItem(key);
}
searchInput.addEventListener('input', (e) => {
if (key) localStorage.setItem(key, e.target.value);
this.filterTable(table, e.target.value);
this.updatePagination(container, table, 1, config);
});
},
setupSort(container, table, config) {
if (!config.sortable) return;
const headers = table.querySelectorAll('th');
headers.forEach((th, index) => {
if (th.textContent.trim()) {
th.classList.add('cursor-pointer', 'hover:bg-gray-100');
th.dataset.sortCol = index;
th.innerHTML += `
<svg class="inline h-4 w-4 ml-1 text-gray-400 sort-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
</svg>
`;
}
});
container.querySelectorAll('th[data-sort-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.sortCol;
const currentSort = th.dataset.sort || 'none';
const newSort = currentSort === 'asc' ? 'desc' : 'asc';
this.sortTable(table, col, newSort);
th.dataset.sort = newSort;
th.querySelector('.sort-icon').innerHTML = newSort === 'asc'
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>'
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>';
});
});
},
setupPagination(container, table, config) {
this.updatePagination(container, table, 1, config);
},
filterTable(table, query) {
const rows = table.querySelectorAll('tbody tr');
const lowerQuery = query.toLowerCase();
let visibleCount = 0;
rows.forEach(row => {
const text = row.textContent.toLowerCase();
const match = text.includes(lowerQuery);
row.style.display = match ? '' : 'none';
if (match) visibleCount++;
});
const infoEl = container.querySelector('.table-info');
if (infoEl) {
infoEl.textContent = `${visibleCount} row${visibleCount !== 1 ? 's' : ''} found`;
}
},
sortTable(table, col, direction) {
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const aVal = a.querySelectorAll('td')[col]?.textContent.trim() || '';
const bVal = b.querySelectorAll('td')[col]?.textContent.trim() || '';
const aNum = parseFloat(aVal);
const bNum = parseFloat(bVal);
if (!isNaN(aNum) && !isNaN(bNum)) {
return direction === 'asc' ? aNum - bNum : bNum - aNum;
}
return direction === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
});
rows.forEach(row => tbody.appendChild(row));
},
updatePagination(container, table, page, config) {
const rows = Array.from(table.querySelectorAll('tbody tr:not([style*=\"display: none\"])'));
const totalPages = Math.ceil(rows.length / config.perPage);
const pagination = container.querySelector('.table-pagination');
if (!pagination) return;
const start = (page - 1) * config.perPage;
const end = start + config.perPage;
rows.forEach((row, index) => {
row.style.display = index >= start && index < end ? '' : 'none';
});
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let html = `
<button class="px-3 py-1 border rounded hover:bg-gray-100" ${page === 1 ? 'disabled' : ''} onclick="TableEnhancer.goToPage('${container.querySelector('table').id || ''}', ${page - 1})">Prev</button>
`;
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) {
html += `<button class="px-3 py-1 border rounded ${i === page ? 'bg-primary-600 text-white' : 'hover:bg-gray-100'}" onclick="TableEnhancer.goToPage('${container.querySelector('table').id || ''}', ${i})">${i}</button>`;
} else if (i === page - 2 || i === page + 2) {
html += `<span class="px-2">...</span>`;
}
}
html += `
<button class="px-3 py-1 border rounded hover:bg-gray-100" ${page === totalPages ? 'disabled' : ''} onclick="TableEnhancer.goToPage('${container.querySelector('table').id || ''}', ${page + 1})">Next</button>
`;
pagination.innerHTML = html;
},
goToPage(tableId, page) {
const table = tableId ? document.getElementById(tableId) : document.querySelector('table');
if (!table) return;
const container = table.closest('.table-container') || table.parentElement;
const config = { perPage: 10 };
this.updatePagination(container, table, page, config);
},
goToPageBySelector(selector, page) {
const container = document.querySelector(selector);
if (!container) return;
const table = container.querySelector('table');
if (!table) return;
const config = { perPage: 10 };
this.updatePagination(container, table, page, config);
}
};

450
public/template.html Normal file
View File

@ -0,0 +1,450 @@
<!DOCTYPE html>
<html lang="en" data-theme="autumn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TinyQC - QC Management System</title>
<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" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></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">
</head>
<body class="bg-base-200 text-base-content" x-data="appData()">
<div class="drawer lg:drawer-open">
<input id="sidebar-drawer" type="checkbox" class="drawer-toggle" x-ref="sidebarDrawer">
<div class="drawer-content flex flex-col min-h-screen">
<nav class="navbar bg-base-200/80 backdrop-blur-md border-b border-base-300 sticky top-0 z-30 w-full shadow-sm">
<div class="flex-none lg:hidden">
<label for="sidebar-drawer" class="btn btn-square btn-ghost text-primary">
<i class="fa-solid fa-bars text-xl"></i>
</label>
</div>
<div class="flex-1 px-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary flex items-center justify-center shadow-lg shadow-primary/20">
<i class="fa-solid fa-flask text-white text-lg"></i>
</div>
<div>
<h1 class="text-xl font-bold text-base-content tracking-tight">tinyqc</h1>
<p class="text-xs opacity-70">QC Management System</p>
</div>
</div>
</div>
<div class="flex-none">
<button class="btn btn-ghost rounded-full mr-2" @click="toggleTheme()">
<i class="fa-solid fa-sun text-warning" x-show="currentTheme === themeConfig.light"></i>
<i class="fa-solid fa-moon text-neutral-content" x-show="currentTheme === themeConfig.dark"></i>
</button>
<div class="dropdown dropdown-end" x-data="{ dropdownOpen: false }">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar placeholder" @click="dropdownOpen = !dropdownOpen">
<div class="bg-primary text-primary-content rounded-full w-10 h-10 flex items-center justify-center">
<span>DR</span>
</div>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow-xl bg-base-100 rounded-box w-52 border border-base-300" x-show="dropdownOpen" @click.outside="dropdownOpen = false" x-transition>
<li class="menu-title px-4 py-2">
<span class="text-base-content font-bold">Dr. Sarah Mitchell</span>
<span class="text-xs text-primary font-medium">Lab Director</span>
</li>
<div class="divider my-0 h-px opacity-10"></div>
<li><a class="hover:bg-base-200"><i class="fa-solid fa-user text-primary"></i> Profile</a></li>
<li><a class="hover:bg-base-200"><i class="fa-solid fa-gear text-primary"></i> Settings</a></li>
<li><a class="hover:bg-base-200"><i class="fa-solid fa-question-circle text-primary"></i> Help</a></li>
<div class="divider my-0 h-px opacity-10"></div>
<li><a class="text-error hover:bg-error/10"><i class="fa-solid fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</nav>
<main class="flex-1 p-6 overflow-auto">
<div class="mx-auto">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6 gap-4">
<div>
<h2 class="text-2xl font-bold tracking-tight text-base-content">Sample Management</h2>
<p class="text-sm mt-1 opacity-70">Manage laboratory samples, track status, and generate reports</p>
</div>
<button class="btn btn-sm btn-primary gap-2 shadow-lg shadow-primary/20" @click="openModal()">
<i class="fa-solid fa-plus"></i> Add Data
</button>
</div>
<div class="rounded-xl border border-base-300 shadow-xl overflow-hidden bg-base-100">
<div class="p-4 border-b border-base-300 flex flex-col md:flex-row md:items-center gap-4">
<div class="relative flex-1 max-w-md">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-sm"></i>
<input type="text" placeholder="Search by sample ID, patient name, or test type..." class="w-full pl-10 pr-4 py-2.5 text-sm rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all bg-base-200 border border-base-300 text-base-content placeholder:opacity-50">
</div>
<div class="flex gap-2">
<select class="select select-bordered select-sm focus:outline-none focus:ring-2 focus:ring-primary/50 bg-base-200 border-base-300 text-base-content">
<option selected>All Status</option>
<option>Pending</option>
<option>Processing</option>
<option>Completed</option>
<option>Cancelled</option>
</select>
<button class="btn btn-sm btn-ghost opacity-70 hover:opacity-100">
<i class="fa-solid fa-filter"></i> Filter
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="uppercase tracking-wider font-semibold bg-base-200 text-base-content/70 text-xs">
<tr>
<th class="py-4 px-5">Sample ID</th>
<th class="py-4 px-5">Patient Name</th>
<th class="py-4 px-5">Test Type</th>
<th class="py-4 px-5">Collection Date</th>
<th class="py-4 px-5">Status</th>
<th class="py-4 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-base-300">
<tr class="hover:bg-base-200 transition-colors">
<td class="py-4 px-5">
<span class="font-mono text-sm text-primary bg-primary/10 px-2 py-1 rounded">SPL-2024-001</span>
</td>
<td class="py-4 px-5">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="rounded-full w-8 h-8 bg-neutral text-neutral-content flex items-center justify-center">
<span class="text-xs" x-text="'JD'"></span>
</div>
</div>
<span class="font-medium text-base-content">John Doe</span>
</div>
</td>
<td class="py-4 px-5 opacity-80">Complete Blood Count</td>
<td class="py-4 px-5 opacity-60">Jan 15, 2024</td>
<td class="py-4 px-5">
<div class="badge badge-warning gap-1">
<i class="fa-solid fa-clock text-xs"></i> Pending
</div>
</td>
<td class="py-4 px-5 text-right">
<div class="flex justify-end gap-2">
<button class="btn btn-xs btn-ghost text-primary hover:bg-primary/10" @click="openModal()">
<i class="fa-solid fa-pencil"></i> Edit
</button>
<button class="btn btn-xs btn-ghost text-error hover:bg-error/10">
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</td>
</tr>
<tr class="hover:bg-base-200 transition-colors">
<td class="py-4 px-5">
<span class="font-mono text-sm text-primary bg-primary/10 px-2 py-1 rounded">SPL-2024-002</span>
</td>
<td class="py-4 px-5">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="rounded-full w-8 h-8 bg-neutral text-neutral-content flex items-center justify-center">
<span class="text-xs">AS</span>
</div>
</div>
<span class="font-medium text-base-content">Alice Smith</span>
</div>
</td>
<td class="py-4 px-5 opacity-80">Lipid Panel</td>
<td class="py-4 px-5 opacity-60">Jan 15, 2024</td>
<td class="py-4 px-5">
<div class="badge badge-info gap-1">
<i class="fa-solid fa-flask text-xs"></i> Processing
</div>
</td>
<td class="py-4 px-5 text-right">
<div class="flex justify-end gap-2">
<button class="btn btn-xs btn-ghost text-primary hover:bg-primary/10" @click="openModal()">
<i class="fa-solid fa-pencil"></i> Edit
</button>
<button class="btn btn-xs btn-ghost text-error hover:bg-error/10">
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</td>
</tr>
<tr class="hover:bg-base-200 transition-colors">
<td class="py-4 px-5">
<span class="font-mono text-sm text-primary bg-primary/10 px-2 py-1 rounded">SPL-2024-003</span>
</td>
<td class="py-4 px-5">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="rounded-full w-8 h-8 bg-neutral text-neutral-content flex items-center justify-center">
<span class="text-xs">BJ</span>
</div>
</div>
<span class="font-medium text-base-content">Bob Johnson</span>
</div>
</td>
<td class="py-4 px-5 opacity-80">Liver Function Test</td>
<td class="py-4 px-5 opacity-60">Jan 14, 2024</td>
<td class="py-4 px-5">
<div class="badge badge-success gap-1">
<i class="fa-solid fa-check text-xs"></i> Completed
</div>
</td>
<td class="py-4 px-5 text-right">
<div class="flex justify-end gap-2">
<button class="btn btn-xs btn-ghost text-primary hover:bg-primary/10" @click="openModal()">
<i class="fa-solid fa-pencil"></i> Edit
</button>
<button class="btn btn-xs btn-ghost text-error hover:bg-error/10">
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</td>
</tr>
<tr class="hover:bg-base-200 transition-colors">
<td class="py-4 px-5">
<span class="font-mono text-sm text-primary bg-primary/10 px-2 py-1 rounded">SPL-2024-004</span>
</td>
<td class="py-4 px-5">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="rounded-full w-8 h-8 bg-neutral text-neutral-content flex items-center justify-center">
<span class="text-xs">EW</span>
</div>
</div>
<span class="font-medium text-base-content">Emily Watson</span>
</div>
</td>
<td class="py-4 px-5 opacity-80">Thyroid Panel</td>
<td class="py-4 px-5 opacity-60">Jan 14, 2024</td>
<td class="py-4 px-5">
<div class="badge badge-success gap-1">
<i class="fa-solid fa-check text-xs"></i> Completed
</div>
</td>
<td class="py-4 px-5 text-right">
<div class="flex justify-end gap-2">
<button class="btn btn-xs btn-ghost text-primary hover:bg-primary/10" @click="openModal()">
<i class="fa-solid fa-pencil"></i> Edit
</button>
<button class="btn btn-xs btn-ghost text-error hover:bg-error/10">
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</td>
</tr>
<tr class="hover:bg-base-200 transition-colors">
<td class="py-4 px-5">
<span class="font-mono text-sm text-primary bg-primary/10 px-2 py-1 rounded">SPL-2024-005</span>
</td>
<td class="py-4 px-5">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="rounded-full w-8 h-8 bg-neutral text-neutral-content flex items-center justify-center">
<span class="text-xs">MC</span>
</div>
</div>
<span class="font-medium text-base-content">Michael Chen</span>
</div>
</td>
<td class="py-4 px-5 opacity-80">Urinalysis</td>
<td class="py-4 px-5 opacity-60">Jan 13, 2024</td>
<td class="py-4 px-5">
<div class="badge badge-error gap-1">
<i class="fa-solid fa-times text-xs"></i> Cancelled
</div>
</td>
<td class="py-4 px-5 text-right">
<div class="flex justify-end gap-2">
<button class="btn btn-xs btn-ghost text-primary hover:bg-primary/10" @click="openModal()">
<i class="fa-solid fa-pencil"></i> Edit
</button>
<button class="btn btn-xs btn-ghost text-error hover:bg-error/10">
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="p-4 border-t border-base-300 flex items-center justify-between">
<span class="text-sm opacity-60">Showing 1 to 5 of 127 entries</span>
<div class="join">
<button class="join-item btn btn-sm bg-base-200 border-base-300">Previous</button>
<button class="join-item btn btn-sm btn-primary">1</button>
<button class="join-item btn btn-sm bg-base-200 border-base-300">2</button>
<button class="join-item btn btn-sm bg-base-200 border-base-300">3</button>
<button class="join-item btn btn-sm bg-base-200 border-base-300">Next</button>
</div>
</div>
</div>
</div>
</main>
<footer class="text-center text-sm opacity-50 py-4 mt-auto">
&copy; 2026 - 5Panda
</footer>
</div>
<div class="drawer-side z-40">
<label for="sidebar-drawer" class="drawer-overlay"></label>
<aside class="bg-base-300 border-r border-base-300 w-64 min-h-full flex flex-col">
<ul class="menu p-4 text-base-content flex-1 w-full">
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg bg-primary/10 text-primary font-medium h-full">
<i class="fa-solid fa-chart-line w-5"></i>
Dashboard
</a>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg opacity-70 hover:bg-base-200 hover:opacity-100 transition-colors h-full">
<i class="fa-solid fa-users w-5 text-primary"></i>
Patients
</a>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg opacity-70 hover:bg-base-200 hover:opacity-100 transition-colors h-full">
<i class="fa-solid fa-vial w-5 text-primary"></i>
Samples
</a>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg opacity-70 hover:bg-base-200 hover:opacity-100 transition-colors h-full">
<i class="fa-solid fa-microscope w-5 text-primary"></i>
Results
</a>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg opacity-70 hover:bg-base-200 hover:opacity-100 transition-colors h-full">
<i class="fa-solid fa-file-medical-alt w-5 text-primary"></i>
Reports
</a>
</li>
<li class="mt-6 mb-2 min-h-0">
<p class="px-4 text-xs font-semibold opacity-40 uppercase tracking-wider">System</p>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg opacity-70 hover:bg-base-200 hover:opacity-100 transition-colors h-full">
<i class="fa-solid fa-cog w-5 text-primary"></i>
Settings
</a>
</li>
<li class="mb-1 min-h-0">
<a class="flex items-center gap-3 px-4 py-3 rounded-lg opacity-70 hover:bg-base-200 hover:opacity-100 transition-colors h-full">
<i class="fa-solid fa-users-cog w-5 text-primary"></i>
Users
</a>
</li>
</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>
</div>
</div>
<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">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2 text-base-content">
<i class="fa-solid fa-vial text-primary"></i>
Add New Sample
</h3>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Patient Name</span>
</label>
<input type="text" placeholder="Enter patient name" 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">
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Test Type</span>
</label>
<select class="select select-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 bg-base-200 border-base-300 text-base-content">
<option selected disabled>Select test type</option>
<option>Complete Blood Count (CBC)</option>
<option>Lipid Panel</option>
<option>Liver Function Test</option>
<option>Kidney Function Test</option>
<option>Thyroid Panel</option>
<option>Urinalysis</option>
<option>Blood Glucose</option>
<option>Electrolytes Panel</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Collection Date</span>
</label>
<input type="date" class="input input-bordered w-full focus:outline-none focus:ring-2 focus:ring-primary/50 bg-base-200 border-base-300 text-base-content">
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium text-base-content opacity-80">Notes</span>
</label>
<textarea class="textarea textarea-bordered w-full h-24 focus:outline-none focus:ring-2 focus:ring-primary/50 placeholder:opacity-50 resize-none bg-base-200 border-base-300 text-base-content" placeholder="Additional notes or special instructions..."></textarea>
</div>
</div>
<div class="modal-action mt-6">
<button class="btn btn-ghost opacity-70" @click="closeModal()">Cancel</button>
<button class="btn btn-primary gap-2 shadow-lg shadow-primary/20 font-medium">
<i class="fa-solid fa-save"></i> Submit
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop bg-black/60" @click="closeModal()"></form>
</dialog>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('appData', () => ({
showModal: false,
themeConfig: {
light: 'autumn',
dark: 'dracula'
},
get currentTheme() {
return localStorage.getItem('theme') || this.themeConfig.light;
},
set currentTheme(value) {
localStorage.setItem('theme', value);
document.documentElement.setAttribute('data-theme', value);
},
get isDark() {
return this.currentTheme === this.themeConfig.dark;
},
init() {
document.documentElement.setAttribute('data-theme', this.currentTheme);
},
toggleSidebar() {
this.$refs.sidebarDrawer.checked = !this.$refs.sidebarDrawer.checked;
},
toggleTheme() {
this.currentTheme = this.isDark ? this.themeConfig.light : this.themeConfig.dark;
},
openModal() {
this.showModal = true;
},
closeModal() {
this.showModal = false;
}
}));
});
</script>
</body>
</html>