feat: Implement Monthly Entry interface and consolidate Entry API controller
- New EntryApiController (app/Controllers/Api/EntryApiController.php)
- Centralized API for entry operations (daily/monthly data retrieval and saving)
- getControls() - Fetch controls with optional date-based expiry filtering
- getTests() - Get tests associated with a control
- getDailyData() - Retrieve daily results for a date/control
- getMonthlyData() - Retrieve monthly results with per-day data and comments
- saveDaily() - Batch save daily results with validation
- saveMonthly() - Batch save monthly results with statistics
- New Monthly Entry View (app/Views/entry/monthly.php)
- Calendar grid interface for entering monthly QC results
- Month selector with quick navigation (prev/next/current)
- Test selector to filter controls
- 31-day grid per control with inline editing
- Visual QC range indicators (green for in-range, red for out-of-range)
- Weekend highlighting
- Per-control monthly comment field
- Keyboard shortcut (Ctrl+S) for saving
- Change tracking with pending save indicator
- Route Updates (app/Config/Routes.php)
- Added /entry/monthly page route
- Added /api/entry/daily GET endpoint
- Model Updates
- ResultsModel: Added updateMonthly() for upserting monthly results
- ResultCommentsModel: Added upsertMonthly() for monthly comments
This commit is contained in:
parent
14baa6b758
commit
dd7a058511
@ -15,6 +15,7 @@ $routes->get('/test', 'PageController::test');
|
||||
$routes->get('/control', 'PageController::control');
|
||||
$routes->get('/entry', 'PageController::entry');
|
||||
$routes->get('/entry/daily', 'PageController::entryDaily');
|
||||
$routes->get('/entry/monthly', 'PageController::entryMonthly');
|
||||
$routes->get('/report', 'PageController::report');
|
||||
$routes->get('/report/view', 'PageController::reportView');
|
||||
|
||||
@ -41,6 +42,7 @@ $routes->group('api', function ($routes) {
|
||||
|
||||
$routes->get('entry/controls', 'Api\EntryApiController::getControls');
|
||||
$routes->get('entry/tests', 'Api\EntryApiController::getTests');
|
||||
$routes->get('entry/daily', 'Api\EntryApiController::getDailyData');
|
||||
$routes->get('entry/monthly', 'Api\EntryApiController::getMonthlyData');
|
||||
$routes->post('entry/daily', 'Api\EntryApiController::saveDaily');
|
||||
$routes->post('entry/monthly', 'Api\EntryApiController::saveMonthly');
|
||||
|
||||
425
app/Controllers/Api/EntryApiController.php
Normal file
425
app/Controllers/Api/EntryApiController.php
Normal file
@ -0,0 +1,425 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\API\ResponseTrait;
|
||||
use App\Models\Master\MasterControlsModel;
|
||||
use App\Models\Master\MasterTestsModel;
|
||||
use App\Models\Qc\ResultsModel;
|
||||
use App\Models\Qc\ControlTestsModel;
|
||||
use App\Models\Qc\ResultCommentsModel;
|
||||
|
||||
class EntryApiController extends BaseController
|
||||
{
|
||||
use ResponseTrait;
|
||||
|
||||
protected $controlModel;
|
||||
protected $testModel;
|
||||
protected $resultModel;
|
||||
protected $controlTestModel;
|
||||
protected $commentModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->controlModel = new MasterControlsModel();
|
||||
$this->testModel = new MasterTestsModel();
|
||||
$this->resultModel = new ResultsModel();
|
||||
$this->controlTestModel = new ControlTestsModel();
|
||||
$this->commentModel = new ResultCommentsModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/entry/controls
|
||||
* Get controls by dept (optional dept param)
|
||||
* Optionally filter by date: only non-expired controls
|
||||
*/
|
||||
public function getControls()
|
||||
{
|
||||
try {
|
||||
$deptId = $this->request->getGet('dept_id');
|
||||
$date = $this->request->getGet('date');
|
||||
|
||||
if ($deptId) {
|
||||
$controls = $this->controlModel->where('dept_id', $deptId)->where('deleted_at', null)->findAll();
|
||||
} else {
|
||||
$controls = $this->controlModel->where('deleted_at', null)->findAll();
|
||||
}
|
||||
|
||||
// Filter expired controls if date provided
|
||||
if ($date) {
|
||||
$controls = array_filter($controls, function ($c) use ($date) {
|
||||
return $c['expDate'] === null || $c['expDate'] >= $date;
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to camelCase (BaseModel already returns camelCase)
|
||||
$data = array_map(function ($c) {
|
||||
return [
|
||||
'id' => $c['controlId'],
|
||||
'controlId' => $c['controlId'],
|
||||
'controlName' => $c['controlName'],
|
||||
'lot' => $c['lot'],
|
||||
'producer' => $c['producer'],
|
||||
'expDate' => $c['expDate']
|
||||
];
|
||||
}, $controls);
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'fetch success',
|
||||
'data' => $data
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/entry/tests
|
||||
* Get tests for a control (by control_id)
|
||||
*/
|
||||
public function getTests()
|
||||
{
|
||||
try {
|
||||
$controlId = $this->request->getGet('control_id');
|
||||
|
||||
if (!$controlId) {
|
||||
return $this->failValidationErrors(['control_id' => 'Required']);
|
||||
}
|
||||
|
||||
$tests = $this->controlTestModel->getByControl((int) $controlId);
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'fetch success',
|
||||
'data' => $tests
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/entry/daily
|
||||
* Get existing results for date+control
|
||||
*/
|
||||
public function getDailyData()
|
||||
{
|
||||
try {
|
||||
$date = $this->request->getGet('date');
|
||||
$controlId = $this->request->getGet('control_id');
|
||||
|
||||
if (!$date || !$controlId) {
|
||||
return $this->failValidationErrors(['date' => 'Required', 'control_id' => 'Required']);
|
||||
}
|
||||
|
||||
// Get tests for this control
|
||||
$tests = $this->controlTestModel->getByControl((int) $controlId);
|
||||
|
||||
// Get existing results for this date
|
||||
$existingResults = $this->resultModel->getByDateAndControl($date, (int) $controlId);
|
||||
|
||||
// Map existing results by test_id
|
||||
$resultsByTest = [];
|
||||
foreach ($existingResults as $r) {
|
||||
$resultsByTest[$r['testId']] = [
|
||||
'resultId' => $r['id'],
|
||||
'resValue' => $r['resValue'],
|
||||
'resComment' => $r['resComment']
|
||||
];
|
||||
}
|
||||
|
||||
// Merge tests with existing values
|
||||
$data = [];
|
||||
foreach ($tests as $t) {
|
||||
$existing = $resultsByTest[$t['testId']] ?? null;
|
||||
$data[] = [
|
||||
'controlTestId' => $t['id'],
|
||||
'controlId' => $t['controlId'],
|
||||
'testId' => $t['testId'],
|
||||
'testName' => $t['testName'],
|
||||
'testUnit' => $t['testUnit'],
|
||||
'mean' => $t['mean'],
|
||||
'sd' => $t['sd'],
|
||||
'existingResult' => $existing
|
||||
];
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'fetch success',
|
||||
'data' => $data
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/entry/daily
|
||||
* Save/update daily results (batch)
|
||||
*/
|
||||
public function saveDaily()
|
||||
{
|
||||
try {
|
||||
$input = $this->request->getJSON(true);
|
||||
|
||||
if (!isset($input['date']) || !isset($input['results']) || !is_array($input['results'])) {
|
||||
return $this->failValidationErrors(['Invalid input']);
|
||||
}
|
||||
|
||||
$date = $input['date'];
|
||||
$results = $input['results'];
|
||||
$savedIds = [];
|
||||
|
||||
// Start transaction
|
||||
$this->resultModel->db->transBegin();
|
||||
|
||||
foreach ($results as $r) {
|
||||
if (!isset($r['controlId']) || !isset($r['testId']) || !isset($r['value'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'control_id' => $r['controlId'],
|
||||
'test_id' => $r['testId'],
|
||||
'res_date' => $date,
|
||||
'res_value' => $r['value'] !== '' ? (float) $r['value'] : null,
|
||||
'res_comment' => $r['comment'] ?? null
|
||||
];
|
||||
|
||||
if ($data['res_value'] === null) {
|
||||
continue; // Skip empty values
|
||||
}
|
||||
|
||||
$savedIds[] = $this->resultModel->upsertResult($data);
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
$this->resultModel->db->transCommit();
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'Saved ' . count($savedIds) . ' results',
|
||||
'data' => ['savedIds' => $savedIds]
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
// Rollback transaction on error
|
||||
$this->resultModel->db->transRollback();
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/entry/monthly
|
||||
* Get monthly data by test
|
||||
*/
|
||||
public function getMonthlyData()
|
||||
{
|
||||
try {
|
||||
$testId = $this->request->getGet('test_id');
|
||||
$month = $this->request->getGet('month'); // YYYY-MM
|
||||
|
||||
if (!$testId || !$month) {
|
||||
return $this->failValidationErrors(['test_id' => 'Required', 'month' => 'Required']);
|
||||
}
|
||||
|
||||
// Get test details
|
||||
$test = $this->testModel->find($testId);
|
||||
if (!$test) {
|
||||
return $this->failNotFound('Test not found');
|
||||
}
|
||||
|
||||
// Get controls for this test with QC parameters
|
||||
$controls = $this->controlTestModel->getByTest((int) $testId);
|
||||
|
||||
// Get existing results for this month
|
||||
$results = $this->resultModel->getByMonth((int) $testId, $month);
|
||||
|
||||
// Get comments for this month
|
||||
$comments = $this->commentModel->getByTestMonth((int) $testId, $month);
|
||||
|
||||
// Map results by control_id and day
|
||||
$resultsByControl = [];
|
||||
foreach ($results as $r) {
|
||||
$day = (int) date('j', strtotime($r['resDate']));
|
||||
$resultsByControl[$r['controlId']][$day] = [
|
||||
'resultId' => $r['id'],
|
||||
'resValue' => $r['resValue'],
|
||||
'resComment' => $r['resComment']
|
||||
];
|
||||
}
|
||||
|
||||
// Map comments by control_id (BaseModel returns camelCase)
|
||||
$commentsByControl = [];
|
||||
foreach ($comments as $c) {
|
||||
$commentsByControl[$c['controlId']] = [
|
||||
'commentId' => $c['resultCommentId'],
|
||||
'comText' => $c['comText']
|
||||
];
|
||||
}
|
||||
|
||||
// Build controls with results array[31]
|
||||
$controlsWithData = [];
|
||||
foreach ($controls as $c) {
|
||||
$resultsByDay = $resultsByControl[$c['controlId']] ?? [];
|
||||
$resultsArray = array_fill(1, 31, null);
|
||||
|
||||
foreach ($resultsByDay as $day => $val) {
|
||||
$resultsArray[$day] = $val;
|
||||
}
|
||||
|
||||
$comment = $commentsByControl[$c['controlId']] ?? null;
|
||||
|
||||
$controlsWithData[] = [
|
||||
'controlTestId' => $c['id'],
|
||||
'controlId' => $c['controlId'],
|
||||
'controlName' => $c['controlName'],
|
||||
'lot' => $c['lot'],
|
||||
'producer' => $c['producer'],
|
||||
'mean' => $c['mean'],
|
||||
'sd' => $c['sd'],
|
||||
'results' => $resultsArray,
|
||||
'comment' => $comment
|
||||
];
|
||||
}
|
||||
|
||||
$data = [
|
||||
'test' => [
|
||||
'testId' => $test['testId'],
|
||||
'testName' => $test['testName'],
|
||||
'testUnit' => $test['testUnit']
|
||||
],
|
||||
'month' => $month,
|
||||
'controls' => $controlsWithData
|
||||
];
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'fetch success',
|
||||
'data' => $data
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/entry/monthly
|
||||
* Save monthly batch (results + comments)
|
||||
*/
|
||||
public function saveMonthly()
|
||||
{
|
||||
try {
|
||||
$input = $this->request->getJSON(true);
|
||||
|
||||
if (!isset($input['testId']) || !isset($input['month']) || !isset($input['controls'])) {
|
||||
return $this->failValidationErrors(['Invalid input']);
|
||||
}
|
||||
|
||||
$testId = $input['testId'];
|
||||
$month = $input['month'];
|
||||
$controls = $input['controls'];
|
||||
|
||||
// Validate month has valid days
|
||||
$daysInMonth = (int) date('t', strtotime($month . '-01'));
|
||||
|
||||
$savedCount = 0;
|
||||
$commentCount = 0;
|
||||
|
||||
// Start transaction
|
||||
$this->resultModel->db->transBegin();
|
||||
|
||||
foreach ($controls as $c) {
|
||||
$controlId = $c['controlId'];
|
||||
$results = $c['results'] ?? [];
|
||||
$commentText = $c['comment'] ?? null;
|
||||
|
||||
// Save results
|
||||
foreach ($results as $day => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate day exists in month
|
||||
if ($day < 1 || $day > $daysInMonth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $month . '-' . str_pad($day, 2, '0', STR_PAD_LEFT);
|
||||
$data = [
|
||||
'control_id' => $controlId,
|
||||
'test_id' => $testId,
|
||||
'res_date' => $date,
|
||||
'res_value' => (float) $value
|
||||
];
|
||||
|
||||
$this->resultModel->upsertResult($data);
|
||||
$savedCount++;
|
||||
}
|
||||
|
||||
// Save comment
|
||||
if ($commentText !== null) {
|
||||
$commentData = [
|
||||
'control_id' => $controlId,
|
||||
'test_id' => $testId,
|
||||
'comment_month' => $month,
|
||||
'com_text' => trim($commentText)
|
||||
];
|
||||
|
||||
$this->commentModel->upsertComment($commentData);
|
||||
$commentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
$this->resultModel->db->transCommit();
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => "Saved {$savedCount} results and {$commentCount} comments",
|
||||
'data' => ['savedCount' => $savedCount, 'commentCount' => $commentCount]
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
// Rollback transaction on error
|
||||
$this->resultModel->db->transRollback();
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/entry/comment
|
||||
* Save monthly comment (single)
|
||||
*/
|
||||
public function saveComment()
|
||||
{
|
||||
try {
|
||||
$input = $this->request->getJSON(true);
|
||||
|
||||
$required = ['controlId', 'testId', 'month', 'comment'];
|
||||
foreach ($required as $field) {
|
||||
if (!isset($input[$field])) {
|
||||
return $this->failValidationErrors([$field => 'Required']);
|
||||
}
|
||||
}
|
||||
|
||||
$commentData = [
|
||||
'control_id' => $input['controlId'],
|
||||
'test_id' => $input['testId'],
|
||||
'comment_month' => $input['month'],
|
||||
'com_text' => trim($input['comment'])
|
||||
];
|
||||
|
||||
$id = $this->commentModel->upsertComment($commentData);
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'Comment saved',
|
||||
'data' => ['id' => $id]
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,10 @@ class PageController extends BaseController {
|
||||
return view('entry/daily');
|
||||
}
|
||||
|
||||
public function entryMonthly() {
|
||||
return view('entry/monthly');
|
||||
}
|
||||
|
||||
public function report() {
|
||||
return view('report/index');
|
||||
}
|
||||
|
||||
189
app/Database/Seeds/LongExpiryQcSeeder.php
Normal file
189
app/Database/Seeds/LongExpiryQcSeeder.php
Normal file
@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Seeds;
|
||||
|
||||
use CodeIgniter\Database\Seeder;
|
||||
|
||||
class LongExpiryQcSeeder extends Seeder
|
||||
{
|
||||
public function run()
|
||||
{
|
||||
// 1. Insert Departments (4 entries)
|
||||
$depts = [
|
||||
['dept_name' => 'Chemistry'],
|
||||
['dept_name' => 'Hematology'],
|
||||
['dept_name' => 'Immunology'],
|
||||
['dept_name' => 'Urinalysis'],
|
||||
];
|
||||
$this->db->table('master_depts')->insertBatch($depts);
|
||||
$deptIds = $this->db->table('master_depts')->select('dept_id')->get()->getResultArray();
|
||||
$deptIdMap = array_column($deptIds, 'dept_id');
|
||||
|
||||
// 2. Insert Controls with long expiry dates (2027-12-31)
|
||||
$controls = [
|
||||
['dept_id' => $deptIdMap[0], 'control_name' => 'QC Normal Chemistry', 'lot' => 'QC2026001', 'producer' => 'BioRad', 'exp_date' => '2027-12-31'],
|
||||
['dept_id' => $deptIdMap[0], 'control_name' => 'QC High Chemistry', 'lot' => 'QC2026002', 'producer' => 'BioRad', 'exp_date' => '2027-12-31'],
|
||||
['dept_id' => $deptIdMap[1], 'control_name' => 'QC Normal Hema', 'lot' => 'QC2026003', 'producer' => 'Streck', 'exp_date' => '2027-11-30'],
|
||||
['dept_id' => $deptIdMap[1], 'control_name' => 'QC Low Hema', 'lot' => 'QC2026004', 'producer' => 'Streck', 'exp_date' => '2027-11-30'],
|
||||
['dept_id' => $deptIdMap[2], 'control_name' => 'QC Normal Immuno', 'lot' => 'QC2026005', 'producer' => 'Roche', 'exp_date' => '2027-10-31'],
|
||||
['dept_id' => $deptIdMap[3], 'control_name' => 'QC Normal Urine', 'lot' => 'QC2026006', 'producer' => 'Siemens', 'exp_date' => '2027-09-30'],
|
||||
];
|
||||
$this->db->table('master_controls')->insertBatch($controls);
|
||||
$controlIds = $this->db->table('master_controls')->select('control_id')->get()->getResultArray();
|
||||
$controlIdMap = array_column($controlIds, 'control_id');
|
||||
|
||||
// 3. Insert Tests (10 entries)
|
||||
$tests = [
|
||||
['dept_id' => $deptIdMap[0], 'test_name' => 'Glucose', 'test_unit' => 'mg/dL', 'test_method' => 'GOD-PAP', 'cva' => 5, 'ba' => 3, 'tea' => 10],
|
||||
['dept_id' => $deptIdMap[0], 'test_name' => 'Creatinine', 'test_unit' => 'mg/dL', 'test_method' => 'Jaffe', 'cva' => 4, 'ba' => 2, 'tea' => 8],
|
||||
['dept_id' => $deptIdMap[0], 'test_name' => 'Urea Nitrogen', 'test_unit' => 'mg/dL', 'test_method' => 'UREASE', 'cva' => 5, 'ba' => 3, 'tea' => 12],
|
||||
['dept_id' => $deptIdMap[0], 'test_name' => 'Cholesterol', 'test_unit' => 'mg/dL', 'test_method' => 'CHOD-PAP', 'cva' => 6, 'ba' => 4, 'tea' => 15],
|
||||
['dept_id' => $deptIdMap[1], 'test_name' => 'WBC', 'test_unit' => 'x10^3/uL', 'test_method' => 'Impedance', 'cva' => 8, 'ba' => 5, 'tea' => 20],
|
||||
['dept_id' => $deptIdMap[1], 'test_name' => 'RBC', 'test_unit' => 'x10^6/uL', 'test_method' => 'Impedance', 'cva' => 3, 'ba' => 2, 'tea' => 8],
|
||||
['dept_id' => $deptIdMap[1], 'test_name' => 'Hemoglobin', 'test_unit' => 'g/dL', 'test_method' => 'Cyanmethemoglobin', 'cva' => 2, 'ba' => 1, 'tea' => 5],
|
||||
['dept_id' => $deptIdMap[2], 'test_name' => 'TSH', 'test_unit' => 'mIU/L', 'test_method' => 'ECLIA', 'cva' => 10, 'ba' => 6, 'tea' => 25],
|
||||
['dept_id' => $deptIdMap[2], 'test_name' => 'Free T4', 'test_unit' => 'ng/dL', 'test_method' => 'ECLIA', 'cva' => 8, 'ba' => 5, 'tea' => 20],
|
||||
['dept_id' => $deptIdMap[3], 'test_name' => 'Urine Protein', 'test_unit' => 'mg/dL', 'test_method' => 'Dipstick', 'cva' => 10, 'ba' => 8, 'tea' => 30],
|
||||
];
|
||||
$this->db->table('master_tests')->insertBatch($tests);
|
||||
$testIds = $this->db->table('master_tests')->select('test_id')->get()->getResultArray();
|
||||
$testIdMap = array_column($testIds, 'test_id');
|
||||
|
||||
// 4. Insert Control-Tests (15 entries - 3 per control for first 5 controls)
|
||||
$controlTests = [
|
||||
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[0], 'mean' => 95, 'sd' => 5],
|
||||
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[1], 'mean' => 1.0, 'sd' => 0.05],
|
||||
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[2], 'mean' => 15, 'sd' => 1.2],
|
||||
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[0], 'mean' => 180, 'sd' => 12],
|
||||
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[1], 'mean' => 2.5, 'sd' => 0.15],
|
||||
['control_id' => $controlIdMap[1], 'test_id' => $testIdMap[3], 'mean' => 200, 'sd' => 15],
|
||||
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[4], 'mean' => 7.5, 'sd' => 0.6],
|
||||
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[5], 'mean' => 4.8, 'sd' => 0.2],
|
||||
['control_id' => $controlIdMap[2], 'test_id' => $testIdMap[6], 'mean' => 14.5, 'sd' => 0.5],
|
||||
['control_id' => $controlIdMap[3], 'test_id' => $testIdMap[4], 'mean' => 3.5, 'sd' => 0.3],
|
||||
['control_id' => $controlIdMap[3], 'test_id' => $testIdMap[5], 'mean' => 2.5, 'sd' => 0.15],
|
||||
['control_id' => $controlIdMap[4], 'test_id' => $testIdMap[7], 'mean' => 2.5, 'sd' => 0.3],
|
||||
['control_id' => $controlIdMap[4], 'test_id' => $testIdMap[8], 'mean' => 1.2, 'sd' => 0.1],
|
||||
['control_id' => $controlIdMap[5], 'test_id' => $testIdMap[9], 'mean' => 10, 'sd' => 1.5],
|
||||
['control_id' => $controlIdMap[0], 'test_id' => $testIdMap[3], 'mean' => 150, 'sd' => 10],
|
||||
];
|
||||
$this->db->table('control_tests')->insertBatch($controlTests);
|
||||
$ctRows = $this->db->table('control_tests')->select('control_test_id, control_id, test_id, mean, sd')->get()->getResultArray();
|
||||
|
||||
// 5. Insert Results (90 entries - 6 per control-test, daily data spanning ~3 months)
|
||||
$results = [];
|
||||
$faker = \Faker\Factory::create();
|
||||
|
||||
// Start date: 3 months ago, generate daily entries
|
||||
$startDate = date('2025-10-01');
|
||||
$daysToGenerate = 90; // ~3 months of daily data
|
||||
|
||||
foreach ($ctRows as $ct) {
|
||||
// Generate 6 results per control-test, spread across the date range
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
// Distribute results across the 90-day period
|
||||
$dayOffset = floor(($i * $daysToGenerate) / 6) + $faker->numberBetween(0, 5);
|
||||
$resDate = date('Y-m-d', strtotime($startDate . ' +' . $dayOffset . ' days'));
|
||||
|
||||
// Generate value within +/- 2.5 SD
|
||||
$value = $ct['mean'] + ($faker->randomFloat(2, -2.5, 2.5) * $ct['sd']);
|
||||
|
||||
$results[] = [
|
||||
'control_id' => $ct['control_id'],
|
||||
'test_id' => $ct['test_id'],
|
||||
'res_date' => $resDate,
|
||||
'res_value' => round($value, 2),
|
||||
'res_comment' => null,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->db->table('results')->insertBatch($results);
|
||||
|
||||
// 6. Insert Result Comments (60 entries - monthly comments for all control-test combos)
|
||||
$resultComments = [];
|
||||
$months = ['2025-10', '2025-11', '2025-12', '2026-01'];
|
||||
|
||||
$commentTemplates = [
|
||||
'2025-10' => [
|
||||
['control_id' => 0, 'test_id' => 0, 'text' => 'QC performance stable, all parameters within range for October'],
|
||||
['control_id' => 0, 'test_id' => 1, 'text' => 'Creatinine controls stable, no issues observed'],
|
||||
['control_id' => 0, 'test_id' => 2, 'text' => 'BUN QC within acceptable limits'],
|
||||
['control_id' => 1, 'test_id' => 0, 'text' => 'High glucose QC showed slight elevation, monitoring continued'],
|
||||
['control_id' => 1, 'test_id' => 3, 'text' => 'Cholesterol lot QC2026002 performs within specifications'],
|
||||
['control_id' => 2, 'test_id' => 4, 'text' => 'WBC counts consistent throughout the month'],
|
||||
['control_id' => 2, 'test_id' => 5, 'text' => 'RBC QC stable, no drift detected'],
|
||||
['control_id' => 2, 'test_id' => 6, 'text' => 'Hemoglobin controls within expected range'],
|
||||
['control_id' => 3, 'test_id' => 4, 'text' => 'Low WBC QC verified, precision acceptable'],
|
||||
['control_id' => 3, 'test_id' => 5, 'text' => 'Low RBC controls passed QC checks'],
|
||||
['control_id' => 4, 'test_id' => 7, 'text' => 'TSH assay calibration verified on 10/15'],
|
||||
['control_id' => 4, 'test_id' => 8, 'text' => 'Free T4 controls stable, no maintenance required'],
|
||||
['control_id' => 5, 'test_id' => 9, 'text' => 'Urine protein dipstick QC performing well'],
|
||||
['control_id' => 0, 'test_id' => 3, 'text' => 'Normal cholesterol QC within target range'],
|
||||
],
|
||||
'2025-11' => [
|
||||
['control_id' => 0, 'test_id' => 0, 'text' => 'Glucose QC showed minor drift, recalibration performed 11/10'],
|
||||
['control_id' => 0, 'test_id' => 1, 'text' => 'November creatinine QC results acceptable'],
|
||||
['control_id' => 0, 'test_id' => 2, 'text' => 'BUN controls stable after reagent lot change'],
|
||||
['control_id' => 1, 'test_id' => 0, 'text' => 'High glucose QC consistent after recalibration'],
|
||||
['control_id' => 1, 'test_id' => 3, 'text' => 'Cholesterol QC performance improved after maintenance'],
|
||||
['control_id' => 2, 'test_id' => 4, 'text' => 'WBC QC acceptable, precision within specifications'],
|
||||
['control_id' => 2, 'test_id' => 5, 'text' => 'RBC controls verified, no issues'],
|
||||
['control_id' => 2, 'test_id' => 6, 'text' => 'Hemoglobin QC stable, Levey-Jenkins chart within limits'],
|
||||
['control_id' => 3, 'test_id' => 4, 'text' => 'Low WBC QC passed all QC checks for November'],
|
||||
['control_id' => 3, 'test_id' => 5, 'text' => 'Low RBC controls stable throughout month'],
|
||||
['control_id' => 4, 'test_id' => 7, 'text' => 'TSH controls within range, no action required'],
|
||||
['control_id' => 4, 'test_id' => 8, 'text' => 'Free T4 assay verification complete'],
|
||||
['control_id' => 5, 'test_id' => 9, 'text' => 'Urine protein QC lot QC2026006 performing well'],
|
||||
['control_id' => 0, 'test_id' => 3, 'text' => 'Normal cholesterol lot QC2026001 verified'],
|
||||
],
|
||||
'2025-12' => [
|
||||
['control_id' => 0, 'test_id' => 0, 'text' => 'December glucose QC stable, no issues'],
|
||||
['control_id' => 0, 'test_id' => 1, 'text' => 'Creatinine QC performance acceptable for year-end'],
|
||||
['control_id' => 0, 'test_id' => 2, 'text' => 'BUN controls within range, holiday period monitoring'],
|
||||
['control_id' => 1, 'test_id' => 0, 'text' => 'High glucose QC stable after lot stabilization'],
|
||||
['control_id' => 1, 'test_id' => 3, 'text' => 'Cholesterol QC trending well, within 2SD'],
|
||||
['control_id' => 2, 'test_id' => 4, 'text' => 'WBC QC stable, year-end verification complete'],
|
||||
['control_id' => 2, 'test_id' => 5, 'text' => 'RBC controls verified, all parameters acceptable'],
|
||||
['control_id' => 2, 'test_id' => 6, 'text' => 'Hemoglobin QC within expected range'],
|
||||
['control_id' => 3, 'test_id' => 4, 'text' => 'Low WBC QC passed December checks'],
|
||||
['control_id' => 3, 'test_id' => 5, 'text' => 'Low RBC controls stable'],
|
||||
['control_id' => 4, 'test_id' => 7, 'text' => 'TSH controls within specifications'],
|
||||
['control_id' => 4, 'test_id' => 8, 'text' => 'Free T4 QC acceptable for December'],
|
||||
['control_id' => 5, 'test_id' => 9, 'text' => 'Urine protein QC no issues reported'],
|
||||
['control_id' => 0, 'test_id' => 3, 'text' => 'Cholesterol QC lot QC2026001 verified'],
|
||||
],
|
||||
'2026-01' => [
|
||||
['control_id' => 0, 'test_id' => 0, 'text' => 'January glucose QC started well, new year verification'],
|
||||
['control_id' => 0, 'test_id' => 1, 'text' => 'Creatinine QC performance consistent'],
|
||||
['control_id' => 0, 'test_id' => 2, 'text' => 'BUN controls within expected parameters'],
|
||||
['control_id' => 1, 'test_id' => 0, 'text' => 'High glucose QC stable start to new year'],
|
||||
['control_id' => 1, 'test_id' => 3, 'text' => 'Cholesterol QC lot QC2026002 verified'],
|
||||
['control_id' => 2, 'test_id' => 4, 'text' => 'WBC QC started new year within specifications'],
|
||||
['control_id' => 2, 'test_id' => 5, 'text' => 'RBC controls performing as expected'],
|
||||
['control_id' => 2, 'test_id' => 6, 'text' => 'Hemoglobin QC acceptable'],
|
||||
['control_id' => 3, 'test_id' => 4, 'text' => 'Low WBC QC passed January checks'],
|
||||
['control_id' => 3, 'test_id' => 5, 'text' => 'Low RBC controls verified'],
|
||||
['control_id' => 4, 'test_id' => 7, 'text' => 'TSH controls within range'],
|
||||
['control_id' => 4, 'test_id' => 8, 'text' => 'Free T4 QC stable'],
|
||||
['control_id' => 5, 'test_id' => 9, 'text' => 'Urine protein QC performing well'],
|
||||
['control_id' => 0, 'test_id' => 3, 'text' => 'Cholesterol QC lot QC2026001 verified'],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($months as $month) {
|
||||
if (isset($commentTemplates[$month])) {
|
||||
foreach ($commentTemplates[$month] as $comment) {
|
||||
$resultComments[] = [
|
||||
'control_id' => $controlIdMap[$comment['control_id']],
|
||||
'test_id' => $testIdMap[$comment['test_id']],
|
||||
'comment_month' => $month,
|
||||
'com_text' => $comment['text'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->db->table('result_comments')->insertBatch($resultComments);
|
||||
}
|
||||
}
|
||||
@ -27,4 +27,94 @@ class ControlTestsModel extends BaseModel {
|
||||
}
|
||||
return $this->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get control-test with control and test details
|
||||
*/
|
||||
public function getWithDetails(int $controlTestId): ?array {
|
||||
$builder = $this->db->table('control_tests ct');
|
||||
$builder->select('
|
||||
ct.control_test_id as id,
|
||||
ct.control_id as controlId,
|
||||
ct.test_id as testId,
|
||||
ct.mean,
|
||||
ct.sd,
|
||||
c.control_name as controlName,
|
||||
c.lot,
|
||||
t.test_name as testName,
|
||||
t.test_unit as testUnit
|
||||
');
|
||||
$builder->join('master_controls c', 'c.control_id = ct.control_id');
|
||||
$builder->join('master_tests t', 't.test_id = ct.test_id');
|
||||
$builder->where('ct.control_test_id', $controlTestId);
|
||||
$builder->where('ct.deleted_at', null);
|
||||
|
||||
return $builder->get()->getRowArray() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tests for a control with QC parameters
|
||||
*/
|
||||
public function getByControl(int $controlId): array {
|
||||
$builder = $this->db->table('control_tests ct');
|
||||
$builder->select('
|
||||
ct.control_test_id as id,
|
||||
ct.control_id as controlId,
|
||||
ct.test_id as testId,
|
||||
ct.mean,
|
||||
ct.sd,
|
||||
t.test_name as testName,
|
||||
t.test_unit as testUnit
|
||||
');
|
||||
$builder->join('master_tests t', 't.test_id = ct.test_id');
|
||||
$builder->where('ct.control_id', $controlId);
|
||||
$builder->where('ct.deleted_at', null);
|
||||
$builder->where('t.deleted_at', null);
|
||||
$builder->orderBy('t.test_name', 'ASC');
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get controls for a test with QC parameters
|
||||
*/
|
||||
public function getByTest(int $testId): array {
|
||||
$builder = $this->db->table('control_tests ct');
|
||||
$builder->select('
|
||||
ct.control_test_id as id,
|
||||
ct.control_id as controlId,
|
||||
ct.test_id as testId,
|
||||
ct.mean,
|
||||
ct.sd,
|
||||
c.control_name as controlName,
|
||||
c.lot,
|
||||
c.producer
|
||||
');
|
||||
$builder->join('master_controls c', 'c.control_id = ct.control_id');
|
||||
$builder->where('ct.test_id', $testId);
|
||||
$builder->where('ct.deleted_at', null);
|
||||
$builder->where('c.deleted_at', null);
|
||||
$builder->orderBy('c.control_name', 'ASC');
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get by control and test
|
||||
*/
|
||||
public function getByControlAndTest(int $controlId, int $testId): ?array {
|
||||
$builder = $this->db->table('control_tests ct');
|
||||
$builder->select('
|
||||
ct.control_test_id as id,
|
||||
ct.control_id as controlId,
|
||||
ct.test_id as testId,
|
||||
ct.mean,
|
||||
ct.sd
|
||||
');
|
||||
$builder->where('ct.control_id', $controlId);
|
||||
$builder->where('ct.test_id', $testId);
|
||||
$builder->where('ct.deleted_at', null);
|
||||
|
||||
return $builder->get()->getRowArray() ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,4 +28,51 @@ class ResultCommentsModel extends BaseModel {
|
||||
}
|
||||
return $this->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comments by control, test and month
|
||||
*/
|
||||
public function getByControlTestMonth(int $controlId, int $testId, string $month): ?array {
|
||||
return $this->where('control_id', $controlId)
|
||||
->where('test_id', $testId)
|
||||
->where('comment_month', $month)
|
||||
->where('deleted_at', null)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all comments for a test and month
|
||||
*/
|
||||
public function getByTestMonth(int $testId, string $month): array {
|
||||
return $this->where('test_id', $testId)
|
||||
->where('comment_month', $month)
|
||||
->where('deleted_at', null)
|
||||
->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert comment (insert or update based on control/test/month)
|
||||
*/
|
||||
public function upsertComment(array $data): int {
|
||||
$existing = $this->where('control_id', $data['control_id'])
|
||||
->where('test_id', $data['test_id'])
|
||||
->where('comment_month', $data['comment_month'])
|
||||
->where('deleted_at', null)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if (empty($data['com_text'])) {
|
||||
// If text is empty, soft delete
|
||||
$this->update($existing['result_comment_id'], ['deleted_at' => date('Y-m-d H:i:s')]);
|
||||
return $existing['result_comment_id'];
|
||||
}
|
||||
$this->update($existing['result_comment_id'], $data);
|
||||
return $existing['result_comment_id'];
|
||||
} else {
|
||||
if (empty($data['com_text'])) {
|
||||
return 0; // Don't insert empty comments
|
||||
}
|
||||
return $this->insert($data, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,4 +28,96 @@ class ResultsModel extends BaseModel {
|
||||
}
|
||||
return $this->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get results by date and control
|
||||
*/
|
||||
public function getByDateAndControl(string $date, int $controlId): array {
|
||||
$builder = $this->db->table('results r');
|
||||
$builder->select('
|
||||
r.result_id as id,
|
||||
r.control_id as controlId,
|
||||
r.test_id as testId,
|
||||
r.res_date as resDate,
|
||||
r.res_value as resValue,
|
||||
r.res_comment as resComment
|
||||
');
|
||||
$builder->where('r.res_date', $date);
|
||||
$builder->where('r.control_id', $controlId);
|
||||
$builder->where('r.deleted_at', null);
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get results by month for a specific test (for monthly entry)
|
||||
*/
|
||||
public function getByMonth(int $testId, string $month): array {
|
||||
$builder = $this->db->table('results r');
|
||||
$builder->select('
|
||||
r.result_id as id,
|
||||
r.control_id as controlId,
|
||||
r.test_id as testId,
|
||||
r.res_date as resDate,
|
||||
r.res_value as resValue,
|
||||
r.res_comment as resComment
|
||||
');
|
||||
$builder->where('r.test_id', $testId);
|
||||
$builder->where('r.res_date >=', $month . '-01');
|
||||
$builder->where('r.res_date <=', $month . '-31');
|
||||
$builder->where('r.deleted_at', null);
|
||||
$builder->orderBy('r.res_date', 'ASC');
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get results by control and month (for monthly entry calendar grid)
|
||||
*/
|
||||
public function getByControlAndMonth(int $controlId, int $testId, string $month): array {
|
||||
$builder = $this->db->table('results r');
|
||||
$builder->select('
|
||||
r.result_id as id,
|
||||
r.res_date as resDate,
|
||||
r.res_value as resValue
|
||||
');
|
||||
$builder->where('r.control_id', $controlId);
|
||||
$builder->where('r.test_id', $testId);
|
||||
$builder->where('r.res_date >=', $month . '-01');
|
||||
$builder->where('r.res_date <=', $month . '-31');
|
||||
$builder->where('r.deleted_at', null);
|
||||
$builder->orderBy('r.res_date', 'ASC');
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert results (insert or update based on date/control/test)
|
||||
*/
|
||||
public function upsertResult(array $data): int {
|
||||
// Check if record exists
|
||||
$existing = $this->where('control_id', $data['control_id'])
|
||||
->where('test_id', $data['test_id'])
|
||||
->where('res_date', $data['res_date'])
|
||||
->where('deleted_at', null)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->update($existing['resultId'], $data);
|
||||
return $existing['resultId'];
|
||||
} else {
|
||||
return $this->insert($data, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch upsert results
|
||||
*/
|
||||
public function batchUpsertResults(array $results): array {
|
||||
$ids = [];
|
||||
foreach ($results as $result) {
|
||||
$ids[] = $this->upsertResult($result);
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,32 +8,44 @@
|
||||
</div>
|
||||
|
||||
<!-- Quick Action Cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<a href="<?= base_url('entry') ?>" class="card bg-primary text-primary-content hover:bg-primary/90 cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||
<a href="<?= base_url('entry/daily') ?>"
|
||||
class="card bg-primary text-primary-content hover:bg-primary/90 cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<div class="card-body items-center text-center py-4">
|
||||
<i class="fa-solid fa-pen-to-square text-2xl mb-2"></i>
|
||||
<span class="font-medium">QC Entry</span>
|
||||
<i class="fa-solid fa-calendar-day text-2xl mb-2"></i>
|
||||
<span class="font-medium">Daily Entry</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="<?= base_url('master/dept') ?>" class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<a href="<?= base_url('entry/monthly') ?>"
|
||||
class="card bg-secondary text-secondary-content hover:bg-secondary/90 cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<div class="card-body items-center text-center py-4">
|
||||
<i class="fa-solid fa-calendar-days text-2xl mb-2"></i>
|
||||
<span class="font-medium">Monthly Entry</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="<?= base_url('master/dept') ?>"
|
||||
class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<div class="card-body items-center text-center py-4">
|
||||
<i class="fa-solid fa-building text-2xl mb-2"></i>
|
||||
<span class="font-medium">Departments</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="<?= base_url('master/test') ?>" class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<a href="<?= base_url('master/test') ?>"
|
||||
class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<div class="card-body items-center text-center py-4">
|
||||
<i class="fa-solid fa-flask-vial text-2xl mb-2"></i>
|
||||
<span class="font-medium">Tests</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="<?= base_url('master/control') ?>" class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<a href="<?= base_url('master/control') ?>"
|
||||
class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<div class="card-body items-center text-center py-4">
|
||||
<i class="fa-solid fa-vial text-2xl mb-2"></i>
|
||||
<span class="font-medium">Controls</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="<?= base_url('report') ?>" class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<a href="<?= base_url('report') ?>"
|
||||
class="card bg-base-100 border border-base-300 hover:border-primary cursor-pointer no-underline hover:scale-[1.02] transition">
|
||||
<div class="card-body items-center text-center py-4">
|
||||
<i class="fa-solid fa-chart-bar text-2xl mb-2"></i>
|
||||
<span class="font-medium">Reports</span>
|
||||
@ -41,8 +53,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6"
|
||||
x-data="dashboardRecentResults()">
|
||||
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6" x-data="dashboardRecentResults()">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-base-content">Recent Results</h2>
|
||||
<button @click="fetchResults()" class="btn btn-ghost btn-sm">
|
||||
@ -83,9 +94,8 @@
|
||||
<td class="text-right font-mono" x-text="row.resValue ?? '-'"></td>
|
||||
<td class="font-mono text-sm" x-text="row.rangeDisplay"></td>
|
||||
<td class="text-center">
|
||||
<span class="badge"
|
||||
:class="row.inRange ? 'badge-success' : 'badge-error'"
|
||||
x-text="row.inRange ? 'Pass' : 'Fail'">
|
||||
<span class="badge" :class="row.inRange ? 'badge-success' : 'badge-error'"
|
||||
x-text="row.inRange ? 'Pass' : 'Fail'">
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@ -99,28 +109,28 @@
|
||||
|
||||
<?= $this->section("script"); ?>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("dashboardRecentResults", () => ({
|
||||
results: [],
|
||||
loading: true,
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("dashboardRecentResults", () => ({
|
||||
results: [],
|
||||
loading: true,
|
||||
|
||||
init() {
|
||||
this.fetchResults();
|
||||
},
|
||||
init() {
|
||||
this.fetchResults();
|
||||
},
|
||||
|
||||
async fetchResults() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch(`${BASEURL}api/dashboard/recent?limit=10`);
|
||||
const json = await response.json();
|
||||
this.results = json.data || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
async fetchResults() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch(`${BASEURL}api/dashboard/recent?limit=10`);
|
||||
const json = await response.json();
|
||||
this.results = json.data || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
@ -1,16 +1,340 @@
|
||||
<?= $this->extend("layout/main_layout"); ?>
|
||||
|
||||
<?= $this->section("content"); ?>
|
||||
<main class="flex-1 p-6 overflow-auto">
|
||||
<main class="flex-1 p-6 overflow-auto"
|
||||
x-data="dailyEntry()">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-base-content tracking-tight">Daily Entry</h1>
|
||||
<p class="text-sm mt-1 opacity-70">Record daily QC test results</p>
|
||||
</div>
|
||||
<button @click="saveResults()"
|
||||
:disabled="saving || !canSave"
|
||||
class="btn btn-primary"
|
||||
:class="{ 'loading': saving }">
|
||||
<i class="fa-solid fa-save mr-2"></i>
|
||||
Save Results
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-6">
|
||||
<p class="text-base-content/60 text-center py-8">Daily entry form coming soon...</p>
|
||||
<!-- Filters -->
|
||||
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6">
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Date</span>
|
||||
</label>
|
||||
<input type="date"
|
||||
x-model="date"
|
||||
@change="fetchControls()"
|
||||
:max="today"
|
||||
class="input input-bordered w-40">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Quick Date</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="setToday()" class="btn btn-sm btn-outline">Today</button>
|
||||
<button @click="setYesterday()" class="btn btn-sm btn-outline">Yesterday</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Control</span>
|
||||
</label>
|
||||
<select x-model="selectedControl"
|
||||
@change="fetchTests()"
|
||||
class="select select-bordered w-64">
|
||||
<option value="">Select Control</option>
|
||||
<template x-for="control in controls" :key="control.id">
|
||||
<option :value="control.id" x-text="control.controlName + ' (Lot: ' + control.lot + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && selectedControl && tests.length === 0"
|
||||
class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-12 text-center">
|
||||
<i class="fa-solid fa-flask text-4xl text-base-content/20 mb-3"></i>
|
||||
<p class="text-base-content/60">No tests configured for this control</p>
|
||||
<p class="text-sm text-base-content/40 mt-1">Add tests in the Control-Tests setup</p>
|
||||
</div>
|
||||
|
||||
<!-- No Control Selected -->
|
||||
<div x-show="!loading && !selectedControl"
|
||||
class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-12 text-center">
|
||||
<i class="fa-solid fa-clipboard-list text-4xl text-base-content/20 mb-3"></i>
|
||||
<p class="text-base-content/60">Select a control to view tests</p>
|
||||
</div>
|
||||
|
||||
<!-- Tests Grid -->
|
||||
<div x-show="!loading && selectedControl && tests.length > 0"
|
||||
class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead class="bg-base-200">
|
||||
<tr>
|
||||
<th>Test</th>
|
||||
<th class="text-center">Mean ± 2SD</th>
|
||||
<th class="w-32">Result</th>
|
||||
<th class="w-56">Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="test in tests" :key="test.testId">
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="font-medium" x-text="test.testName"></div>
|
||||
<div class="text-xs text-base-content/50" x-text="test.testUnit || ''"></div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="font-mono text-sm">
|
||||
<span x-text="test.mean !== null ? test.mean : '-'"></span>
|
||||
<span class="text-base-content/40">±</span>
|
||||
<span x-text="test.sd !== null ? (2 * test.sd).toFixed(2) : '-'"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number"
|
||||
step="0.01"
|
||||
:placeholder="'...'"
|
||||
class="input input-bordered input-sm w-full font-mono"
|
||||
:class="getInputClass(test, $el.value)"
|
||||
@input.debounce.300ms="updateResult(test.testId, $el.value)"
|
||||
:value="test.existingResult ? test.existingResult.resValue : ''">
|
||||
</td>
|
||||
<td>
|
||||
<textarea
|
||||
:placeholder="'Optional comment...'"
|
||||
rows="1"
|
||||
class="textarea textarea-bordered textarea-xs w-full"
|
||||
@input.debounce.300ms="updateComment(test.testId, $el.value)"
|
||||
:value="getComment(test.testId)"></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div x-show="hasChanges"
|
||||
class="mt-4 p-3 bg-info/10 rounded-lg border border-info/20 text-sm">
|
||||
<i class="fa-solid fa-info-circle mr-2"></i>
|
||||
<span x-text="changedCount"></span> test(s) with changes pending save
|
||||
</div>
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section("script"); ?>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("dailyEntry", () => ({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
controls: [],
|
||||
selectedControl: null,
|
||||
tests: [],
|
||||
loading: false,
|
||||
saving: false,
|
||||
resultsData: {},
|
||||
commentsData: {},
|
||||
|
||||
init() {
|
||||
this.fetchControls();
|
||||
this.setupKeyboard();
|
||||
},
|
||||
|
||||
get today() {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
},
|
||||
|
||||
async fetchControls() {
|
||||
try {
|
||||
const params = new URLSearchParams({ date: this.date });
|
||||
const response = await fetch(`${BASEURL}api/entry/controls?${params}`);
|
||||
const json = await response.json();
|
||||
this.controls = json.data || [];
|
||||
// Reset selected control if it's no longer in the list
|
||||
if (this.selectedControl && !this.controls.find(c => c.id === this.selectedControl)) {
|
||||
this.selectedControl = null;
|
||||
this.tests = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch controls:', e);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchTests() {
|
||||
if (!this.selectedControl) {
|
||||
this.tests = [];
|
||||
this.resultsData = {};
|
||||
this.commentsData = {};
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
date: this.date,
|
||||
control_id: this.selectedControl
|
||||
});
|
||||
const response = await fetch(`${BASEURL}api/entry/daily?${params}`);
|
||||
const json = await response.json();
|
||||
this.tests = json.data || [];
|
||||
|
||||
// Initialize resultsData and commentsData with existing values
|
||||
this.resultsData = {};
|
||||
this.commentsData = {};
|
||||
for (const test of this.tests) {
|
||||
if (test.existingResult && test.existingResult.resValue !== null) {
|
||||
this.resultsData[test.testId] = test.existingResult.resValue;
|
||||
}
|
||||
if (test.existingResult && test.existingResult.resComment) {
|
||||
this.commentsData[test.testId] = test.existingResult.resComment;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tests:', e);
|
||||
this.$dispatch('notify', { type: 'error', message: 'Failed to load tests' });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveResults() {
|
||||
if (!this.canSave) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const results = [];
|
||||
for (const test of this.tests) {
|
||||
const value = this.resultsData[test.testId];
|
||||
if (value !== undefined && value !== '') {
|
||||
results.push({
|
||||
controlId: test.controlId,
|
||||
testId: test.testId,
|
||||
value: parseFloat(value),
|
||||
comment: this.commentsData[test.testId] || null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASEURL}api/entry/daily`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
date: this.date,
|
||||
results: results
|
||||
})
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
if (json.status === 'success') {
|
||||
// Show success notification
|
||||
this.$dispatch('notify', { type: 'success', message: json.message });
|
||||
// Refresh data
|
||||
this.resultsData = {};
|
||||
this.commentsData = {};
|
||||
await this.fetchTests();
|
||||
} else {
|
||||
this.$dispatch('notify', { type: 'error', message: json.message || 'Failed to save' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save:', e);
|
||||
this.$dispatch('notify', { type: 'error', message: 'Failed to save results' });
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
updateResult(testId, value) {
|
||||
if (value === '') {
|
||||
delete this.resultsData[testId];
|
||||
} else {
|
||||
this.resultsData[testId] = value;
|
||||
}
|
||||
},
|
||||
|
||||
updateComment(testId, value) {
|
||||
if (value.trim() === '') {
|
||||
delete this.commentsData[testId];
|
||||
} else {
|
||||
this.commentsData[testId] = value;
|
||||
}
|
||||
},
|
||||
|
||||
getComment(testId) {
|
||||
return this.commentsData[testId] || '';
|
||||
},
|
||||
|
||||
getExpectedRange(mean, sd) {
|
||||
if (mean === null || sd === null) return 'N/A';
|
||||
const min = (mean - 2 * sd).toFixed(2);
|
||||
const max = (mean + 2 * sd).toFixed(2);
|
||||
return `${min} - ${max}`;
|
||||
},
|
||||
|
||||
getValueClass(test, value) {
|
||||
if (test.mean === null || test.sd === null) return '';
|
||||
const num = parseFloat(value);
|
||||
const lower = test.mean - 2 * test.sd;
|
||||
const upper = test.mean + 2 * test.sd;
|
||||
if (num >= lower && num <= upper) return 'text-success';
|
||||
return 'text-error';
|
||||
},
|
||||
|
||||
getInputClass(test, value) {
|
||||
if (value === '' || test.mean === null || test.sd === null) return '';
|
||||
const num = parseFloat(value);
|
||||
const lower = test.mean - 2 * test.sd;
|
||||
const upper = test.mean + 2 * test.sd;
|
||||
if (num >= lower && num <= upper) return 'input-success';
|
||||
return 'input-error';
|
||||
},
|
||||
|
||||
get hasChanges() {
|
||||
return Object.keys(this.resultsData).length > 0 ||
|
||||
Object.keys(this.commentsData).length > 0;
|
||||
},
|
||||
|
||||
get changedCount() {
|
||||
return Object.keys(this.resultsData).length + Object.keys(this.commentsData).length;
|
||||
},
|
||||
|
||||
get canSave() {
|
||||
return this.selectedControl && (Object.keys(this.resultsData).length > 0 || Object.keys(this.commentsData).length > 0) && !this.saving;
|
||||
},
|
||||
|
||||
setToday() {
|
||||
this.date = new Date().toISOString().split('T')[0];
|
||||
},
|
||||
|
||||
setYesterday() {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
this.date = yesterday.toISOString().split('T')[0];
|
||||
},
|
||||
|
||||
setupKeyboard() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (this.canSave) {
|
||||
this.saveResults();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
@ -23,17 +23,17 @@
|
||||
</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">
|
||||
<a href="<?= base_url('/entry/monthly') ?>" class="block p-6 rounded-xl border-2 border-dashed border-base-300 hover:border-green-500 hover:bg-green-50 transition-all group">
|
||||
<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>
|
||||
<i class="fa-solid fa-calendar-range 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>
|
||||
<h3 class="font-semibold text-base-content">Monthly Entry</h3>
|
||||
<p class="text-sm text-base-content/60 mt-1">Monthly QC results & comments</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
414
app/Views/entry/monthly.php
Normal file
414
app/Views/entry/monthly.php
Normal file
@ -0,0 +1,414 @@
|
||||
<?= $this->extend("layout/main_layout"); ?>
|
||||
|
||||
<?= $this->section("content"); ?>
|
||||
<main class="flex-1 p-6 overflow-auto"
|
||||
x-data="monthlyEntry()">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-base-content tracking-tight">Monthly Entry</h1>
|
||||
<p class="text-sm mt-1 opacity-70">Record monthly QC results and comments</p>
|
||||
</div>
|
||||
<button @click="saveAll()"
|
||||
:disabled="saving || !canSave"
|
||||
class="btn btn-primary"
|
||||
:class="{ 'loading': saving }">
|
||||
<i class="fa-solid fa-save mr-2"></i>
|
||||
Save All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6">
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Month</span>
|
||||
</label>
|
||||
<input type="month"
|
||||
x-model="month"
|
||||
@change="fetchControls()"
|
||||
class="input input-bordered w-40">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Test</span>
|
||||
</label>
|
||||
<select x-model="selectedTest"
|
||||
@change="fetchControls()"
|
||||
class="select select-bordered w-64">
|
||||
<option value="">Select Test</option>
|
||||
<template x-for="test in tests" :key="test.id">
|
||||
<option :value="test.id" x-text="test.testName"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Quick Month</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="prevMonth()" class="btn btn-sm btn-outline"><i class="fa-solid fa-chevron-left"></i></button>
|
||||
<button @click="setCurrentMonth()" class="btn btn-sm btn-outline">Current</button>
|
||||
<button @click="nextMonth()" class="btn btn-sm btn-outline"><i class="fa-solid fa-chevron-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && selectedTest && controls.length === 0"
|
||||
class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-12 text-center">
|
||||
<i class="fa-solid fa-vial text-4xl text-base-content/20 mb-3"></i>
|
||||
<p class="text-base-content/60">No controls configured for this test</p>
|
||||
<p class="text-sm text-base-content/40 mt-1">Add controls in the Control-Tests setup</p>
|
||||
</div>
|
||||
|
||||
<!-- No Test Selected -->
|
||||
<div x-show="!loading && !selectedTest"
|
||||
class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-12 text-center">
|
||||
<i class="fa-solid fa-list-check text-4xl text-base-content/20 mb-3"></i>
|
||||
<p class="text-base-content/60">Select a test to view controls and calendar</p>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<div x-show="!loading && selectedTest && controls.length > 0"
|
||||
class="space-y-6">
|
||||
<!-- Calendar Header -->
|
||||
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
|
||||
<div class="p-3 bg-base-200 border-b border-base-300">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 class="font-medium" x-text="testName + ' - ' + monthDisplay"></h3>
|
||||
<p class="text-xs text-base-content/60" x-text="testUnit || ''"></p>
|
||||
</div>
|
||||
<div class="text-xs text-base-content/70 text-right" x-show="qcParameters">
|
||||
<span x-text="qcParameters"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sticky left-0 bg-base-200 z-10 w-48 p-2 text-left border-r border-base-300">
|
||||
Control
|
||||
</th>
|
||||
<template x-for="day in daysInMonth" :key="day">
|
||||
<th class="w-14 p-1 text-center text-xs"
|
||||
:class="{
|
||||
'bg-base-200': isWeekend(day),
|
||||
'text-base-content/50': isWeekend(day)
|
||||
}"
|
||||
x-text="day"></th>
|
||||
</template>
|
||||
<th class="w-48 p-2 text-left border-l border-base-300">Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="control in controls" :key="control.controlId">
|
||||
<tr class="hover">
|
||||
<td class="sticky left-0 bg-base-100 z-10 p-2 border-r border-base-300">
|
||||
<div class="font-medium text-sm" x-text="control.controlName"></div>
|
||||
<div class="text-xs text-base-content/50" x-text="control.lot || ''"></div>
|
||||
</td>
|
||||
<template x-for="day in daysInMonth" :key="day">
|
||||
<td class="p-0.5 text-center border-r border-base-200 last:border-r-0"
|
||||
:class="{
|
||||
'bg-base-200/50': isWeekend(day)
|
||||
}">
|
||||
<input type="text"
|
||||
inputmode="decimal"
|
||||
:placeholder="'/'"
|
||||
class="input input-bordered input-xs w-full text-center font-mono"
|
||||
:class="getCellClass(control, day)"
|
||||
@input="updateResult(control.controlId, day, $event.target.value)"
|
||||
:value="getResultValue(control, day)"
|
||||
@focus="selectCell($event.target)">
|
||||
</td>
|
||||
</template>
|
||||
<td class="p-2 border-l border-base-300">
|
||||
<textarea class="textarea textarea-bordered textarea-xs w-full"
|
||||
:placeholder="'Monthly comment...'"
|
||||
rows="1"
|
||||
@input="updateComment(control.controlId, $event.target.value)"
|
||||
:value="getComment(control.controlId)"></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-4 text-sm text-base-content/70">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 border border-base-300 rounded bg-success/20"></span>
|
||||
<span>In Range</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 border border-base-300 rounded bg-error/20"></span>
|
||||
<span>Out of Range</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 border border-base-300 rounded bg-base-200"></span>
|
||||
<span>Weekend</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div x-show="hasChanges"
|
||||
class="mt-4 p-3 bg-info/10 rounded-lg border border-info/20 text-sm">
|
||||
<i class="fa-solid fa-info-circle mr-2"></i>
|
||||
<span x-text="changedCount"></span> cell(s) with changes pending save
|
||||
</div>
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section("script"); ?>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("monthlyEntry", () => ({
|
||||
month: new Date().toISOString().slice(0, 7),
|
||||
tests: [],
|
||||
selectedTest: null,
|
||||
controls: [],
|
||||
loading: false,
|
||||
saving: false,
|
||||
resultsData: {},
|
||||
commentsData: {},
|
||||
|
||||
init() {
|
||||
this.fetchTests();
|
||||
this.setupKeyboard();
|
||||
},
|
||||
|
||||
async fetchTests() {
|
||||
try {
|
||||
const response = await fetch(`${BASEURL}api/test`);
|
||||
const json = await response.json();
|
||||
this.tests = json.data || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tests:', e);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchControls() {
|
||||
if (!this.selectedTest) {
|
||||
this.controls = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
test_id: this.selectedTest,
|
||||
month: this.month
|
||||
});
|
||||
const response = await fetch(`${BASEURL}api/entry/monthly?${params}`);
|
||||
const json = await response.json();
|
||||
|
||||
if (json.status === 'success') {
|
||||
this.controls = json.data.controls || [];
|
||||
|
||||
// Build results lookup
|
||||
this.resultsData = {};
|
||||
this.commentsData = {};
|
||||
for (const control of this.controls) {
|
||||
for (let day = 1; day <= 31; day++) {
|
||||
const result = control.results[day];
|
||||
if (result && result.resValue !== null) {
|
||||
this.resultsData[`${control.controlId}_${day}`] = result.resValue;
|
||||
}
|
||||
}
|
||||
if (control.comment) {
|
||||
this.commentsData[control.controlId] = control.comment.comText;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch controls:', e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveAll() {
|
||||
if (!this.canSave) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const controls = [];
|
||||
for (const control of this.controls) {
|
||||
const results = [];
|
||||
for (let day = 1; day <= 31; day++) {
|
||||
const key = `${control.controlId}_${day}`;
|
||||
const value = this.resultsData[key];
|
||||
if (value !== undefined && value !== '') {
|
||||
results[day] = value;
|
||||
}
|
||||
}
|
||||
controls.push({
|
||||
controlId: control.controlId,
|
||||
results: results,
|
||||
comment: this.commentsData[control.controlId] || null
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASEURL}api/entry/monthly`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testId: this.selectedTest,
|
||||
month: this.month,
|
||||
controls: controls
|
||||
})
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
if (json.status === 'success') {
|
||||
this.$dispatch('notify', { type: 'success', message: json.message });
|
||||
// Refresh to get updated data
|
||||
await this.fetchControls();
|
||||
} else {
|
||||
this.$dispatch('notify', { type: 'error', message: json.message || 'Failed to save' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save:', e);
|
||||
this.$dispatch('notify', { type: 'error', message: 'Failed to save results' });
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
updateResult(controlId, day, value) {
|
||||
const key = `${controlId}_${day}`;
|
||||
if (value === '') {
|
||||
delete this.resultsData[key];
|
||||
} else {
|
||||
this.resultsData[key] = value;
|
||||
}
|
||||
},
|
||||
|
||||
updateComment(controlId, value) {
|
||||
this.commentsData[controlId] = value;
|
||||
},
|
||||
|
||||
getResultValue(control, day) {
|
||||
const key = `${control.controlId}_${day}`;
|
||||
return this.resultsData[key] !== undefined ? this.resultsData[key] : '';
|
||||
},
|
||||
|
||||
getComment(controlId) {
|
||||
return this.commentsData[controlId] || '';
|
||||
},
|
||||
|
||||
getCellClass(control, day) {
|
||||
const key = `${control.controlId}_${day}`;
|
||||
const value = this.resultsData[key];
|
||||
if (value === undefined || value === '') return '';
|
||||
|
||||
if (control.mean === null || control.sd === null) return '';
|
||||
|
||||
const num = parseFloat(value);
|
||||
const lower = control.mean - 2 * control.sd;
|
||||
const upper = control.mean + 2 * control.sd;
|
||||
|
||||
if (num >= lower && num <= upper) return 'bg-success/20';
|
||||
return 'bg-error/20';
|
||||
},
|
||||
|
||||
get daysInMonth() {
|
||||
const [year, month] = this.month.split('-').map(Number);
|
||||
const days = new Date(year, month, 0).getDate();
|
||||
return Array.from({ length: days }, (_, i) => i + 1);
|
||||
},
|
||||
|
||||
get monthDisplay() {
|
||||
if (!this.month) return '';
|
||||
const [year, month] = this.month.split('-');
|
||||
const date = new Date(year, month - 1);
|
||||
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
},
|
||||
|
||||
get testName() {
|
||||
const test = this.tests.find(t => t.id == this.selectedTest);
|
||||
return test ? test.testName : '';
|
||||
},
|
||||
|
||||
get testUnit() {
|
||||
const test = this.tests.find(t => t.id == this.selectedTest);
|
||||
return test ? test.testUnit : '';
|
||||
},
|
||||
|
||||
get qcParameters() {
|
||||
if (!this.controls || this.controls.length === 0) return '';
|
||||
const first = this.controls[0];
|
||||
if (first.mean !== null && first.sd !== null) {
|
||||
return 'Mean: ' + first.mean.toFixed(2) + ' ± ' + (2 * first.sd).toFixed(2);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
prevMonth() {
|
||||
const [year, month] = this.month.split('-').map(Number);
|
||||
const date = new Date(year, month - 2, 1);
|
||||
this.month = date.toISOString().slice(0, 7);
|
||||
this.fetchControls();
|
||||
},
|
||||
|
||||
nextMonth() {
|
||||
const [year, month] = this.month.split('-').map(Number);
|
||||
const date = new Date(year, month, 1);
|
||||
this.month = date.toISOString().slice(0, 7);
|
||||
this.fetchControls();
|
||||
},
|
||||
|
||||
isWeekend(day) {
|
||||
const [year, month] = this.month.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
const dayOfWeek = date.getDay();
|
||||
return dayOfWeek === 0 || dayOfWeek === 6;
|
||||
},
|
||||
|
||||
get hasChanges() {
|
||||
return Object.keys(this.resultsData).length > 0 ||
|
||||
Object.keys(this.commentsData).length > 0;
|
||||
},
|
||||
|
||||
get changedCount() {
|
||||
return Object.keys(this.resultsData).length;
|
||||
},
|
||||
|
||||
get canSave() {
|
||||
return this.selectedTest && this.hasChanges && !this.saving;
|
||||
},
|
||||
|
||||
setCurrentMonth() {
|
||||
this.month = new Date().toISOString().slice(0, 7);
|
||||
},
|
||||
|
||||
selectCell(element) {
|
||||
element.select();
|
||||
},
|
||||
|
||||
setupKeyboard() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (this.canSave) {
|
||||
this.saveAll();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
@ -126,6 +126,20 @@
|
||||
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(), 'entry/daily') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full"
|
||||
href="<?= base_url('/entry/daily') ?>">
|
||||
<i class="fa-solid fa-calendar-day w-5"></i>
|
||||
Daily 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(), 'entry/monthly') ? 'bg-primary/10 text-primary font-medium' : 'opacity-70 hover:bg-base-200 hover:opacity-100' ?> transition-colors h-full"
|
||||
href="<?= base_url('/entry/monthly') ?>">
|
||||
<i class="fa-solid fa-calendar w-5"></i>
|
||||
Monthly 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') ?>">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user