refactor: Rename controllers to follow CodeIgniter 4 naming convention

- Rename all controllers from X.php to XController.php format
- Add new RefTxtModel for text-based reference ranges
- Rename group_dialog.php to grp_dialog.php and remove title_dialog.php
- Add comprehensive test suite for v2/master/TestDef module
- Update Routes.php to reflect controller renames
- Remove obsolete data files (clqms_v2.sql, lab.dbml)
This commit is contained in:
mahdahar 2026-01-05 16:55:34 +07:00
parent 9e0b01e7e2
commit cd65e91db1
58 changed files with 6057 additions and 3886 deletions

161
README.md
View File

@ -66,6 +66,167 @@ When working on UI components or dropdowns, **always check for existing ValueSet
---
## 📋 Master Data Management
CLQMS provides comprehensive master data management for laboratory operations. All master data is accessible via the V2 UI at `/v2/master/*` endpoints.
### 🧪 Laboratory Tests (`/v2/master/tests`)
The Test Definitions module manages all laboratory test configurations including parameters, calculated tests, and test panels.
#### Test Types
| Type Code | Description | Table |
|-----------|-------------|-------|
| `TEST` | Individual laboratory test with technical specs | `testdefsite` + `testdeftech` |
| `PARAM` | Parameter value (non-lab measurement) | `testdefsite` + `testdeftech` |
| `CALC` | Calculated test with formula | `testdefsite` + `testdefcal` |
| `GROUP` | Panel/profile containing multiple tests | `testdefsite` + `testdefgrp` |
| `TITLE` | Section title for report organization | `testdefsite` |
#### API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/tests` | List all tests with optional filtering |
| `GET` | `/api/tests/{id}` | Get test details with type-specific data |
| `POST` | `/api/tests` | Create new test definition |
| `PATCH` | `/api/tests` | Update existing test |
| `DELETE` | `/api/tests` | Soft delete test (sets EndDate) |
#### Filtering Parameters
- `TestSiteName` - Search by test name (partial match)
- `TestType` - Filter by test type VID (1-5)
- `VisibleScr` - Filter by screen visibility (0/1)
- `VisibleRpt` - Filter by report visibility (0/1)
#### Test Response Structure
```json
{
"status": "success",
"message": "Data fetched successfully",
"data": [
{
"TestSiteID": 1,
"TestSiteCode": "CBC",
"TestSiteName": "Complete Blood Count",
"TestType": 4,
"TypeCode": "GROUP",
"TypeName": "Group Test",
"SeqScr": 50,
"VisibleScr": 1,
"VisibleRpt": 1
}
]
}
```
### 📏 Reference Ranges (`/v2/master/refrange`)
Reference Ranges define normal and critical values for test results. The system supports multiple reference range types based on patient demographics.
#### Reference Range Types
| Type | Table | Description |
|------|-------|-------------|
| Numeric | `refnum` | Numeric ranges with age/sex criteria |
| Threshold | `refthold` | Critical threshold values |
| Text | `reftxt` | Text-based reference values |
| Value Set | `refvset` | Coded reference values |
#### Numeric Reference Range Structure
| Field | Description |
|-------|-------------|
| `NumRefType` | Type: REF (Reference), CRTC (Critical), VAL (Validation), RERUN |
| `RangeType` | RANGE or THOLD |
| `Sex` | Gender filter (0=All, 1=Female, 2=Male) |
| `AgeStart` | Minimum age (years) |
| `AgeEnd` | Maximum age (years) |
| `LowSign` | Low boundary sign (=, <, <=) |
| `Low` | Low boundary value |
| `HighSign` | High boundary sign (=, >, >=) |
| `High` | High boundary value |
| `Flag` | Result flag (H, L, A, etc.) |
#### API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/refnum` | List numeric reference ranges |
| `GET` | `/api/refnum/{id}` | Get reference range details |
| `POST` | `/api/refnum` | Create reference range |
| `PATCH` | `/api/refnum` | Update reference range |
| `DELETE` | `/api/refnum` | Soft delete reference range |
### 📑 Value Sets (`/v2/master/valuesets`)
Value Sets are configurable dropdown options used throughout the system. Each Value Set Definition (VSetDef) contains multiple Value Set Values (ValueSet).
#### Value Set Hierarchy
```
valuesetdef (VSetDefID, VSName, VSDesc)
└── valueset (VID, VSetID, VValue, VDesc, VOrder, VCategory)
```
#### Common Value Sets
| VSetDefID | Name | Example Values |
|-----------|------|----------------|
| 1 | Priority | STAT (S), ASAP (A), Routine (R), Preop (P) |
| 2 | Enable/Disable | Disabled (0), Enabled (1) |
| 3 | Gender | Female (1), Male (2), Unknown (3) |
| 10 | Order Status | STC, SCtd, SArrv, SRcvd, SAna, etc. |
| 15 | Specimen Type | BLD, SER, PLAS, UR, CSF, etc. |
| 16 | Unit | L, mL, g/dL, mg/dL, etc. |
| 27 | Test Type | TEST, PARAM, CALC, GROUP, TITLE |
| 28 | Result Unit | g/dL, g/L, mg/dL, x10^6/mL, etc. |
| 35 | Test Activity | Order, Analyse, VER, REV, REP |
#### API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/valuesetdef` | List all value set definitions |
| `GET` | `/api/valuesetdef/{id}` | Get valueset with all values |
| `GET` | `/api/valuesetdef/{id}/values` | Get values for specific valueset |
| `POST` | `/api/valuesetdef` | Create new valueset definition |
| `PATCH` | `/api/valuesetdef` | Update valueset definition |
| `DELETE` | `/api/valuesetdef` | Delete valueset definition |
#### Value Set Response Structure
```json
{
"status": "success",
"data": {
"VSetDefID": 27,
"VSName": "Test Type",
"VSDesc": "testdefsite.TestType",
"values": [
{ "VID": 1, "VValue": "TEST", "VDesc": "Test", "VOrder": 1 },
{ "VID": 2, "VValue": "PARAM", "VDesc": "Parameter", "VOrder": 2 },
{ "VID": 3, "VValue": "CALC", "VDesc": "Calculated Test", "VOrder": 3 },
{ "VID": 4, "VValue": "GROUP", "VDesc": "Group Test", "VOrder": 4 },
{ "VID": 5, "VValue": "TITLE", "VDesc": "Title", "VOrder": 5 }
]
}
}
```
### 📊 Database Tables Summary
| Category | Tables | Purpose |
|----------|--------|---------|
| Tests | `testdefsite`, `testdeftech`, `testdefcal`, `testdefgrp`, `testmap` | Test definitions |
| Reference Ranges | `refnum`, `refthold`, `reftxt`, `refvset` | Result validation |
| Value Sets | `valuesetdef`, `valueset` | Configurable options |
---
## 🔌 Edge API - Instrument Integration
The **Edge API** provides endpoints for integrating laboratory instruments via the `tiny-edge` middleware. Results from instruments are staged in the `edgeres` table before processing into the main patient results (`patres`).

View File

@ -5,7 +5,7 @@ use CodeIgniter\Router\RouteCollection;
/**
* @var RouteCollection $routes
*/
$routes->get('/', function() {
$routes->get('/', function () {
return redirect()->to('/v2');
});
@ -13,10 +13,10 @@ $routes->options('(:any)', function () {
return '';
});
$routes->group('api', ['filter' => 'auth'], function($routes) {
$routes->get('dashboard', 'Dashboard::index');
$routes->get('result', 'Result::index');
$routes->get('sample', 'Sample::index');
$routes->group('api', ['filter' => 'auth'], function ($routes) {
$routes->get('dashboard', 'DashboardController::index');
$routes->get('result', 'ResultController::index');
$routes->get('sample', 'SampleController::index');
});
// Public Routes (no auth required)
@ -24,10 +24,10 @@ $routes->get('/v2/login', 'PagesController::login');
// V2 Auth API Routes (public - no auth required)
$routes->group('v2/auth', function ($routes) {
$routes->post('login', 'AuthV2::login');
$routes->post('register', 'AuthV2::register');
$routes->get('check', 'AuthV2::checkAuth');
$routes->post('logout', 'AuthV2::logout');
$routes->post('login', 'AuthV2Controller::login');
$routes->post('register', 'AuthV2Controller::register');
$routes->get('check', 'AuthV2Controller::checkAuth');
$routes->post('logout', 'AuthV2Controller::logout');
});
// Protected Page Routes - V2 (requires auth)
@ -37,18 +37,18 @@ $routes->group('v2', ['filter' => 'auth'], function ($routes) {
$routes->get('patients', 'PagesController::patients');
$routes->get('requests', 'PagesController::requests');
$routes->get('settings', 'PagesController::settings');
// Master Data - Organization
$routes->get('master/organization/accounts', 'PagesController::masterOrgAccounts');
$routes->get('master/organization/sites', 'PagesController::masterOrgSites');
$routes->get('master/organization/disciplines', 'PagesController::masterOrgDisciplines');
$routes->get('master/organization/departments', 'PagesController::masterOrgDepartments');
$routes->get('master/organization/workstations', 'PagesController::masterOrgWorkstations');
// Master Data - Specimen
$routes->get('master/specimen/containers', 'PagesController::masterSpecimenContainers');
$routes->get('master/specimen/preparations', 'PagesController::masterSpecimenPreparations');
// Master Data - Tests & ValueSets
$routes->get('master/tests', 'PagesController::masterTests');
$routes->get('master/valuesets', 'PagesController::masterValueSets');
@ -60,36 +60,36 @@ $routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1');
$routes->group('api', function ($routes) {
// Auth
$routes->group('auth', function ($routes) {
$routes->post('login', 'Auth::login');
$routes->post('change_pass', 'Auth::change_pass');
$routes->post('register', 'Auth::register');
$routes->get('check', 'Auth::checkAuth');
$routes->post('logout', 'Auth::logout');
$routes->post('login', 'AuthController::login');
$routes->post('change_pass', 'AuthController::change_pass');
$routes->post('register', 'AuthController::register');
$routes->get('check', 'AuthController::checkAuth');
$routes->post('logout', 'AuthController::logout');
});
// Patient
$routes->group('patient', function ($routes) {
$routes->get('/', 'Patient\Patient::index');
$routes->post('/', 'Patient\Patient::create');
$routes->get('(:num)', 'Patient\Patient::show/$1');
$routes->delete('/', 'Patient\Patient::delete');
$routes->patch('/', 'Patient\Patient::update');
$routes->get('check', 'Patient\Patient::patientCheck');
$routes->get('/', 'Patient\PatientController::index');
$routes->post('/', 'Patient\PatientController::create');
$routes->get('(:num)', 'Patient\PatientController::show/$1');
$routes->delete('/', 'Patient\PatientController::delete');
$routes->patch('/', 'Patient\PatientController::update');
$routes->get('check', 'Patient\PatientController::patientCheck');
});
// PatVisit
$routes->group('patvisit', function ($routes) {
$routes->get('/', 'PatVisit::index');
$routes->post('/', 'PatVisit::create');
$routes->get('patient/(:num)', 'PatVisit::showByPatient/$1');
$routes->get('(:any)', 'PatVisit::show/$1');
$routes->delete('/', 'PatVisit::delete');
$routes->patch('/', 'PatVisit::update');
$routes->get('/', 'PatVisitController::index');
$routes->post('/', 'PatVisitController::create');
$routes->get('patient/(:num)', 'PatVisitController::showByPatient/$1');
$routes->get('(:any)', 'PatVisitController::show/$1');
$routes->delete('/', 'PatVisitController::delete');
$routes->patch('/', 'PatVisitController::update');
});
$routes->group('patvisitadt', function ($routes) {
$routes->post('/', 'PatVisit::createADT');
$routes->patch('/', 'PatVisit::updateADT');
$routes->post('/', 'PatVisitController::createADT');
$routes->patch('/', 'PatVisitController::updateADT');
});
// Master Data
@ -115,169 +115,169 @@ $routes->group('api', function ($routes) {
// Location
$routes->group('location', function ($routes) {
$routes->get('/', 'Location::index');
$routes->get('(:num)', 'Location::show/$1');
$routes->post('/', 'Location::create');
$routes->patch('/', 'Location::update');
$routes->delete('/', 'Location::delete');
$routes->get('/', 'LocationController::index');
$routes->get('(:num)', 'LocationController::show/$1');
$routes->post('/', 'LocationController::create');
$routes->patch('/', 'LocationController::update');
$routes->delete('/', 'LocationController::delete');
});
// Contact
$routes->group('contact', function ($routes) {
$routes->get('/', 'Contact\Contact::index');
$routes->get('(:num)', 'Contact\Contact::show/$1');
$routes->post('/', 'Contact\Contact::create');
$routes->patch('/', 'Contact\Contact::update');
$routes->delete('/', 'Contact\Contact::delete');
$routes->get('/', 'Contact\ContactController::index');
$routes->get('(:num)', 'Contact\ContactController::show/$1');
$routes->post('/', 'Contact\ContactController::create');
$routes->patch('/', 'Contact\ContactController::update');
$routes->delete('/', 'Contact\ContactController::delete');
});
$routes->group('occupation', function ($routes) {
$routes->get('/', 'Contact\Occupation::index');
$routes->get('(:num)', 'Contact\Occupation::show/$1');
$routes->post('/', 'Contact\Occupation::create');
$routes->patch('/', 'Contact\Occupation::update');
//$routes->delete('/', 'Contact\Occupation::delete');
$routes->get('/', 'Contact\OccupationController::index');
$routes->get('(:num)', 'Contact\OccupationController::show/$1');
$routes->post('/', 'Contact\OccupationController::create');
$routes->patch('/', 'Contact\OccupationController::update');
//$routes->delete('/', 'Contact\OccupationController::delete');
});
$routes->group('medicalspecialty', function ($routes) {
$routes->get('/', 'Contact\MedicalSpecialty::index');
$routes->get('(:num)', 'Contact\MedicalSpecialty::show/$1');
$routes->post('/', 'Contact\MedicalSpecialty::create');
$routes->patch('/', 'Contact\MedicalSpecialty::update');
$routes->get('/', 'Contact\MedicalSpecialtyController::index');
$routes->get('(:num)', 'Contact\MedicalSpecialtyController::show/$1');
$routes->post('/', 'Contact\MedicalSpecialtyController::create');
$routes->patch('/', 'Contact\MedicalSpecialtyController::update');
});
// ValueSet
$routes->group('valueset', function ($routes) {
$routes->get('/', 'ValueSet\ValueSet::index');
$routes->get('(:num)', 'ValueSet\ValueSet::show/$1');
$routes->get('valuesetdef/(:num)', 'ValueSet\ValueSet::showByValueSetDef/$1');
$routes->post('/', 'ValueSet\ValueSet::create');
$routes->patch('/', 'ValueSet\ValueSet::update');
$routes->delete('/', 'ValueSet\ValueSet::delete');
$routes->get('/', 'ValueSet\ValueSetController::index');
$routes->get('(:num)', 'ValueSet\ValueSetController::show/$1');
$routes->get('valuesetdef/(:num)', 'ValueSet\ValueSetController::showByValueSetDef/$1');
$routes->post('/', 'ValueSet\ValueSetController::create');
$routes->patch('/', 'ValueSet\ValueSetController::update');
$routes->delete('/', 'ValueSet\ValueSetController::delete');
});
$routes->group('valuesetdef', function ($routes) {
$routes->get('/', 'ValueSet\ValueSetDef::index');
$routes->get('(:segment)', 'ValueSet\ValueSetDef::show/$1');
$routes->post('/', 'ValueSet\ValueSetDef::create');
$routes->patch('/', 'ValueSet\ValueSetDef::update');
$routes->delete('/', 'ValueSet\ValueSetDef::delete');
$routes->get('/', 'ValueSet\ValueSetDefController::index');
$routes->get('(:segment)', 'ValueSet\ValueSetDefController::show/$1');
$routes->post('/', 'ValueSet\ValueSetDefController::create');
$routes->patch('/', 'ValueSet\ValueSetDefController::update');
$routes->delete('/', 'ValueSet\ValueSetDefController::delete');
});
// Counter
$routes->group('counter', function ($routes) {
$routes->get('/', 'Counter::index');
$routes->get('(:num)', 'Counter::show/$1');
$routes->post('/', 'Counter::create');
$routes->patch('/', 'Counter::update');
$routes->delete('/', 'Counter::delete');
$routes->get('/', 'CounterController::index');
$routes->get('(:num)', 'CounterController::show/$1');
$routes->post('/', 'CounterController::create');
$routes->patch('/', 'CounterController::update');
$routes->delete('/', 'CounterController::delete');
});
// AreaGeo
$routes->group('areageo', function ($routes) {
$routes->get('/', 'AreaGeo::index');
$routes->get('provinces', 'AreaGeo::getProvinces');
$routes->get('cities', 'AreaGeo::getCities');
$routes->get('/', 'AreaGeoController::index');
$routes->get('provinces', 'AreaGeoController::getProvinces');
$routes->get('cities', 'AreaGeoController::getCities');
});
// Organization
$routes->group('organization', function ($routes) {
// Account
$routes->group('account', function ($routes) {
$routes->get('/', 'Organization\Account::index');
$routes->get('(:num)', 'Organization\Account::show/$1');
$routes->post('/', 'Organization\Account::create');
$routes->patch('/', 'Organization\Account::update');
$routes->delete('/', 'Organization\Account::delete');
$routes->get('/', 'Organization\AccountController::index');
$routes->get('(:num)', 'Organization\AccountController::show/$1');
$routes->post('/', 'Organization\AccountController::create');
$routes->patch('/', 'Organization\AccountController::update');
$routes->delete('/', 'Organization\AccountController::delete');
});
// Site
$routes->group('site', function ($routes) {
$routes->get('/', 'Organization\Site::index');
$routes->get('(:num)', 'Organization\Site::show/$1');
$routes->post('/', 'Organization\Site::create');
$routes->patch('/', 'Organization\Site::update');
$routes->delete('/', 'Organization\Site::delete');
$routes->get('/', 'Organization\SiteController::index');
$routes->get('(:num)', 'Organization\SiteController::show/$1');
$routes->post('/', 'Organization\SiteController::create');
$routes->patch('/', 'Organization\SiteController::update');
$routes->delete('/', 'Organization\SiteController::delete');
});
// Discipline
$routes->group('discipline', function ($routes) {
$routes->get('/', 'Organization\Discipline::index');
$routes->get('(:num)', 'Organization\Discipline::show/$1');
$routes->post('/', 'Organization\Discipline::create');
$routes->patch('/', 'Organization\Discipline::update');
$routes->delete('/', 'Organization\Discipline::delete');
$routes->get('/', 'Organization\DisciplineController::index');
$routes->get('(:num)', 'Organization\DisciplineController::show/$1');
$routes->post('/', 'Organization\DisciplineController::create');
$routes->patch('/', 'Organization\DisciplineController::update');
$routes->delete('/', 'Organization\DisciplineController::delete');
});
// Department
$routes->group('department', function ($routes) {
$routes->get('/', 'Organization\Department::index');
$routes->get('(:num)', 'Organization\Department::show/$1');
$routes->post('/', 'Organization\Department::create');
$routes->patch('/', 'Organization\Department::update');
$routes->delete('/', 'Organization\Department::delete');
$routes->get('/', 'Organization\DepartmentController::index');
$routes->get('(:num)', 'Organization\DepartmentController::show/$1');
$routes->post('/', 'Organization\DepartmentController::create');
$routes->patch('/', 'Organization\DepartmentController::update');
$routes->delete('/', 'Organization\DepartmentController::delete');
});
// Workstation
$routes->group('workstation', function ($routes) {
$routes->get('/', 'Organization\Workstation::index');
$routes->get('(:num)', 'Organization\Workstation::show/$1');
$routes->post('/', 'Organization\Workstation::create');
$routes->patch('/', 'Organization\Workstation::update');
$routes->delete('/', 'Organization\Workstation::delete');
$routes->get('/', 'Organization\WorkstationController::index');
$routes->get('(:num)', 'Organization\WorkstationController::show/$1');
$routes->post('/', 'Organization\WorkstationController::create');
$routes->patch('/', 'Organization\WorkstationController::update');
$routes->delete('/', 'Organization\WorkstationController::delete');
});
});
// Specimen
$routes->group('specimen', function ($routes) {
$routes->group('containerdef', function ($routes) {
$routes->get('/', 'Specimen\ContainerDef::index');
$routes->get('(:num)', 'Specimen\ContainerDef::show/$1');
$routes->post('/', 'Specimen\ContainerDef::create');
$routes->patch('/', 'Specimen\ContainerDef::update');
$routes->get('/', 'Specimen\ContainerDefController::index');
$routes->get('(:num)', 'Specimen\ContainerDefController::show/$1');
$routes->post('/', 'Specimen\ContainerDefController::create');
$routes->patch('/', 'Specimen\ContainerDefController::update');
});
$routes->group('prep', function ($routes) {
$routes->get('/', 'Specimen\Prep::index');
$routes->get('(:num)', 'Specimen\Prep::show/$1');
$routes->post('/', 'Specimen\Prep::create');
$routes->patch('/', 'Specimen\Prep::update');
$routes->get('/', 'Specimen\SpecimenPrepController::index');
$routes->get('(:num)', 'Specimen\SpecimenPrepController::show/$1');
$routes->post('/', 'Specimen\SpecimenPrepController::create');
$routes->patch('/', 'Specimen\SpecimenPrepController::update');
});
$routes->group('status', function ($routes) {
$routes->get('/', 'Specimen\Status::index');
$routes->get('(:num)', 'Specimen\Status::show/$1');
$routes->post('/', 'Specimen\Status::create');
$routes->patch('/', 'Specimen\Status::update');
$routes->get('/', 'Specimen\SpecimenStatusController::index');
$routes->get('(:num)', 'Specimen\SpecimenStatusController::show/$1');
$routes->post('/', 'Specimen\SpecimenStatusController::create');
$routes->patch('/', 'Specimen\SpecimenStatusController::update');
});
$routes->group('collection', function ($routes) {
$routes->get('/', 'Specimen\Collection::index');
$routes->get('(:num)', 'Specimen\Collection::show/$1');
$routes->post('/', 'Specimen\Collection::create');
$routes->patch('/', 'Specimen\Collection::update');
$routes->get('/', 'Specimen\SpecimenCollectionController::index');
$routes->get('(:num)', 'Specimen\SpecimenCollectionController::show/$1');
$routes->post('/', 'Specimen\SpecimenCollectionController::create');
$routes->patch('/', 'Specimen\SpecimenCollectionController::update');
});
$routes->get('/', 'Specimen\Specimen::index');
$routes->get('(:num)', 'Specimen\Specimen::show/$1');
$routes->post('/', 'Specimen\Specimen::create');
$routes->patch('/', 'Specimen\Specimen::update');
$routes->get('/', 'Specimen\SpecimenController::index');
$routes->get('(:num)', 'Specimen\SpecimenController::show/$1');
$routes->post('/', 'Specimen\SpecimenController::create');
$routes->patch('/', 'Specimen\SpecimenController::update');
});
// Tests
$routes->group('tests', function ($routes) {
$routes->get('/', 'Tests::index');
$routes->get('(:any)', 'Tests::show/$1');
$routes->post('/', 'Tests::create');
$routes->patch('/', 'Tests::update');
$routes->get('/', 'TestsController::index');
$routes->get('(:num)', 'TestsController::show/$1');
$routes->post('/', 'TestsController::create');
$routes->patch('/', 'TestsController::update');
});
// Edge API - Integration with tiny-edge
$routes->group('edge', function ($routes) {
$routes->post('results', 'Edge::results');
$routes->get('orders', 'Edge::orders');
$routes->post('orders/(:num)/ack', 'Edge::ack/$1');
$routes->post('status', 'Edge::status');
$routes->post('results', 'EdgeController::results');
$routes->get('orders', 'EdgeController::orders');
$routes->post('orders/(:num)/ack', 'EdgeController::ack/$1');
$routes->post('status', 'EdgeController::status');
});
});

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\AreaGeoModel;
class AreaGeo extends BaseController {
class AreaGeoController extends BaseController {
use ResponseTrait;
protected $model;
@ -44,4 +44,4 @@ class AreaGeo extends BaseController {
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200);
}
}
}

View File

@ -12,23 +12,26 @@ use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use CodeIgniter\Cookie\Cookie;
class Auth extends Controller {
class AuthController extends Controller
{
use ResponseTrait;
// ok
public function __construct() {
public function __construct()
{
$this->db = \Config\Database::connect();
}
// ok
public function checkAuth() {
public function checkAuth()
{
$token = $this->request->getCookie('token');
$key = getenv('JWT_SECRET');
$key = getenv('JWT_SECRET');
// Jika token FE tidak ada langsung kabarkan failed
if (!$token) {
return $this->respond([
'status' => 'failed',
'status' => 'failed',
'message' => 'No token found'
], 401);
}
@ -38,37 +41,37 @@ class Auth extends Controller {
$decodedPayload = JWT::decode($token, new Key($key, 'HS256'));
return $this->respond([
'status' => 'success',
'status' => 'success',
'message' => 'Authenticated',
'data' => $decodedPayload
'data' => $decodedPayload
], 200);
} catch (ExpiredException $e) {
return $this->respond([
'status' => 'failed',
'status' => 'failed',
'message' => 'Token expired',
'data' => []
'data' => []
], 401);
} catch (SignatureInvalidException $e) {
return $this->respond([
'status' => 'failed',
'status' => 'failed',
'message' => 'Invalid token signature',
'data' => []
'data' => []
], 401);
} catch (BeforeValidException $e) {
return $this->respond([
'status' => 'failed',
'status' => 'failed',
'message' => 'Token not valid yet',
'data' => []
'data' => []
], 401);
} catch (\Exception $e) {
return $this->respond([
'status' => 'failed',
'status' => 'failed',
'message' => 'Invalid token: ' . $e->getMessage(),
'data' => []
'data' => []
], 401);
}
}
@ -122,7 +125,7 @@ class Auth extends Controller {
// // 'httponly' => true, // dipakai agar cookie berikut tidak dapat diakses oleh javascript
// // 'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX
// ]);
// // Response tanpa token di body
// return $this->respond([
@ -131,7 +134,8 @@ class Auth extends Controller {
// 'message' => 'Login successful'
// ], 200);
// }
public function login() {
public function login()
{
// Ambil dari JSON Form dan Key .env
$username = $this->request->getVar('username');
@ -146,7 +150,9 @@ class Auth extends Controller {
$query = $this->db->query($sql);
$row = $query->getResultArray();
if (!$row) { return $this->fail('User not found.', 401); }
if (!$row) {
return $this->fail('User not found.', 401);
}
$row = $row[0];
if (!password_verify($password, $row['password'])) {
return $this->fail('Invalid password.', 401);
@ -155,10 +161,10 @@ class Auth extends Controller {
// Buat JWT payload
$exp = time() + 864000;
$payload = [
'userid' => $row['id'],
'userid' => $row['id'],
'roleid' => $row['role_id'],
'username' => $row['username'],
'exp' => $exp
'exp' => $exp
];
try {
@ -170,18 +176,18 @@ class Auth extends Controller {
// Kirim Respon ke HttpOnly yg akan disimpan di browser dan tidak akan dapat diakses oleh siapapun
$this->response->setCookie([
'name' => 'token', // nama token
'value' => $jwt, // value dari jwt yg sudah di hash
'expire' => 864000, // 10 hari
'path' => '/', // valid untuk semua path
'secure' => true, // set true kalau sudah HTTPS
'name' => 'token', // nama token
'value' => $jwt, // value dari jwt yg sudah di hash
'expire' => 864000, // 10 hari
'path' => '/', // valid untuk semua path
'secure' => true, // set true kalau sudah HTTPS
'httponly' => true, // dipakai agar cookie berikut tidak dapat diakses oleh javascript
'samesite' => Cookie::SAMESITE_NONE
'samesite' => Cookie::SAMESITE_NONE
]);
// Response tanpa token di body
return $this->respond([
'status' => 'success',
'status' => 'success',
'code' => 200,
'message' => 'Login successful'
], 200);
@ -199,33 +205,35 @@ class Auth extends Controller {
// 'secure' => $isSecure,
// 'httponly' => true,
// 'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX
// ])->setJSON([
// 'status' => 'success',
// 'code' => 200,
// 'message' => 'Logout successful'
// ], 200);
// }
public function logout() {
public function logout()
{
// Definisikan ini pada cookies browser, harus sama dengan cookies login
return $this->response->setCookie([
'name' => 'token',
'value' => '',
'expire' => time() - 3600,
'path' => '/',
'secure' => true,
'name' => 'token',
'value' => '',
'expire' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => Cookie::SAMESITE_NONE
])->setJSON([
'status' => 'success',
'code' => 200,
'message' => 'Logout successful'
], 200);
'status' => 'success',
'code' => 200,
'message' => 'Logout successful'
], 200);
}
// ok
public function register() {
public function register()
{
$username = strtolower($this->request->getJsonVar('username'));
$password = $this->request->getJsonVar('password');
@ -233,7 +241,7 @@ class Auth extends Controller {
// Validasi Awal Dari BE
if (empty($username) || empty($password)) {
return $this->respond([
'status' => 'failed',
'status' => 'failed',
'code' => 400,
'message' => 'Username and password are required'
], 400); // Gunakan 400 Bad Request
@ -242,11 +250,11 @@ class Auth extends Controller {
// Cek Duplikasi Username
$exists = $this->db->query("SELECT id FROM users WHERE username = ?", [$username])->getRow();
if ($exists) {
return $this->respond(['status' => 'failed', 'code'=>409,'message' => 'Username already exists'], 409);
return $this->respond(['status' => 'failed', 'code' => 409, 'message' => 'Username already exists'], 409);
}
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
// Mulai transaksi Insert
$this->db->transStart();
$this->db->query(
@ -258,8 +266,8 @@ class Auth extends Controller {
// Cek status transaksi
if ($this->db->transStatus() === false) {
return $this->respond([
'status' => 'error',
'code' => 500,
'status' => 'error',
'code' => 500,
'message' => 'Failed to create user. Please try again later.'
], 500);
}
@ -269,7 +277,7 @@ class Auth extends Controller {
'status' => 'success',
'code' => 201,
'message' => 'User ' . $username . ' successfully created.'
], 201);
], 201);
}
@ -294,19 +302,20 @@ class Auth extends Controller {
// return $this->respond($response);
// }
public function coba() {
public function coba()
{
$token = $this->request->getCookie('token');
$key = getenv('JWT_SECRET');
$key = getenv('JWT_SECRET');
// Decode Token dengan Key yg ada di .env
$decodedPayload = JWT::decode($token, new Key($key, 'HS256'));
return $this->respond([
'status' => 'success',
'status' => 'success',
'message' => 'Authenticated',
'data' => $decodedPayload
'data' => $decodedPayload
], 200);
}
}

View File

@ -18,7 +18,7 @@ use CodeIgniter\Cookie\Cookie;
* Handles authentication for V2 UI
* Separate from the main Auth controller to avoid conflicts
*/
class AuthV2 extends Controller
class AuthV2Controller extends Controller
{
use ResponseTrait;

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Contact\ContactModel;
class Contact extends BaseController {
class ContactController extends BaseController {
use ResponseTrait;
protected $db;
@ -76,4 +76,4 @@ class Contact extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}
}

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Contact\MedicalSpecialtyModel;
class MedicalSpecialty extends BaseController {
class MedicalSpecialtyController extends BaseController {
use ResponseTrait;
protected $db;
@ -61,4 +61,4 @@ class MedicalSpecialty extends BaseController {
}
}
}
}

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Contact\OccupationModel;
class Occupation extends BaseController {
class OccupationController extends BaseController {
use ResponseTrait;
protected $db;
@ -61,4 +61,4 @@ class Occupation extends BaseController {
}
}
}
}

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\CounterModel;
class Counter extends BaseController {
class CounterController extends BaseController {
use ResponseTrait;
protected $model;
@ -62,4 +62,4 @@ class Counter extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}
}

View File

@ -12,23 +12,25 @@ use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use CodeIgniter\Cookie\Cookie;
class Sample extends Controller {
class DashboardController extends Controller
{
use ResponseTrait;
public function index() {
public function index()
{
$token = $this->request->getCookie('token');
$key = getenv('JWT_SECRET');
$key = getenv('JWT_SECRET');
// Decode Token dengan Key yg ada di .env
$decodedPayload = JWT::decode($token, new Key($key, 'HS256'));
return $this->respond([
'status' => 'success',
'code' => 200,
'status' => 'success',
'code' => 200,
'message' => 'Authenticated',
'data' => $decodedPayload
'data' => $decodedPayload
], 200);
}
}

View File

@ -5,13 +5,15 @@ namespace App\Controllers;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
class Edge extends Controller {
class EdgeController extends Controller
{
use ResponseTrait;
protected $db;
protected $edgeResModel;
public function __construct() {
public function __construct()
{
$this->db = \Config\Database::connect();
$this->edgeResModel = new \App\Models\EdgeResModel();
}
@ -20,7 +22,8 @@ class Edge extends Controller {
* POST /api/edge/results
* Receive results from tiny-edge
*/
public function results() {
public function results()
{
try {
$input = $this->request->getJSON(true);
@ -70,7 +73,8 @@ class Edge extends Controller {
* GET /api/edge/orders
* Return pending orders for an instrument
*/
public function orders() {
public function orders()
{
try {
$instrumentId = $this->request->getGet('instrument');
@ -95,7 +99,8 @@ class Edge extends Controller {
* POST /api/edge/orders/:id/ack
* Acknowledge order delivery
*/
public function ack($orderId = null) {
public function ack($orderId = null)
{
try {
if (!$orderId) {
return $this->failValidationErrors('Order ID is required');
@ -129,7 +134,8 @@ class Edge extends Controller {
* POST /api/edge/status
* Log instrument status
*/
public function status() {
public function status()
{
try {
$input = $this->request->getJSON(true);

View File

@ -12,7 +12,7 @@ use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use CodeIgniter\Cookie\Cookie;
class Home extends Controller {
class HomeController extends Controller {
use ResponseTrait;
public function index() {

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Location\LocationModel;
class Location extends BaseController {
class LocationController extends BaseController {
use ResponseTrait;
protected $model;
@ -72,4 +72,4 @@ class Location extends BaseController {
}
}
}
}

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
use CodeIgniter\Database\RawSql;
class OrderTest extends Controller {
class OrderTestController extends Controller {
use ResponseTrait;
public function __construct() {
@ -214,4 +214,4 @@ class OrderTest extends Controller {
return $data;
}
}
}

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\AccountModel;
class Account extends BaseController {
class AccountController extends BaseController {
use ResponseTrait;
protected $db;
@ -76,4 +76,4 @@ class Account extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}
}

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\DepartmentModel;
class Department extends BaseController {
class DepartmentController extends BaseController {
use ResponseTrait;
protected $db;
@ -72,4 +72,4 @@ class Department extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}
}

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\DisciplineModel;
class Discipline extends BaseController {
class DisciplineController extends BaseController {
use ResponseTrait;
protected $db;
@ -79,4 +79,4 @@ class Discipline extends BaseController {
}
*/
}
}
}

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\SiteModel;
class Site extends BaseController {
class SiteController extends BaseController {
use ResponseTrait;
protected $db;
@ -75,4 +75,4 @@ class Site extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}
}

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\WorkstationModel;
class Workstation extends BaseController {
class WorkstationController extends BaseController {
use ResponseTrait;
protected $db;
@ -73,4 +73,4 @@ class Workstation extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}
}

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\PatVisit\PatVisitModel;
use App\Models\PatVisit\PatVisitADTModel;
class PatVisit extends BaseController {
class PatVisitController extends BaseController {
use ResponseTrait;
protected $model;
@ -82,4 +82,4 @@ class PatVisit extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}
}

View File

@ -6,7 +6,7 @@ use CodeIgniter\Controller;
use App\Models\Patient\PatientModel;
class Patient extends Controller {
class PatientController extends Controller {
use ResponseTrait;
protected $db;
@ -214,4 +214,4 @@ class Patient extends Controller {
return $this->failServerError('Something went wrong.'.$e->getMessage());
}
}
}
}

View File

@ -12,7 +12,7 @@ use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use CodeIgniter\Cookie\Cookie;
class Dashboard extends Controller {
class ResultController extends Controller {
use ResponseTrait;
public function index() {

View File

@ -12,7 +12,7 @@ use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use CodeIgniter\Cookie\Cookie;
class Result extends Controller {
class SampleController extends Controller {
use ResponseTrait;
public function index() {

View File

@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Specimen\ContainerDefModel;
class ContainerDef extends BaseController {
class ContainerDefController extends BaseController {
use ResponseTrait;
protected $db;
@ -69,4 +69,4 @@ class ContainerDef extends BaseController {
}
}
}
}

View File

@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Specimen\SpecimenCollectionModel;
class SpecimenCollection extends BaseController {
class SpecimenCollectionController extends BaseController {
use ResponseTrait;
protected $db;
@ -62,4 +62,4 @@ class SpecimenCollection extends BaseController {
}
}
}
}

View File

@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Specimen\SpecimenModel;
class Specimen extends BaseController {
class SpecimenController extends BaseController {
use ResponseTrait;
protected $db;
@ -62,4 +62,4 @@ class Specimen extends BaseController {
}
}
}
}

View File

@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Specimen\SpecimenPrepModel;
class SpecimenPrep extends BaseController {
class SpecimenPrepController extends BaseController {
use ResponseTrait;
protected $db;
@ -62,4 +62,4 @@ class SpecimenPrep extends BaseController {
}
}
}
}

View File

@ -62,4 +62,4 @@ class ContainerDef extends BaseController {
}
}
}
}

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Test\TestMapModel;
class TestMap extends BaseController {
class TestMapController extends BaseController {
use ResponseTrait;
protected $db;
@ -53,4 +53,4 @@ class TestMap extends BaseController {
}
}
}
}

View File

@ -1,523 +0,0 @@
<?php
namespace App\Controllers;
use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
class Tests extends BaseController {
use ResponseTrait;
protected $db;
protected $rules;
protected $model;
protected $modelCal;
protected $modelTech;
protected $modelGrp;
protected $modelMap;
protected $modelValueSet;
public function __construct() {
$this->db = \Config\Database::connect();
$this->model = new \App\Models\Test\TestDefSiteModel;
$this->modelCal = new \App\Models\Test\TestDefCalModel;
$this->modelTech = new \App\Models\Test\TestDefTechModel;
$this->modelGrp = new \App\Models\Test\TestDefGrpModel;
$this->modelMap = new \App\Models\Test\TestMapModel;
$this->modelValueSet = new \App\Models\ValueSet\ValueSetModel;
// Validation rules for main test definition
$this->rules = [
'TestSiteCode' => 'required|min_length[3]|max_length[6]',
'TestSiteName' => 'required',
'TestType' => 'required',
'SiteID' => 'required'
];
}
/**
* GET /v1/tests
* GET /v1/tests/site
* List all tests with optional filtering
*/
public function index() {
$siteId = $this->request->getGet('SiteID');
$testType = $this->request->getGet('TestType');
$visibleScr = $this->request->getGet('VisibleScr');
$visibleRpt = $this->request->getGet('VisibleRpt');
$keyword = $this->request->getGet('TestSiteName');
$builder = $this->db->table('testdefsite')
->select("testdefsite.TestSiteID, testdefsite.TestSiteCode, testdefsite.TestSiteName, testdefsite.TestType,
testdefsite.SeqScr, testdefsite.SeqRpt, testdefsite.VisibleScr, testdefsite.VisibleRpt,
testdefsite.CountStat, testdefsite.StartDate, testdefsite.EndDate,
valueset.VValue as TypeCode, valueset.VDesc as TypeName")
->join("valueset", "valueset.VID=testdefsite.TestType", "left")
->where('testdefsite.EndDate IS NULL');
if ($siteId) {
$builder->where('testdefsite.SiteID', $siteId);
}
if ($testType) {
$builder->where('testdefsite.TestType', $testType);
}
if ($visibleScr !== null) {
$builder->where('testdefsite.VisibleScr', $visibleScr);
}
if ($visibleRpt !== null) {
$builder->where('testdefsite.VisibleRpt', $visibleRpt);
}
if ($keyword) {
$builder->like('testdefsite.TestSiteName', $keyword);
}
$rows = $builder->orderBy('testdefsite.SeqScr', 'ASC')->get()->getResultArray();
if (empty($rows)) {
return $this->respond([ 'status' => 'success', 'message' => "No data.", 'data' => [] ], 200);
}
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200);
}
/**
* GET /v1/tests/{id}
* GET /v1/tests/site/{id}
* Get single test by ID with all related details
*/
public function show($id = null) {
if (!$id) return $this->failValidationErrors('TestSiteID is required');
$row = $this->model->select("testdefsite.*, valueset.VValue as TypeCode, valueset.VDesc as TypeName")
->join("valueset", "valueset.VID=testdefsite.TestType", "left")
->where("testdefsite.TestSiteID", $id)
->find($id);
if (!$row) {
return $this->respond([ 'status' => 'success', 'message' => "No data.", 'data' => null ], 200);
}
// Load related details based on TestType
$typeCode = $row['TypeCode'] ?? '';
if ($typeCode === 'CALC') {
// Load calculation details
$row['testdefcal'] = $this->db->table('testdefcal')
->select('testdefcal.*, d.DisciplineName, dept.DepartmentName')
->join('discipline d', 'd.DisciplineID=testdefcal.DisciplineID', 'left')
->join('department dept', 'dept.DepartmentID=testdefcal.DepartmentID', 'left')
->where('testdefcal.TestSiteID', $id)
->where('testdefcal.EndDate IS NULL')
->get()->getResultArray();
// Load test mappings
$row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll();
} elseif ($typeCode === 'GROUP') {
// Load group members with test details
$row['testdefgrp'] = $this->db->table('testdefgrp')
->select('testdefgrp.*, t.TestSiteCode, t.TestSiteName, t.TestType, vs.VValue as MemberTypeCode')
->join('testdefsite t', 't.TestSiteID=testdefgrp.Member', 'left')
->join('valueset vs', 'vs.VID=t.TestType', 'left')
->where('testdefgrp.TestSiteID', $id)
->where('testdefgrp.EndDate IS NULL')
->orderBy('testdefgrp.TestGrpID', 'ASC')
->get()->getResultArray();
// Load test mappings
$row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll();
} elseif ($typeCode === 'TITLE') {
// Load test mappings only for TITLE type
$row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll();
} else {
// TEST or PARAM - load technical details
$row['testdeftech'] = $this->db->table('testdeftech')
->select('testdeftech.*, d.DisciplineName, dept.DepartmentName')
->join('discipline d', 'd.DisciplineID=testdeftech.DisciplineID', 'left')
->join('department dept', 'dept.DepartmentID=testdeftech.DepartmentID', 'left')
->where('testdeftech.TestSiteID', $id)
->where('testdeftech.EndDate IS NULL')
->get()->getResultArray();
// Load test mappings
$row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll();
}
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row ], 200);
}
/**
* POST /v1/tests
* POST /v1/tests/site
* Create new test definition
*/
public function create() {
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
$this->db->transStart();
try {
// 1. Insert into Main Table (testdefsite)
$testSiteData = [
'SiteID' => $input['SiteID'],
'TestSiteCode' => $input['TestSiteCode'],
'TestSiteName' => $input['TestSiteName'],
'TestType' => $input['TestType'],
'Description' => $input['Description'] ?? null,
'SeqScr' => $input['SeqScr'] ?? 0,
'SeqRpt' => $input['SeqRpt'] ?? 0,
'IndentLeft' => $input['IndentLeft'] ?? 0,
'FontStyle' => $input['FontStyle'] ?? null,
'VisibleScr' => $input['VisibleScr'] ?? 1,
'VisibleRpt' => $input['VisibleRpt'] ?? 1,
'CountStat' => $input['CountStat'] ?? 1,
'StartDate' => $input['StartDate'] ?? date('Y-m-d H:i:s')
];
$id = $this->model->insert($testSiteData);
if (!$id) {
throw new \Exception("Failed to insert main test definition");
}
// 2. Handle Details based on TestType
$this->handleDetails($id, $input, 'insert');
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->failServerError('Transaction failed');
}
return $this->respondCreated([
'status' => 'created',
'message' => "Test created successfully",
'data' => ['TestSiteId' => $id]
]);
} catch (\Exception $e) {
$this->db->transRollback();
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* PUT/PATCH /v1/tests/{id}
* PUT/PATCH /v1/tests/site/{id}
* Update existing test definition
*/
public function update($id = null) {
$input = $this->request->getJSON(true);
// Determine ID
if (!$id && isset($input["TestSiteID"])) { $id = $input["TestSiteID"]; }
if (!$id) { return $this->failValidationErrors('TestSiteID is required.'); }
// Verify record exists
$existing = $this->model->find($id);
if (!$existing) {
return $this->failNotFound('Test not found');
}
$this->db->transStart();
try {
// 1. Update Main Table
$testSiteData = [];
$allowedUpdateFields = ['TestSiteCode', 'TestSiteName', 'TestType', 'Description',
'SeqScr', 'SeqRpt', 'IndentLeft', 'FontStyle',
'VisibleScr', 'VisibleRpt', 'CountStat', 'StartDate'];
foreach ($allowedUpdateFields as $field) {
if (isset($input[$field])) {
$testSiteData[$field] = $input[$field];
}
}
if (!empty($testSiteData)) {
$this->model->update($id, $testSiteData);
}
// 2. Handle Details
$this->handleDetails($id, $input, 'update');
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->failServerError('Transaction failed');
}
return $this->respond([
'status' => 'success',
'message' => "Test updated successfully",
'data' => ['TestSiteId' => $id]
]);
} catch (\Exception $e) {
$this->db->transRollback();
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* DELETE /v1/tests/{id}
* DELETE /v1/tests/site/{id}
* Soft delete test by setting EndDate
*/
public function delete($id = null) {
$input = $this->request->getJSON(true);
// Determine ID
if (!$id && isset($input["TestSiteID"])) { $id = $input["TestSiteID"]; }
if (!$id) { return $this->failValidationErrors('TestSiteID is required.'); }
// Verify record exists
$existing = $this->model->find($id);
if (!$existing) {
return $this->failNotFound('Test not found');
}
// Check if already disabled
if (!empty($existing['EndDate'])) {
return $this->failValidationErrors('Test is already disabled');
}
$this->db->transStart();
try {
$now = date('Y-m-d H:i:s');
// 1. Soft delete main record
$this->model->update($id, ['EndDate' => $now]);
// 2. Get TestType to handle related records
$testType = $existing['TestType'];
$vs = $this->modelValueSet->find($testType);
$typeCode = $vs['VValue'] ?? '';
// 3. Soft delete related records based on TestType
if ($typeCode === 'CALC') {
$this->db->table('testdefcal')
->where('TestSiteID', $id)
->update(['EndDate' => $now]);
} elseif ($typeCode === 'GROUP') {
$this->db->table('testdefgrp')
->where('TestSiteID', $id)
->update(['EndDate' => $now]);
} elseif (in_array($typeCode, ['TEST', 'PARAM'])) {
$this->db->table('testdeftech')
->where('TestSiteID', $id)
->update(['EndDate' => $now]);
}
// 4. Soft delete test mappings
$this->db->table('testmap')
->where('TestSiteID', $id)
->update(['EndDate' => $now]);
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->failServerError('Transaction failed');
}
return $this->respond([
'status' => 'success',
'message' => "Test disabled successfully",
'data' => ['TestSiteId' => $id, 'EndDate' => $now]
]);
} catch (\Exception $e) {
$this->db->transRollback();
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* Helper to handle inserting/updating sub-tables based on TestType
*/
private function handleDetails($testSiteID, $input, $action) {
$testTypeID = $input['TestType'] ?? null;
// If update and TestType not in payload, fetch from DB
if (!$testTypeID && $action === 'update') {
$existing = $this->model->find($testSiteID);
$testTypeID = $existing['TestType'] ?? null;
}
if (!$testTypeID) return;
// Get Type Code (TEST, PARAM, CALC, GROUP, TITLE)
$vs = $this->modelValueSet->find($testTypeID);
$typeCode = $vs['VValue'] ?? '';
// Get details data from input
$details = $input['details'] ?? $input;
$details['TestSiteID'] = $testSiteID;
$details['SiteID'] = $input['SiteID'] ?? 1;
switch ($typeCode) {
case 'CALC':
$this->saveCalcDetails($testSiteID, $details, $action);
break;
case 'GROUP':
$this->saveGroupDetails($testSiteID, $details, $input, $action);
break;
case 'TITLE':
// TITLE type only has testdefsite, no additional details needed
// But we should save test mappings if provided
if (isset($input['testmap']) && is_array($input['testmap'])) {
$this->saveTestMap($testSiteID, $input['testmap'], $action);
}
break;
case 'TEST':
case 'PARAM':
default:
$this->saveTechDetails($testSiteID, $details, $action, $typeCode);
break;
}
// Save test mappings for TEST and CALC types as well
if (in_array($typeCode, ['TEST', 'CALC']) && isset($input['testmap']) && is_array($input['testmap'])) {
$this->saveTestMap($testSiteID, $input['testmap'], $action);
}
}
/**
* Save technical details for TEST and PARAM types
*/
private function saveTechDetails($testSiteID, $data, $action, $typeCode) {
$techData = [
'TestSiteID' => $testSiteID,
'DisciplineID' => $data['DisciplineID'] ?? null,
'DepartmentID' => $data['DepartmentID'] ?? null,
'ResultType' => $data['ResultType'] ?? null,
'RefType' => $data['RefType'] ?? null,
'VSet' => $data['VSet'] ?? null,
'ReqQty' => $data['ReqQty'] ?? null,
'ReqQtyUnit' => $data['ReqQtyUnit'] ?? null,
'Unit1' => $data['Unit1'] ?? null,
'Factor' => $data['Factor'] ?? null,
'Unit2' => $data['Unit2'] ?? null,
'Decimal' => $data['Decimal'] ?? 2,
'CollReq' => $data['CollReq'] ?? null,
'Method' => $data['Method'] ?? null,
'ExpectedTAT' => $data['ExpectedTAT'] ?? null
];
if ($action === 'update') {
$exists = $this->db->table('testdeftech')
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->get()->getRowArray();
if ($exists) {
$this->modelTech->update($exists['TestTechID'], $techData);
} else {
$this->modelTech->insert($techData);
}
} else {
$this->modelTech->insert($techData);
}
}
/**
* Save calculation details for CALC type
*/
private function saveCalcDetails($testSiteID, $data, $action) {
$calcData = [
'TestSiteID' => $testSiteID,
'DisciplineID' => $data['DisciplineID'] ?? null,
'DepartmentID' => $data['DepartmentID'] ?? null,
'FormulaInput' => $data['FormulaInput'] ?? null,
'FormulaCode' => $data['FormulaCode'] ?? $data['Formula'] ?? null,
'RefType' => $data['RefType'] ?? 'NMRC',
'Unit1' => $data['Unit1'] ?? $data['ResultUnit'] ?? null,
'Factor' => $data['Factor'] ?? null,
'Unit2' => $data['Unit2'] ?? null,
'Decimal' => $data['Decimal'] ?? 2,
'Method' => $data['Method'] ?? null
];
if ($action === 'update') {
$exists = $this->db->table('testdefcal')
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->get()->getRowArray();
if ($exists) {
$this->modelCal->update($exists['TestCalID'], $calcData);
} else {
$this->modelCal->insert($calcData);
}
} else {
$this->modelCal->insert($calcData);
}
}
/**
* Save group details for GROUP type
*/
private function saveGroupDetails($testSiteID, $data, $input, $action) {
if ($action === 'update') {
// Soft delete existing members
$this->db->table('testdefgrp')
->where('TestSiteID', $testSiteID)
->update(['EndDate' => date('Y-m-d H:i:s')]);
}
// Get members from details or input
$members = $data['members'] ?? ($input['Members'] ?? []);
if (is_array($members)) {
foreach ($members as $m) {
$memberID = is_array($m) ? ($m['Member'] ?? ($m['TestSiteID'] ?? null)) : $m;
if ($memberID) {
$this->modelGrp->insert([
'TestSiteID' => $testSiteID,
'Member' => $memberID
]);
}
}
}
}
/**
* Save test mappings
*/
private function saveTestMap($testSiteID, $mappings, $action) {
if ($action === 'update') {
// Soft delete existing mappings
$this->db->table('testmap')
->where('TestSiteID', $testSiteID)
->update(['EndDate' => date('Y-m-d H:i:s')]);
}
if (is_array($mappings)) {
foreach ($mappings as $map) {
$mapData = [
'TestSiteID' => $testSiteID,
'HostType' => $map['HostType'] ?? null,
'HostID' => $map['HostID'] ?? null,
'HostDataSource' => $map['HostDataSource'] ?? null,
'HostTestCode' => $map['HostTestCode'] ?? null,
'HostTestName' => $map['HostTestName'] ?? null,
'ClientType' => $map['ClientType'] ?? null,
'ClientID' => $map['ClientID'] ?? null,
'ClientDataSource' => $map['ClientDataSource'] ?? null,
'ConDefID' => $map['ConDefID'] ?? null,
'ClientTestCode' => $map['ClientTestCode'] ?? null,
'ClientTestName' => $map['ClientTestName'] ?? null
];
$this->modelMap->insert($mapData);
}
}
}
}

View File

@ -0,0 +1,741 @@
<?php
namespace App\Controllers;
use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
class TestsController extends BaseController
{
use ResponseTrait;
protected $db;
protected $rules;
protected $model;
protected $modelCal;
protected $modelTech;
protected $modelGrp;
protected $modelMap;
protected $modelValueSet;
protected $modelRefNum;
protected $modelRefTxt;
// Valueset ID constants
const VALUESET_REF_TYPE = 44; // testdeftech.RefType
const VALUESET_RANGE_TYPE = 45; // refnum.RangeType
const VALUESET_NUM_REF_TYPE = 46; // refnum.NumRefType
const VALUESET_TXT_REF_TYPE = 47; // reftxt.TxtRefType
const VALUESET_SEX = 3; // Sex values
const VALUESET_MATH_SIGN = 41; // LowSign, HighSign
public function __construct()
{
$this->db = \Config\Database::connect();
$this->model = new \App\Models\Test\TestDefSiteModel;
$this->modelCal = new \App\Models\Test\TestDefCalModel;
$this->modelTech = new \App\Models\Test\TestDefTechModel;
$this->modelGrp = new \App\Models\Test\TestDefGrpModel;
$this->modelMap = new \App\Models\Test\TestMapModel;
$this->modelValueSet = new \App\Models\ValueSet\ValueSetModel;
$this->modelRefNum = new \App\Models\RefRange\RefNumModel;
$this->modelRefTxt = new \App\Models\RefRange\RefTxtModel;
// Validation rules for main test definition
$this->rules = [
'TestSiteCode' => 'required|min_length[3]|max_length[6]',
'TestSiteName' => 'required',
'TestType' => 'required',
'SiteID' => 'required'
];
}
/**
* GET /v1/tests
* GET /v1/tests/site
* List all tests with optional filtering
*/
public function index()
{
$siteId = $this->request->getGet('SiteID');
$testType = $this->request->getGet('TestType');
$visibleScr = $this->request->getGet('VisibleScr');
$visibleRpt = $this->request->getGet('VisibleRpt');
$keyword = $this->request->getGet('TestSiteName');
$builder = $this->db->table('testdefsite')
->select("testdefsite.TestSiteID, testdefsite.TestSiteCode, testdefsite.TestSiteName, testdefsite.TestType,
testdefsite.SeqScr, testdefsite.SeqRpt, testdefsite.VisibleScr, testdefsite.VisibleRpt,
testdefsite.CountStat, testdefsite.StartDate, testdefsite.EndDate,
valueset.VValue as TypeCode, valueset.VDesc as TypeName")
->join("valueset", "valueset.VID=testdefsite.TestType", "left")
->where('testdefsite.EndDate IS NULL');
if ($siteId) {
$builder->where('testdefsite.SiteID', $siteId);
}
if ($testType) {
$builder->where('testdefsite.TestType', $testType);
}
if ($visibleScr !== null) {
$builder->where('testdefsite.VisibleScr', $visibleScr);
}
if ($visibleRpt !== null) {
$builder->where('testdefsite.VisibleRpt', $visibleRpt);
}
if ($keyword) {
$builder->like('testdefsite.TestSiteName', $keyword);
}
$rows = $builder->orderBy('testdefsite.SeqScr', 'ASC')->get()->getResultArray();
if (empty($rows)) {
return $this->respond(['status' => 'success', 'message' => "No data.", 'data' => []], 200);
}
return $this->respond(['status' => 'success', 'message' => "Data fetched successfully", 'data' => $rows], 200);
}
/**
* GET /v1/tests/{id}
* GET /v1/tests/site/{id}
* Get single test by ID with all related details
*/
public function show($id = null)
{
if (!$id)
return $this->failValidationErrors('TestSiteID is required');
$row = $this->model->select("testdefsite.*, valueset.VValue as TypeCode, valueset.VDesc as TypeName")
->join("valueset", "valueset.VID=testdefsite.TestType", "left")
->where("testdefsite.TestSiteID", $id)
->find($id);
if (!$row) {
return $this->respond(['status' => 'success', 'message' => "No data.", 'data' => null], 200);
}
// Load related details based on TestType
$typeCode = $row['TypeCode'] ?? '';
if ($typeCode === 'CALC') {
// Load calculation details
$row['testdefcal'] = $this->db->table('testdefcal')
->select('testdefcal.*, d.DisciplineName, dept.DepartmentName')
->join('discipline d', 'd.DisciplineID=testdefcal.DisciplineID', 'left')
->join('department dept', 'dept.DepartmentID=testdefcal.DepartmentID', 'left')
->where('testdefcal.TestSiteID', $id)
->where('testdefcal.EndDate IS NULL')
->get()->getResultArray();
// Load test mappings
$row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll();
} elseif ($typeCode === 'GROUP') {
// Load group members with test details
$row['testdefgrp'] = $this->db->table('testdefgrp')
->select('testdefgrp.*, t.TestSiteCode, t.TestSiteName, t.TestType, vs.VValue as MemberTypeCode')
->join('testdefsite t', 't.TestSiteID=testdefgrp.Member', 'left')
->join('valueset vs', 'vs.VID=t.TestType', 'left')
->where('testdefgrp.TestSiteID', $id)
->where('testdefgrp.EndDate IS NULL')
->orderBy('testdefgrp.TestGrpID', 'ASC')
->get()->getResultArray();
// Load test mappings
$row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll();
} elseif ($typeCode === 'TITLE') {
// Load test mappings only for TITLE type
$row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll();
} else {
// TEST or PARAM - load technical details
$row['testdeftech'] = $this->db->table('testdeftech')
->select('testdeftech.*, d.DisciplineName, dept.DepartmentName')
->join('discipline d', 'd.DisciplineID=testdeftech.DisciplineID', 'left')
->join('department dept', 'dept.DepartmentID=testdeftech.DepartmentID', 'left')
->where('testdeftech.TestSiteID', $id)
->where('testdeftech.EndDate IS NULL')
->get()->getResultArray();
// Load test mappings
$row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll();
// Load refnum/reftxt based on RefType
if (!empty($row['testdeftech'])) {
$techData = $row['testdeftech'][0];
$refType = (int) $techData['RefType'];
// Load refnum for NMRC type (RefType = 1)
if ($refType === 1) {
$refnumData = $this->modelRefNum
->where('TestSiteID', $id)
->where('EndDate IS NULL')
->orderBy('Display', 'ASC')
->findAll();
// Add VValue for display
$row['refnum'] = array_map(function ($r) {
return [
'RefNumID' => $r['RefNumID'],
'NumRefType' => (int) $r['NumRefType'],
'NumRefTypeVValue' => $this->getVValue(46, $r['NumRefType']),
'RangeType' => (int) $r['RangeType'],
'RangeTypeVValue' => $this->getVValue(45, $r['RangeType']),
'Sex' => (int) $r['Sex'],
'SexVValue' => $this->getVValue(3, $r['Sex']),
'AgeStart' => (int) $r['AgeStart'],
'AgeEnd' => (int) $r['AgeEnd'],
'LowSign' => $r['LowSign'] !== null ? (int) $r['LowSign'] : null,
'LowSignVValue' => $this->getVValue(41, $r['LowSign']),
'Low' => $r['Low'] !== null ? (int) $r['Low'] : null,
'HighSign' => $r['HighSign'] !== null ? (int) $r['HighSign'] : null,
'HighSignVValue' => $this->getVValue(41, $r['HighSign']),
'High' => $r['High'] !== null ? (int) $r['High'] : null,
'Flag' => $r['Flag']
];
}, $refnumData ?? []);
$row['numRefTypeOptions'] = $this->getValuesetOptions(46);
$row['rangeTypeOptions'] = $this->getValuesetOptions(45);
}
// Load reftxt for TEXT type (RefType = 2)
if ($refType === 2) {
$reftxtData = $this->modelRefTxt
->where('TestSiteID', $id)
->where('EndDate IS NULL')
->orderBy('RefTxtID', 'ASC')
->findAll();
$row['reftxt'] = array_map(function ($r) {
return [
'RefTxtID' => $r['RefTxtID'],
'TxtRefType' => (int) $r['TxtRefType'],
'TxtRefTypeVValue' => $this->getVValue(47, $r['TxtRefType']),
'Sex' => (int) $r['Sex'],
'SexVValue' => $this->getVValue(3, $r['Sex']),
'AgeStart' => (int) $r['AgeStart'],
'AgeEnd' => (int) $r['AgeEnd'],
'RefTxt' => $r['RefTxt'],
'Flag' => $r['Flag']
];
}, $reftxtData ?? []);
$row['txtRefTypeOptions'] = $this->getValuesetOptions(47);
}
}
}
// Include valueset options for dropdowns
$row['refTypeOptions'] = $this->getValuesetOptions(self::VALUESET_REF_TYPE);
$row['sexOptions'] = $this->getValuesetOptions(self::VALUESET_SEX);
$row['mathSignOptions'] = $this->getValuesetOptions(self::VALUESET_MATH_SIGN);
return $this->respond(['status' => 'success', 'message' => "Data fetched successfully", 'data' => $row], 200);
}
/**
* POST /v1/tests
* POST /v1/tests/site
* Create new test definition
*/
public function create()
{
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
$this->db->transStart();
try {
// 1. Insert into Main Table (testdefsite)
$testSiteData = [
'SiteID' => $input['SiteID'],
'TestSiteCode' => $input['TestSiteCode'],
'TestSiteName' => $input['TestSiteName'],
'TestType' => $input['TestType'],
'Description' => $input['Description'] ?? null,
'SeqScr' => $input['SeqScr'] ?? 0,
'SeqRpt' => $input['SeqRpt'] ?? 0,
'IndentLeft' => $input['IndentLeft'] ?? 0,
'FontStyle' => $input['FontStyle'] ?? null,
'VisibleScr' => $input['VisibleScr'] ?? 1,
'VisibleRpt' => $input['VisibleRpt'] ?? 1,
'CountStat' => $input['CountStat'] ?? 1,
'StartDate' => $input['StartDate'] ?? date('Y-m-d H:i:s')
];
$id = $this->model->insert($testSiteData);
if (!$id) {
throw new \Exception("Failed to insert main test definition");
}
// 2. Handle Details based on TestType
$this->handleDetails($id, $input, 'insert');
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->failServerError('Transaction failed');
}
return $this->respondCreated([
'status' => 'created',
'message' => "Test created successfully",
'data' => ['TestSiteId' => $id]
]);
} catch (\Exception $e) {
$this->db->transRollback();
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* PUT/PATCH /v1/tests/{id}
* PUT/PATCH /v1/tests/site/{id}
* Update existing test definition
*/
public function update($id = null)
{
$input = $this->request->getJSON(true);
// Determine ID
if (!$id && isset($input["TestSiteID"])) {
$id = $input["TestSiteID"];
}
if (!$id) {
return $this->failValidationErrors('TestSiteID is required.');
}
// Verify record exists
$existing = $this->model->find($id);
if (!$existing) {
return $this->failNotFound('Test not found');
}
$this->db->transStart();
try {
// 1. Update Main Table
$testSiteData = [];
$allowedUpdateFields = [
'TestSiteCode',
'TestSiteName',
'TestType',
'Description',
'SeqScr',
'SeqRpt',
'IndentLeft',
'FontStyle',
'VisibleScr',
'VisibleRpt',
'CountStat',
'StartDate'
];
foreach ($allowedUpdateFields as $field) {
if (isset($input[$field])) {
$testSiteData[$field] = $input[$field];
}
}
if (!empty($testSiteData)) {
$this->model->update($id, $testSiteData);
}
// 2. Handle Details
$this->handleDetails($id, $input, 'update');
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->failServerError('Transaction failed');
}
return $this->respond([
'status' => 'success',
'message' => "Test updated successfully",
'data' => ['TestSiteId' => $id]
]);
} catch (\Exception $e) {
$this->db->transRollback();
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* DELETE /v1/tests/{id}
* DELETE /v1/tests/site/{id}
* Soft delete test by setting EndDate
*/
public function delete($id = null)
{
$input = $this->request->getJSON(true);
// Determine ID
if (!$id && isset($input["TestSiteID"])) {
$id = $input["TestSiteID"];
}
if (!$id) {
return $this->failValidationErrors('TestSiteID is required.');
}
// Verify record exists
$existing = $this->model->find($id);
if (!$existing) {
return $this->failNotFound('Test not found');
}
// Check if already disabled
if (!empty($existing['EndDate'])) {
return $this->failValidationErrors('Test is already disabled');
}
$this->db->transStart();
try {
$now = date('Y-m-d H:i:s');
// 1. Soft delete main record
$this->model->update($id, ['EndDate' => $now]);
// 2. Get TestType to handle related records
$testType = $existing['TestType'];
$vs = $this->modelValueSet->find($testType);
$typeCode = $vs['VValue'] ?? '';
// 3. Soft delete related records based on TestType
if ($typeCode === 'CALC') {
$this->db->table('testdefcal')
->where('TestSiteID', $id)
->update(['EndDate' => $now]);
} elseif ($typeCode === 'GROUP') {
$this->db->table('testdefgrp')
->where('TestSiteID', $id)
->update(['EndDate' => $now]);
} elseif (in_array($typeCode, ['TEST', 'PARAM'])) {
$this->db->table('testdeftech')
->where('TestSiteID', $id)
->update(['EndDate' => $now]);
// Soft delete refnum and reftxt records
$this->modelRefNum->where('TestSiteID', $id)->set('EndDate', $now)->update();
$this->modelRefTxt->where('TestSiteID', $id)->set('EndDate', $now)->update();
}
// 4. Soft delete test mappings
$this->db->table('testmap')
->where('TestSiteID', $id)
->update(['EndDate' => $now]);
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->failServerError('Transaction failed');
}
return $this->respond([
'status' => 'success',
'message' => "Test disabled successfully",
'data' => ['TestSiteId' => $id, 'EndDate' => $now]
]);
} catch (\Exception $e) {
$this->db->transRollback();
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
/**
* Helper to get valueset options
*/
private function getValuesetOptions($vsetID)
{
return $this->db->table('valueset')
->select('VID as vid, VValue as vvalue, VDesc as vdesc')
->where('VSetID', $vsetID)
->orderBy('VOrder', 'ASC')
->get()->getResultArray();
}
/**
* Helper to get VValue from VID for display
*/
private function getVValue($vsetID, $vid)
{
if ($vid === null || $vid === '')
return null;
$row = $this->db->table('valueset')
->select('VValue as vvalue')
->where('VSetID', $vsetID)
->where('VID', (int) $vid)
->get()->getRowArray();
return $row ? $row['vvalue'] : null;
}
/**
* Helper to handle inserting/updating sub-tables based on TestType
*/
private function handleDetails($testSiteID, $input, $action)
{
$testTypeID = $input['TestType'] ?? null;
// If update and TestType not in payload, fetch from DB
if (!$testTypeID && $action === 'update') {
$existing = $this->model->find($testSiteID);
$testTypeID = $existing['TestType'] ?? null;
}
if (!$testTypeID)
return;
// Get Type Code (TEST, PARAM, CALC, GROUP, TITLE)
$vs = $this->modelValueSet->find($testTypeID);
$typeCode = $vs['VValue'] ?? '';
// Get details data from input
$details = $input['details'] ?? $input;
$details['TestSiteID'] = $testSiteID;
$details['SiteID'] = $input['SiteID'] ?? 1;
switch ($typeCode) {
case 'CALC':
$this->saveCalcDetails($testSiteID, $details, $action);
break;
case 'GROUP':
$this->saveGroupDetails($testSiteID, $details, $input, $action);
break;
case 'TITLE':
// TITLE type only has testdefsite, no additional details needed
// But we should save test mappings if provided
if (isset($input['testmap']) && is_array($input['testmap'])) {
$this->saveTestMap($testSiteID, $input['testmap'], $action);
}
break;
case 'TEST':
case 'PARAM':
default:
$this->saveTechDetails($testSiteID, $details, $action, $typeCode);
// Save refnum/reftxt for TEST/PARAM types
if (in_array($typeCode, ['TEST', 'PARAM']) && isset($details['RefType'])) {
$refType = (int) $details['RefType'];
// Save refnum for NMRC type (RefType = 1)
if ($refType === 1 && isset($input['refnum']) && is_array($input['refnum'])) {
$this->saveRefNumRanges($testSiteID, $input['refnum'], $action, $input['SiteID'] ?? 1);
}
// Save reftxt for TEXT type (RefType = 2)
if ($refType === 2 && isset($input['reftxt']) && is_array($input['reftxt'])) {
$this->saveRefTxtRanges($testSiteID, $input['reftxt'], $action, $input['SiteID'] ?? 1);
}
}
break;
}
// Save test mappings for TEST and CALC types as well
if (in_array($typeCode, ['TEST', 'CALC']) && isset($input['testmap']) && is_array($input['testmap'])) {
$this->saveTestMap($testSiteID, $input['testmap'], $action);
}
}
/**
* Save technical details for TEST and PARAM types
*/
private function saveTechDetails($testSiteID, $data, $action, $typeCode)
{
$techData = [
'TestSiteID' => $testSiteID,
'DisciplineID' => $data['DisciplineID'] ?? null,
'DepartmentID' => $data['DepartmentID'] ?? null,
'ResultType' => $data['ResultType'] ?? null,
'RefType' => $data['RefType'] ?? null,
'VSet' => $data['VSet'] ?? null,
'ReqQty' => $data['ReqQty'] ?? null,
'ReqQtyUnit' => $data['ReqQtyUnit'] ?? null,
'Unit1' => $data['Unit1'] ?? null,
'Factor' => $data['Factor'] ?? null,
'Unit2' => $data['Unit2'] ?? null,
'Decimal' => $data['Decimal'] ?? 2,
'CollReq' => $data['CollReq'] ?? null,
'Method' => $data['Method'] ?? null,
'ExpectedTAT' => $data['ExpectedTAT'] ?? null
];
if ($action === 'update') {
$exists = $this->db->table('testdeftech')
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->get()->getRowArray();
if ($exists) {
$this->modelTech->update($exists['TestTechID'], $techData);
} else {
$this->modelTech->insert($techData);
}
} else {
$this->modelTech->insert($techData);
}
}
/**
* Save refnum ranges for NMRC type
*/
private function saveRefNumRanges($testSiteID, $ranges, $action, $siteID)
{
if ($action === 'update') {
$this->modelRefNum->where('TestSiteID', $testSiteID)
->set('EndDate', date('Y-m-d H:i:s'))
->update();
}
foreach ($ranges as $index => $range) {
$this->modelRefNum->insert([
'TestSiteID' => $testSiteID,
'SiteID' => $siteID,
'NumRefType' => (int) $range['NumRefType'],
'RangeType' => (int) $range['RangeType'],
'Sex' => (int) $range['Sex'],
'AgeStart' => (int) ($range['AgeStart'] ?? 0),
'AgeEnd' => (int) ($range['AgeEnd'] ?? 150),
'LowSign' => !empty($range['LowSign']) ? (int) $range['LowSign'] : null,
'Low' => !empty($range['Low']) ? (int) $range['Low'] : null,
'HighSign' => !empty($range['HighSign']) ? (int) $range['HighSign'] : null,
'High' => !empty($range['High']) ? (int) $range['High'] : null,
'Flag' => $range['Flag'] ?? null,
'Display' => $index,
'CreateDate' => date('Y-m-d H:i:s')
]);
}
}
/**
* Save reftxt ranges for TEXT type
*/
private function saveRefTxtRanges($testSiteID, $ranges, $action, $siteID)
{
if ($action === 'update') {
$this->modelRefTxt->where('TestSiteID', $testSiteID)
->set('EndDate', date('Y-m-d H:i:s'))
->update();
}
foreach ($ranges as $range) {
$this->modelRefTxt->insert([
'TestSiteID' => $testSiteID,
'SiteID' => $siteID,
'TxtRefType' => (int) $range['TxtRefType'],
'Sex' => (int) $range['Sex'],
'AgeStart' => (int) ($range['AgeStart'] ?? 0),
'AgeEnd' => (int) ($range['AgeEnd'] ?? 150),
'RefTxt' => $range['RefTxt'] ?? '',
'Flag' => $range['Flag'] ?? null,
'CreateDate' => date('Y-m-d H:i:s')
]);
}
}
/**
* Save calculation details for CALC type
*/
private function saveCalcDetails($testSiteID, $data, $action)
{
$calcData = [
'TestSiteID' => $testSiteID,
'DisciplineID' => $data['DisciplineID'] ?? null,
'DepartmentID' => $data['DepartmentID'] ?? null,
'FormulaInput' => $data['FormulaInput'] ?? null,
'FormulaCode' => $data['FormulaCode'] ?? $data['Formula'] ?? null,
'RefType' => $data['RefType'] ?? 'NMRC',
'Unit1' => $data['Unit1'] ?? $data['ResultUnit'] ?? null,
'Factor' => $data['Factor'] ?? null,
'Unit2' => $data['Unit2'] ?? null,
'Decimal' => $data['Decimal'] ?? 2,
'Method' => $data['Method'] ?? null
];
if ($action === 'update') {
$exists = $this->db->table('testdefcal')
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->get()->getRowArray();
if ($exists) {
$this->modelCal->update($exists['TestCalID'], $calcData);
} else {
$this->modelCal->insert($calcData);
}
} else {
$this->modelCal->insert($calcData);
}
}
/**
* Save group details for GROUP type
*/
private function saveGroupDetails($testSiteID, $data, $input, $action)
{
if ($action === 'update') {
// Soft delete existing members
$this->db->table('testdefgrp')
->where('TestSiteID', $testSiteID)
->update(['EndDate' => date('Y-m-d H:i:s')]);
}
// Get members from details or input
$members = $data['members'] ?? ($input['Members'] ?? []);
if (is_array($members)) {
foreach ($members as $m) {
$memberID = is_array($m) ? ($m['Member'] ?? ($m['TestSiteID'] ?? null)) : $m;
if ($memberID) {
$this->modelGrp->insert([
'TestSiteID' => $testSiteID,
'Member' => $memberID
]);
}
}
}
}
/**
* Save test mappings
*/
private function saveTestMap($testSiteID, $mappings, $action)
{
if ($action === 'update') {
// Soft delete existing mappings
$this->db->table('testmap')
->where('TestSiteID', $testSiteID)
->update(['EndDate' => date('Y-m-d H:i:s')]);
}
if (is_array($mappings)) {
foreach ($mappings as $map) {
$mapData = [
'TestSiteID' => $testSiteID,
'HostType' => $map['HostType'] ?? null,
'HostID' => $map['HostID'] ?? null,
'HostDataSource' => $map['HostDataSource'] ?? null,
'HostTestCode' => $map['HostTestCode'] ?? null,
'HostTestName' => $map['HostTestName'] ?? null,
'ClientType' => $map['ClientType'] ?? null,
'ClientID' => $map['ClientID'] ?? null,
'ClientDataSource' => $map['ClientDataSource'] ?? null,
'ConDefID' => $map['ConDefID'] ?? null,
'ClientTestCode' => $map['ClientTestCode'] ?? null,
'ClientTestName' => $map['ClientTestName'] ?? null
];
$this->modelMap->insert($mapData);
}
}
}
}

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\ValueSet\ValueSetModel;
class ValueSet extends BaseController {
class ValueSetController extends BaseController {
use ResponseTrait;
protected $db;
@ -94,4 +94,4 @@ class ValueSet extends BaseController {
}
}
}
}

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\ValueSet\ValueSetDefModel;
class ValueSetDef extends BaseController {
class ValueSetDefController extends BaseController {
use ResponseTrait;
protected $db;
@ -70,4 +70,4 @@ class ValueSetDef extends BaseController {
}
}
}
}

View File

@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\SyncCRM\ZonesModel;
class Zones extends BaseController {
class ZonesController extends BaseController {
use ResponseTrait;
protected $model;
@ -92,4 +92,4 @@ class Zones extends BaseController {
}
}
*/
*/

View File

@ -4,17 +4,36 @@ namespace App\Models\RefRange;
use App\Models\BaseModel;
class RefNumModel extends BaseModel {
class RefNumModel extends BaseModel
{
protected $table = 'refnum';
protected $primaryKey = 'RefNumID';
protected $allowedFields = ['SiteID', 'TestSiteID', 'SpcType', 'Sex', 'AgeStart', 'AgeEnd',
'CriticalLow', 'Low', 'High', 'CriticalHigh',
'CreateDate', 'EndDate'];
protected $allowedFields = [
'SiteID',
'TestSiteID',
'SpcType',
'Sex',
'Criteria',
'AgeStart',
'AgeEnd',
'NumRefType',
'RangeType',
'LowSign',
'Low',
'HighSign',
'High',
'Display',
'Flag',
'Interpretation',
'Notes',
'CreateDate',
'StartDate',
'EndDate'
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = '';
protected $useSoftDeletes = true;
protected $deletedField = "EndDate";
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models\RefRange;
use App\Models\BaseModel;
class RefTxtModel extends BaseModel
{
protected $table = 'reftxt';
protected $primaryKey = 'RefTxtID';
protected $allowedFields = [
'SiteID',
'TestSiteID',
'SpcType',
'Sex',
'Criteria',
'AgeStart',
'AgeEnd',
'TxtRefType',
'RefTxt',
'Flag',
'Notes',
'CreateDate',
'StartDate',
'EndDate'
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = '';
protected $useSoftDeletes = true;
protected $deletedField = "EndDate";
}

View File

@ -1,257 +1,375 @@
<!-- Calculated Test Form Modal -->
<div
x-show="showModal && currentDialogType === 'CALC'"
x-cloak
class="modal-overlay"
@click.self="closeModal()"
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"
>
<div
class="modal-content p-6 max-w-3xl w-full max-h-[90vh] overflow-y-auto"
@click.stop
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Calculation Dialog (for CALC type) -->
<div x-show="showModal && (getTypeCode(form.TestType) === 'CALC' || form.TypeCode === 'CALC')" x-cloak
class="modal-overlay" @click.self="closeModal()" 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">
<div class="modal-content p-6 max-w-3xl w-full max-h-[90vh] overflow-y-auto" @click.stop
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-calculator" style="color: rgb(var(--color-secondary));"></i>
<span x-text="isEditing ? 'Edit Calculated Test' : 'New Calculated Test'"></span>
</h3>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-amber-100 flex items-center justify-center">
<i class="fa-solid fa-calculator text-amber-600 text-lg"></i>
</div>
<div>
<h3 class="font-bold text-lg" style="color: rgb(var(--color-text));">
<span x-text="isEditing ? 'Edit Calculated Test' : 'New Calculated Test'"></span>
</h3>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">Derived/Calculated Test Definition</p>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-square" @click="closeModal()">
<i class="fa-solid fa-times"></i>
</button>
</div>
<!-- Calculation Type Badge -->
<div class="mb-4">
<span class="badge badge-warning gap-1">
<i class="fa-solid fa-calculator"></i>
<span x-text="form.TypeCode || getTypeName(form.TestType)"></span>
</span>
</div>
<!-- Tabs Navigation -->
<div class="flex flex-wrap gap-1 mb-4 p-1 rounded-lg"
style="background: rgb(var(--color-bg-secondary)); border: 1px solid rgb(var(--color-border));">
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'basic' ? 'bg-amber-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'basic'">
<i class="fa-solid fa-info-circle mr-1"></i> Basic
</button>
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'formula' ? 'bg-amber-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'formula'">
<i class="fa-solid fa-calculator mr-1"></i> Formula
</button>
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'results' ? 'bg-amber-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'results'">
<i class="fa-solid fa-flask mr-1"></i> Results
</button>
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'reference' ? 'bg-amber-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'reference'">
<i class="fa-solid fa-ruler-combined mr-1"></i> Ref
</button>
</div>
<!-- Form -->
<div class="space-y-4">
<!-- Basic Info -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Test Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.TestSiteName && 'input-error'"
x-model="form.TestSiteName"
placeholder="Absolute Neutrophils"
/>
<label class="label" x-show="errors.TestSiteName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.TestSiteName"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Test Code <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input font-mono"
:class="errors.TestSiteCode && 'input-error'"
x-model="form.TestSiteCode"
placeholder="ANC"
/>
<label class="label" x-show="errors.TestSiteCode">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.TestSiteCode"></span>
</label>
</div>
</div>
<!-- Test Type & Result Unit -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Test Type</span>
</label>
<input type="text" class="input input-disabled bg-base-200" x-model="form.TestTypeName" readonly />
</div>
<div>
<label class="label">
<span class="label-text font-medium">Result Unit</span>
</label>
<input
type="text"
class="input"
x-model="form.Unit1"
placeholder="% or cells/µL"
/>
</div>
</div>
<!-- Tab: Basic Information (includes Org and Seq) -->
<div x-show="activeTab === 'basic'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<!-- Formula Section -->
<div class="border rounded-xl p-4 bg-base-50">
<h4 class="font-semibold flex items-center gap-2 mb-4">
<i class="fa-solid fa-function"></i>
Calculation Formula
</h4>
<div class="space-y-3">
<div>
<!-- Basic Info Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-info-circle text-amber-500"></i> Basic Information
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Test Code <span class="text-error">*</span></span>
</label>
<input type="text" class="input input-bordered font-mono uppercase w-full"
:class="errors.TestSiteCode && 'input-error'" x-model="form.TestSiteCode"
placeholder="e.g., BMI, eGFR, LDL_C" maxlength="10" />
<label class="label" x-show="errors.TestSiteCode">
<span class="label-text-alt text-error text-xs" x-text="errors.TestSiteCode"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Test Name <span class="text-error">*</span></span>
</label>
<input type="text" class="input input-bordered w-full" :class="errors.TestSiteName && 'input-error'"
x-model="form.TestSiteName" placeholder="e.g., Body Mass Index, eGFR" />
<label class="label" x-show="errors.TestSiteName">
<span class="label-text-alt text-error text-xs">Test name is required</span>
</label>
</div>
</div>
<div class="mt-3">
<label class="label">
<span class="label-text font-medium">Formula Expression <span style="color: rgb(var(--color-error));">*</span></span>
<span class="label-text-alt text-base-content/60">Use test codes as variables in curly braces</span>
<span class="label-text font-medium text-sm">Description</span>
</label>
<textarea
class="input font-mono h-20"
:class="errors.FormulaCode && 'input-error'"
x-model="form.FormulaCode"
placeholder="({WBC} * {NEU%}) / 100"
></textarea>
<label class="label" x-show="errors.FormulaCode">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.FormulaCode"></span>
<textarea class="textarea textarea-bordered w-full" x-model="form.Description"
placeholder="e.g., Calculated based on weight and height..." rows="2"></textarea>
</div>
</div>
<!-- Organization Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-building text-amber-500"></i> Organization
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Discipline</span>
</label>
<select class="select select-bordered w-full" x-model="form.DisciplineID">
<option value="">Select Discipline</option>
<option value="1">Hematology</option>
<option value="2">Chemistry</option>
<option value="3">Microbiology</option>
<option value="4">Urinalysis</option>
<option value="10">General</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Department</span>
</label>
<select class="select select-bordered w-full" x-model="form.DepartmentID">
<option value="">Select Department</option>
<option value="1">Lab Hematology</option>
<option value="2">Lab Chemistry</option>
<option value="3">Lab Microbiology</option>
<option value="4">Lab Urinalysis</option>
</select>
</div>
</div>
</div>
<!-- Sequencing Section -->
<div>
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-list-ol text-amber-500"></i> Sequencing & Visibility
</h4>
<div class="grid grid-cols-4 gap-3">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Seq (Screen)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.SeqScr" placeholder="0" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Seq (Report)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.SeqRpt" placeholder="0" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Indent</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.IndentLeft"
placeholder="0" />
</div>
<div class="flex items-center">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.CountStat" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Count in Statistics</span>
</label>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleScr" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Visible on Screen</span>
</label>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleRpt" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Visible on Report</span>
</label>
</div>
</div>
</div>
<!-- Formula Variables/Tests -->
<!-- Tab: Formula Configuration -->
<div x-show="activeTab === 'formula'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<div class="space-y-4">
<div>
<label class="label">
<span class="label-text font-medium">Input Parameters</span>
<span class="label-text-alt text-base-content/60">Tests referenced in formula</span>
<span class="label-text font-medium text-sm">Formula Input Variables <span
class="text-error">*</span></span>
<span class="label-text-alt text-xs">Comma-separated test codes (these become variable names)</span>
</label>
<div class="flex flex-wrap gap-2">
<template x-if="!form.FormulaInput || form.FormulaInput.length === 0">
<span class="text-sm text-base-content/50 italic">No parameters defined.</span>
</template>
<template x-for="(v, idx) in (form.FormulaInput ? form.FormulaInput.split('^') : [])" :key="idx">
<span class="badge badge-primary gap-1">
<code x-text="v"></code>
</span>
</template>
<input type="text" class="input input-bordered font-mono w-full" x-model="form.FormulaInput"
placeholder="e.g., WEIGHT,HEIGHT,AGE,SCR" />
<p class="text-xs mt-1" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-info-circle mr-1"></i>
Enter test codes that will be used as input variables. Use comma to separate multiple variables.
</p>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Formula Expression <span class="text-error">*</span></span>
<span class="label-text-alt text-xs">JavaScript expression using variable names</span>
</label>
<textarea class="textarea textarea-bordered font-mono text-sm w-full" x-model="form.FormulaCode"
placeholder="e.g., WEIGHT / ((HEIGHT/100) * (HEIGHT/100))" rows="3"></textarea>
</div>
<!-- Available Functions Help -->
<div class="p-3 rounded bg-opacity-20" style="background: rgb(var(--color-bg-tertiary));">
<h5 class="font-semibold text-sm mb-2">Available Functions:</h5>
<div class="grid grid-cols-2 gap-2 text-xs font-mono" style="color: rgb(var(--color-text-muted));">
<div><code>ABS(x)</code> - Absolute value</div>
<div><code>ROUND(x, d)</code> - Round to d decimals</div>
<div><code>MIN(a, b, ...)</code> - Minimum value</div>
<div><code>MAX(a, b, ...)</code> - Maximum value</div>
<div><code>IF(cond, t, f)</code> - Conditional</div>
<div><code>MEAN(a, b, ...)</code> - Average</div>
<div><code>SQRT(x)</code> - Square root</div>
<div><code>POW(x, y)</code> - Power (x^y)</div>
</div>
</div>
<!-- Add Variable -->
<div class="flex gap-2">
<select class="select select-bordered flex-1" x-model="form.newFormulaVar">
<option value="">Select test variable...</option>
<template x-for="(t, idx) in availableTests" :key="idx">
<option :value="t.TestSiteCode" x-text="t.TestSiteCode + ' - ' + t.TestSiteName"></option>
</template>
</select>
<button class="btn btn-outline" @click="addFormulaVar()">
<i class="fa-solid fa-plus"></i>
</button>
</div>
</div>
</div>
<!-- Result Options -->
<div class="grid grid-cols-3 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Decimal Places</span>
</label>
<input
type="number"
class="input text-center"
x-model="form.Decimal"
min="0"
max="10"
placeholder="2"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Conversion Factor</span>
</label>
<input
type="text"
class="input"
x-model="form.Factor"
placeholder="Optional conversion"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Secondary Unit</span>
</label>
<input
type="text"
class="input"
x-model="form.Unit2"
placeholder="Optional secondary unit"
/>
</div>
</div>
<!-- Description -->
<div>
<label class="label">
<span class="label-text font-medium">Description</span>
</label>
<textarea
class="input h-16 pt-2"
x-model="form.Description"
placeholder="Calculation description..."
></textarea>
</div>
<!-- Sequence & Site -->
<div class="grid grid-cols-3 gap-4">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="label">
<span class="label-text font-medium">Seq (Screen)</span>
</label>
<input type="number" class="input text-center" x-model="form.SeqScr" />
</div>
<div>
<label class="label">
<span class="label-text font-medium">Seq (Report)</span>
</label>
<input type="number" class="input text-center" x-model="form.SeqRpt" />
</div>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Site</span>
</label>
<select class="select" x-model="form.SiteID">
<template x-for="s in sitesList" :key="s.SiteID">
<option :value="s.SiteID" x-text="s.SiteName"></option>
<!-- Formula Preview -->
<div class="p-3 rounded border"
style="background: rgb(var(--color-bg-secondary)); border-color: rgb(var(--color-border));">
<h5 class="font-semibold text-sm mb-2 flex items-center gap-2">
<i class="fa-solid fa-eye text-amber-500"></i>
Formula Preview
</h5>
<template x-if="form.FormulaInput || form.FormulaCode">
<div class="font-mono text-sm space-y-1">
<div class="flex gap-2">
<span class="opacity-60">Inputs:</span>
<span x-text="form.FormulaInput || '(none)'"></span>
</div>
<div class="flex gap-2">
<span class="opacity-60">Formula:</span>
<code x-text="form.FormulaCode || '(none)'"></code>
</div>
</div>
</template>
</select>
<template x-if="!form.FormulaInput && !form.FormulaCode">
<span class="text-sm opacity-50 italic">Enter formula inputs and expression above</span>
</template>
</div>
</div>
</div>
<!-- Options -->
<div class="flex items-center gap-6 p-4 rounded-xl border border-slate-100 bg-slate-50/50">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.VisibleScr" :true-value="1" :false-value="0" />
<span class="label-text">Visible in Screen</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.VisibleRpt" :true-value="1" :false-value="0" />
<span class="label-text">Visible in Report</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.CountStat" :true-value="1" :false-value="0" />
<span class="label-text">Count in Statistics</span>
</label>
<!-- Tab: Result Configuration (includes Sample) -->
<div x-show="activeTab === 'results'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<!-- Result Configuration -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-flask text-amber-500"></i> Result Configuration
</h4>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Result Unit</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.Unit1"
placeholder="e.g., kg/m2, mg/dL, %" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Decimal Places</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.Decimal" placeholder="2"
min="0" max="10" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Expected TAT (min)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.ExpectedTAT"
placeholder="5" />
</div>
</div>
</div>
<!-- Sample -->
<div>
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-vial text-amber-500"></i> Sample
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Sample Type</span>
</label>
<select class="select select-bordered w-full" x-model="form.SampleType">
<option value="">Select Sample Type</option>
<option value="SERUM">Serum</option>
<option value="PLASMA">Plasma</option>
<option value="BLOOD">Whole Blood</option>
<option value="URINE">Urine</option>
<option value="CSF">CSF</option>
<option value="OTHER">Other</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Method</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.Method"
placeholder="e.g., Automated calculation" />
</div>
</div>
</div>
</div>
<!-- Tab: Reference Range -->
<div x-show="activeTab === 'reference'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<div class="grid grid-cols-4 gap-3">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Min Normal</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.RefMin" placeholder="0"
step="any" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Max Normal</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.RefMax" placeholder="25"
step="any" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Critical Low</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.CriticalLow"
placeholder="Optional" step="any" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Critical High</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.CriticalHigh"
placeholder="Optional" step="any" />
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-8 pt-6" style="border-top: 1px solid rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">Cancel</button>
<button class="btn btn-secondary flex-1" @click="save()" :disabled="saving">
<span x-show="saving" class="spinner spinner-sm"></span>
<div class="flex gap-3 mt-6 pt-4 border-t" style="border-color: rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">
<i class="fa-solid fa-times mr-2"></i> Cancel
</button>
<button class="btn btn-warning flex-1" @click="save()" :disabled="saving">
<span x-show="saving" class="loading loading-spinner loading-sm mr-2"></span>
<i x-show="!saving" class="fa-solid fa-save mr-2"></i>
<span x-text="saving ? 'Saving...' : 'Save Test'"></span>
<span x-text="saving ? 'Saving...' : (isEditing ? 'Update Calculated Test' : 'Create Calculated Test')"></span>
</button>
</div>
</div>
</div>
</div>

View File

@ -1,211 +0,0 @@
<!-- Group Test Form Modal -->
<div
x-show="showModal && currentDialogType === 'GROUP'"
x-cloak
class="modal-overlay"
@click.self="closeModal()"
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"
>
<div
class="modal-content p-5 max-w-2xl w-full max-h-[90vh] overflow-y-auto"
@click.stop
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-layer-group" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Test Group' : 'New Test Group'"></span>
</h3>
<button class="btn btn-ghost btn-sm btn-square" @click="closeModal()">
<i class="fa-solid fa-times"></i>
</button>
</div>
<!-- Form -->
<div class="space-y-3">
<!-- Basic Info -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="label py-1">
<span class="label-text font-medium text-sm">Group Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input input-sm input-bordered"
:class="errors.TestSiteName && 'input-error'"
x-model="form.TestSiteName"
placeholder="CBC Panel"
/>
</div>
<div>
<label class="label py-1">
<span class="label-text font-medium text-sm">Group Code <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input input-sm input-bordered font-mono"
:class="errors.TestSiteCode && 'input-error'"
x-model="form.TestSiteCode"
placeholder="CBC"
/>
</div>
</div>
<!-- Test Type & Default Specimen -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="label py-1">
<span class="label-text font-medium text-sm">Test Type</span>
</label>
<input type="text" class="input input-sm input-bordered bg-base-200" x-model="form.TestTypeName" readonly />
</div>
<div>
<label class="label py-1">
<span class="label-text font-medium text-sm">Default Specimen</span>
</label>
<select class="select select-sm select-bordered" x-model="form.SpcType">
<option value="">Select</option>
<template x-for="s in specimenTypesList" :key="s.VID || s.id">
<option :value="s.VValue" x-text="s.VDesc || s.VValue"></option>
</template>
</select>
</div>
</div>
<!-- Test Members Selection -->
<div class="border rounded-lg p-3 bg-base-50">
<div class="flex items-center justify-between mb-2">
<h4 class="font-semibold text-sm flex items-center gap-1">
<i class="fa-solid fa-list-check text-sm"></i>
Group Members
</h4>
<button class="btn btn-primary btn-xs" @click="openTestSelector()">
<i class="fa-solid fa-plus mr-1"></i> Add
</button>
</div>
<!-- Selected Members List -->
<div class="space-y-2 max-h-64 overflow-y-auto">
<template x-if="!form.members || form.members.length === 0">
<div class="text-center py-4 text-base-content/50">
<i class="fa-solid fa-inbox text-xl mb-1"></i>
<p class="text-xs">No tests added</p>
</div>
</template>
<template x-for="(member, index) in form.members" :key="index">
<div class="grid grid-cols-[1fr_auto] gap-2 p-2 bg-white rounded border items-center">
<span class="text-xs font-medium truncate" x-text="member.TestSiteCode+' - '+member.TestSiteName"></span>
<div class="flex items-center gap-1">
<input type="number" class="input input-xs py-1 text-center w-8" x-model="member.SeqScr" placeholder="#" title="Order"/>
<button class="btn btn-ghost btn-xs btn-square text-error" @click="removeMember(index)">
<i class="fa-solid fa-times text-xs"></i>
</button>
</div>
</div>
</template>
</div>
</div>
<!-- Sequence & Site - Very Compact -->
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-1">
<span class="text-xs text-base-content/60">Scr:</span>
<input type="number" class="input input-xs w-12 text-center" x-model="form.SeqScr" />
</div>
<div class="flex items-center gap-1">
<span class="text-xs text-base-content/60">Rpt:</span>
<input type="number" class="input input-xs w-12 text-center" x-model="form.SeqRpt" />
</div>
<select class="select select-xs flex-1" x-model="form.SiteID">
<template x-for="s in sitesList" :key="s.SiteID">
<option :value="s.SiteID" x-text="s.SiteName"></option>
</template>
</select>
</div>
<!-- Options - Compact -->
<div class="flex items-center gap-3 p-2 rounded border border-slate-100 bg-slate-50/50">
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" class="checkbox checkbox-xs" x-model="form.VisibleScr" :true-value="1" :false-value="0" />
<span class="label-text text-xs">Screen</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" class="checkbox checkbox-xs" x-model="form.VisibleRpt" :true-value="1" :false-value="0" />
<span class="label-text text-xs">Report</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" class="checkbox checkbox-xs" x-model="form.CountStat" :true-value="1" :false-value="0" />
<span class="label-text text-xs">Stats</span>
</label>
</div>
</div>
<!-- Actions -->
<div class="flex gap-2 mt-4 pt-3" style="border-top: 1px solid rgb(var(--color-border));">
<button class="btn btn-sm btn-ghost flex-1" @click="closeModal()">Cancel</button>
<button class="btn btn-sm btn-primary flex-1" @click="save()" :disabled="saving">
<span x-show="saving" class="spinner spinner-xs"></span>
<i x-show="!saving" class="fa-solid fa-save mr-1"></i>
<span x-text="saving ? 'Saving...' : 'Save'"></span>
</button>
</div>
</div>
<!-- Test Selector Modal -->
<div x-show="showTestSelector" x-cloak class="modal-overlay" style="z-index: 100;">
<div class="modal-content p-4 max-w-lg" @click.stop>
<div class="flex items-center justify-between mb-3">
<h4 class="font-bold">Select Tests</h4>
<button class="btn btn-ghost btn-sm btn-square" @click="showTestSelector = false">
<i class="fa-solid fa-times"></i>
</button>
</div>
<input
type="text"
class="input input-sm mb-2"
placeholder="Search tests..."
x-model="testSearch"
/>
<div class="max-h-48 overflow-y-auto space-y-1">
<template x-for="test in availableTests" :key="test.TestSiteID">
<label class="flex items-center gap-2 p-2 hover:bg-base-200 rounded-lg cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-sm checkbox-primary"
:checked="isTestSelected(test.TestSiteID)"
@change="toggleTestSelection(test)"
/>
<div class="flex-1">
<div class="font-medium text-sm" x-text="test.TestSiteName"></div>
<div class="text-xs text-base-content/60 font-mono" x-text="test.TestSiteCode"></div>
</div>
</label>
</template>
</div>
<div class="flex gap-2 mt-3 pt-2" style="border-top: 1px solid rgb(var(--color-border));">
<button class="btn btn-sm btn-ghost flex-1" @click="showTestSelector = false">Cancel</button>
<button class="btn btn-sm btn-primary flex-1" @click="confirmTestSelection()">
<i class="fa-solid fa-check mr-1"></i> Confirm
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,305 @@
<!-- Group Dialog (for GROUP type) -->
<div x-show="showModal && (getTypeCode(form.TestType) === 'GROUP' || form.TypeCode === 'GROUP')" x-cloak
class="modal-overlay" @click.self="closeModal()" 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">
<div class="modal-content p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto" @click.stop
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary bg-opacity-20 flex items-center justify-center">
<i class="fa-solid fa-layer-group text-primary text-lg"></i>
</div>
<div>
<h3 class="font-bold text-lg" style="color: rgb(var(--color-text));">
<span x-text="isEditing ? 'Edit Test Group' : 'New Test Group'"></span>
</h3>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">Group/Panel Definition</p>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-square" @click="closeModal()">
<i class="fa-solid fa-times"></i>
</button>
</div>
<!-- Group Type Badge -->
<div class="mb-4">
<span class="badge badge-primary gap-1">
<i class="fa-solid fa-layer-group"></i>
<span x-text="form.TypeCode || getTypeName(form.TestType)"></span>
</span>
</div>
<!-- Tabs Navigation -->
<div class="flex flex-wrap gap-1 mb-4 p-1 rounded-lg"
style="background: rgb(var(--color-bg-secondary)); border: 1px solid rgb(var(--color-border));">
<button class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'basic' ? 'bg-primary text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'basic'">
<i class="fa-solid fa-info-circle mr-1"></i> Basic
</button>
<button class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'members' ? 'bg-primary text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'members'">
<i class="fa-solid fa-users mr-1"></i> Members
</button>
</div>
<!-- Form -->
<div class="space-y-4">
<!-- Tab: Basic Information (includes Seq) -->
<div x-show="activeTab === 'basic'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<!-- Basic Info Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-info-circle text-primary"></i> Basic Information
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Group Code <span class="text-error">*</span></span>
<span class="label-text-alt text-xs">Auto-generated from name</span>
</label>
<input type="text" class="input input-bordered font-mono uppercase w-full"
:class="errors.TestSiteCode && 'input-error'" x-model="form.TestSiteCode" placeholder="Auto-generated"
maxlength="10" />
<label class="label" x-show="errors.TestSiteCode">
<span class="label-text-alt text-error text-xs" x-text="errors.TestSiteCode"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Group Name <span class="text-error">*</span></span>
</label>
<input type="text" class="input input-bordered w-full" :class="errors.TestSiteName && 'input-error'"
x-model="form.TestSiteName" placeholder="e.g., Lipid Profile, CBC Panel" />
<label class="label" x-show="errors.TestSiteName">
<span class="label-text-alt text-error text-xs">Group name is required</span>
</label>
</div>
</div>
<div class="mt-3">
<label class="label">
<span class="label-text font-medium text-sm">Description</span>
</label>
<textarea class="textarea textarea-bordered w-full" x-model="form.Description"
placeholder="e.g., Comprehensive lipid analysis panel..." rows="2"></textarea>
</div>
</div>
<!-- Sequencing Section -->
<div>
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-list-ol text-primary"></i> Sequencing & Visibility
</h4>
<div class="grid grid-cols-4 gap-3">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Seq (Screen)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.SeqScr" placeholder="0" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Seq (Report)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.SeqRpt" placeholder="0" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Indent</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.IndentLeft"
placeholder="0" />
</div>
<div class="flex items-center">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.CountStat" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Count in Statistics</span>
</label>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleScr" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Visible on Screen</span>
</label>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleRpt" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Visible on Report</span>
</label>
</div>
</div>
</div>
<!-- Tab: Group Members -->
<div x-show="activeTab === 'members'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<div class="space-y-4">
<div class="flex items-center justify-between p-3 rounded-lg bg-primary bg-opacity-10"
style="border: 1px solid rgb(var(--color-primary));">
<div class="flex items-center gap-2">
<i class="fa-solid fa-users text-primary"></i>
<span class="font-medium text-sm">Group Members (<span
x-text="form.groupMembers?.length || 0"></span>)</span>
</div>
<button class="btn btn-sm btn-primary" @click="showMemberSelector = true">
<i class="fa-solid fa-plus mr-1"></i> Add Member
</button>
</div>
<!-- Member List -->
<template x-if="!form.groupMembers || form.groupMembers.length === 0">
<div class="text-center py-8 rounded-lg border border-dashed"
style="border-color: rgb(var(--color-border));">
<i class="fa-solid fa-inbox text-3xl opacity-40 mb-2"></i>
<p class="opacity-60">No members added yet</p>
<p class="text-xs opacity-50">Click "Add Member" to add tests to this group</p>
</div>
</template>
<template x-if="form.groupMembers && form.groupMembers.length > 0">
<div class="overflow-x-auto">
<table class="table table-xs">
<thead>
<tr class="bg-base-200">
<th>Code</th>
<th>Name</th>
<th>Type</th>
<th>Seq</th>
<th class="w-10">Actions</th>
</tr>
</thead>
<tbody>
<template x-for="(member, index) in form.groupMembers" :key="index">
<tr class="hover">
<td><code class="text-xs" x-text="member.TestSiteCode"></code></td>
<td x-text="member.TestSiteName"></td>
<td>
<span class="badge badge-xs" :class="{
'badge-info': member.MemberTypeCode === 'TEST',
'badge-success': member.MemberTypeCode === 'PARAM'
}" x-text="member.MemberTypeCode || 'TEST'"></span>
</td>
<td>
<input type="number" class="input input-xs w-16" x-model.number="member.SeqScr"
placeholder="0" />
</td>
<td>
<button class="btn btn-ghost btn-xs btn-square text-error" @click="removeMember(index)">
<i class="fa-solid fa-times"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<!-- Quick Add Common Tests -->
<div class="p-3 rounded-lg border border-dashed" style="border-color: rgb(var(--color-border));">
<h4 class="font-medium text-sm mb-2 flex items-center gap-2">
<i class="fa-solid fa-bolt text-amber-500"></i>
Quick Add Common Tests
</h4>
<div class="flex flex-wrap gap-2">
<button class="btn btn-xs btn-outline" @click="addCommonMember('HBA1C', 'TEST')">HbA1c</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('GLU_R', 'TEST')">Glucose (Random)</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('GLU_F', 'TEST')">Glucose
(Fasting)</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('CHOL', 'TEST')">Cholesterol</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('TG', 'TEST')">Triglycerides</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('HDL', 'TEST')">HDL</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('LDL', 'TEST')">LDL</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('VLDL', 'TEST')">VLDL</button>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<button class="btn btn-xs btn-outline" @click="addCommonMember('RBC', 'PARAM')">RBC</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('WBC', 'PARAM')">WBC</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('HGB', 'PARAM')">HGB</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('HCT', 'PARAM')">HCT</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('PLT', 'PARAM')">PLT</button>
<button class="btn btn-xs btn-outline" @click="addCommonMember('MCV', 'PARAM')">MCV</button>
</div>
</div>
</div>
</div>
</div>
<!-- Member Selector Modal (outside tabs) -->
<div x-show="showMemberSelector" x-cloak class="modal-overlay" x-transition>
<div class="modal-content p-6 max-w-3xl w-full max-h-[80vh] overflow-y-auto" @click.stop
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95">
<div class="flex items-center justify-between mb-4">
<h4 class="font-bold text-lg">Select Test Members</h4>
<button class="btn btn-ghost btn-sm btn-square" @click="showMemberSelector = false">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="mb-4">
<input type="text" class="input input-bordered w-full" placeholder="Search tests..." x-model="memberSearch" />
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
<template
x-for="test in availableTests.filter(t => t.TestSiteName?.toLowerCase().includes(memberSearch?.toLowerCase() || ''))"
:key="test.TestSiteID">
<label
class="flex items-center gap-3 p-3 rounded-lg border cursor-pointer hover:bg-opacity-50 transition-colors"
style="background: rgb(var(--color-bg-secondary)); border-color: rgb(var(--color-border));">
<input type="checkbox" class="checkbox checkbox-sm"
:checked="form.groupMembers?.some(m => m.TestSiteID === test.TestSiteID)"
@change="toggleMember(test)" />
<div class="flex-1">
<div class="font-medium" x-text="test.TestSiteName"></div>
<div class="text-xs flex items-center gap-2">
<code x-text="test.TestSiteCode"></code>
<span class="badge badge-xs" :class="{
'badge-info': test.TypeCode === 'TEST',
'badge-success': test.TypeCode === 'PARAM'
}" x-text="test.TypeCode"></span>
</div>
</div>
</label>
</template>
</div>
<div class="flex justify-end gap-2 mt-4 pt-4 border-t" style="border-color: rgb(var(--color-border));">
<button class="btn btn-ghost" @click="showMemberSelector = false">Cancel</button>
<button class="btn btn-primary" @click="showMemberSelector = false; $forceUpdate()">Done</button>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-6 pt-4 border-t" style="border-color: rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">
<i class="fa-solid fa-times mr-2"></i> Cancel
</button>
<button class="btn btn-primary flex-1" @click="save()" :disabled="saving">
<span x-show="saving" class="loading loading-spinner loading-sm mr-2"></span>
<i x-show="!saving" class="fa-solid fa-save mr-2"></i>
<span x-text="saving ? 'Saving...' : (isEditing ? 'Update Group' : 'Create Group')"></span>
</button>
</div>
</div>
</div>

View File

@ -1,223 +1,417 @@
<!-- Parameter Test Form Modal -->
<div
x-show="showModal && currentDialogType === 'PARAM'"
x-cloak
class="modal-overlay"
@click.self="closeModal()"
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"
>
<div
class="modal-content p-6 max-w-3xl w-full max-h-[90vh] overflow-y-auto"
@click.stop
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Parameter Dialog (for PARAM type) -->
<div x-show="showModal && (getTypeCode(form.TestType) === 'PARAM' || form.TypeCode === 'PARAM')" x-cloak
class="modal-overlay" @click.self="closeModal()" 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">
<div class="modal-content p-6 max-w-3xl w-full max-h-[90vh] overflow-y-auto" @click.stop
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-flask" style="color: rgb(var(--color-info));"></i>
<span x-text="isEditing ? 'Edit Parameter Test' : 'New Parameter Test'"></span>
</h3>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-emerald-100 flex items-center justify-center">
<i class="fa-solid fa-sliders text-emerald-600 text-lg"></i>
</div>
<div>
<h3 class="font-bold text-lg" style="color: rgb(var(--color-text));">
<span x-text="isEditing ? 'Edit Parameter' : 'New Parameter'"></span>
</h3>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">Parameter/Component Definition</p>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-square" @click="closeModal()">
<i class="fa-solid fa-times"></i>
</button>
</div>
<!-- Parameter Type Badge -->
<div class="mb-4">
<span class="badge badge-success gap-1">
<i class="fa-solid fa-sliders"></i>
<span x-text="form.TypeCode || getTypeName(form.TestType)"></span>
</span>
</div>
<!-- Tabs Navigation -->
<div class="flex flex-wrap gap-1 mb-4 p-1 rounded-lg"
style="background: rgb(var(--color-bg-secondary)); border: 1px solid rgb(var(--color-border));">
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'basic' ? 'bg-emerald-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'basic'">
<i class="fa-solid fa-info-circle mr-1"></i> Basic
</button>
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'results' ? 'bg-emerald-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'results'">
<i class="fa-solid fa-flask mr-1"></i> Results
</button>
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'reference' ? 'bg-emerald-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'reference'">
<i class="fa-solid fa-ruler-combined mr-1"></i> Ref
</button>
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'valueset' ? 'bg-emerald-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'valueset'">
<i class="fa-solid fa-list-ul mr-1"></i> VSet
</button>
</div>
<!-- Form -->
<div class="space-y-4">
<!-- Basic Info -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Test Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.TestSiteName && 'input-error'"
x-model="form.TestSiteName"
placeholder="WBC Count"
/>
<label class="label" x-show="errors.TestSiteName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.TestSiteName"></span>
</label>
<!-- Tab: Basic Information (includes Org, Sample, and Seq) -->
<div x-show="activeTab === 'basic'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<!-- Basic Info Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-info-circle text-emerald-500"></i> Basic Information
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Parameter Code <span class="text-error">*</span></span>
</label>
<input type="text" class="input input-bordered font-mono uppercase w-full"
:class="errors.TestSiteCode && 'input-error'" x-model="form.TestSiteCode"
placeholder="e.g., RBC, WBC, HGB" maxlength="10" />
<label class="label" x-show="errors.TestSiteCode">
<span class="label-text-alt text-error text-xs" x-text="errors.TestSiteCode"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Parameter Name <span class="text-error">*</span></span>
</label>
<input type="text" class="input input-bordered w-full" :class="errors.TestSiteName && 'input-error'"
x-model="form.TestSiteName" placeholder="e.g., Red Blood Cell Count" />
<label class="label" x-show="errors.TestSiteName">
<span class="label-text-alt text-error text-xs">Parameter name is required</span>
</label>
</div>
</div>
<div class="mt-3">
<label class="label">
<span class="label-text font-medium text-sm">Description</span>
</label>
<textarea class="textarea textarea-bordered w-full" x-model="form.Description"
placeholder="Optional description..." rows="2"></textarea>
</div>
</div>
<!-- Organization Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-building text-emerald-500"></i> Organization
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Discipline</span>
</label>
<select class="select select-bordered w-full" x-model="form.DisciplineID">
<option value="">Select Discipline</option>
<option value="1">Hematology</option>
<option value="2">Chemistry</option>
<option value="3">Microbiology</option>
<option value="4">Urinalysis</option>
<option value="5">Immunology</option>
<option value="6">Serology</option>
<option value="10">General</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Department</span>
</label>
<select class="select select-bordered w-full" x-model="form.DepartmentID">
<option value="">Select Department</option>
<option value="1">Lab Hematology</option>
<option value="2">Lab Chemistry</option>
<option value="3">Lab Microbiology</option>
<option value="4">Lab Urinalysis</option>
<option value="5">Lab Immunology</option>
</select>
</div>
</div>
</div>
<!-- Sample Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-vial text-emerald-500"></i> Sample
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Sample Type</span>
</label>
<select class="select select-bordered w-full" x-model="form.SampleType">
<option value="">Select Sample Type</option>
<option value="SERUM">Serum</option>
<option value="PLASMA">Plasma</option>
<option value="BLOOD">Whole Blood</option>
<option value="URINE">Urine</option>
<option value="CSF">CSF</option>
<option value="OTHER">Other</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Method</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.Method"
placeholder="e.g., Automated Cell Counter" />
</div>
</div>
</div>
<!-- Sequencing Section -->
<div>
<label class="label">
<span class="label-text font-medium">Test Code <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input font-mono"
:class="errors.TestSiteCode && 'input-error'"
x-model="form.TestSiteCode"
placeholder="WBC"
/>
<label class="label" x-show="errors.TestSiteCode">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.TestSiteCode"></span>
</label>
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-list-ol text-emerald-500"></i> Sequencing & Visibility
</h4>
<div class="grid grid-cols-4 gap-3">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Seq (Screen)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.SeqScr" placeholder="0" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Seq (Report)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.SeqRpt" placeholder="0" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Indent</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.IndentLeft"
placeholder="0" />
</div>
<div class="flex items-center">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.CountStat" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Count in Statistics</span>
</label>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleScr" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Visible on Screen</span>
</label>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleRpt" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Visible on Report</span>
</label>
</div>
</div>
</div>
<!-- Test Type & Method -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Test Type</span>
</label>
<input type="text" class="input input-disabled bg-base-200" x-model="form.TestTypeName" readonly />
<!-- Tab: Result Configuration (includes Sample & Method) -->
<div x-show="activeTab === 'results'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<!-- Result Configuration -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-flask text-emerald-500"></i> Result Configuration
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Result Type</span>
</label>
<select class="select select-bordered w-full" x-model="form.ResultType">
<option value="">Select Result Type</option>
<option value="NMRIC">Numeric</option>
<option value="TEXT">Text</option>
<option value="VSET">Value Set (Select)</option>
<option value="RANGE">Range with Reference</option>
<option value="DTTM">Date/Time</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Reference Type</span>
</label>
<select class="select select-bordered w-full" x-model="form.RefType">
<option value="">Select Reference Type</option>
<option value="NMRC">Numeric Range</option>
<option value="TEXT">Text Reference</option>
<option value="AGE">Age-based</option>
<option value="GENDER">Gender-based</option>
<option value="NONE">No Reference</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Unit 1</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.Unit1"
placeholder="e.g., 10^6/µL, g/dL" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Unit 2 (SI)</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.Unit2"
placeholder="e.g., 10^12/L, g/L" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Decimal Places</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.Decimal" placeholder="2"
min="0" max="10" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Expected TAT (min)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.ExpectedTAT"
placeholder="30" />
</div>
</div>
</div>
<!-- Sample & Method -->
<div>
<label class="label">
<span class="label-text font-medium">Method</span>
</label>
<select class="select" x-model="form.Method">
<option value="">Select Method</option>
<template x-for="m in methodsList" :key="m.VID">
<option :value="m.VValue" x-text="m.VDesc || m.VValue"></option>
</template>
</select>
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-vial text-emerald-500"></i> Sample & Method
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Sample Type</span>
</label>
<select class="select select-bordered w-full" x-model="form.SampleType">
<option value="">Select Sample Type</option>
<option value="SERUM">Serum</option>
<option value="PLASMA">Plasma</option>
<option value="BLOOD">Whole Blood</option>
<option value="URINE">Urine</option>
<option value="CSF">CSF</option>
<option value="OTHER">Other</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Method</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.Method"
placeholder="e.g., Automated Cell Counter" />
</div>
</div>
</div>
</div>
<!-- Specimen & Container -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Specimen Type</span>
</label>
<select class="select" x-model="form.SpcType">
<option value="">Select Specimen</option>
<template x-for="s in specimenTypesList" :key="s.VID || s.id">
<option :value="s.VValue" x-text="s.VDesc || s.VValue"></option>
</template>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Container</span>
</label>
<select class="select" x-model="form.ConDefID">
<option value="">Select Container</option>
<template x-for="c in containersList" :key="c.ConDefID || c.id">
<option :value="c.ConDefID" x-text="c.ConCode || c.name"></option>
</template>
</select>
</div>
</div>
<!-- Volume & Unit -->
<div class="grid grid-cols-3 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Volume Required</span>
</label>
<input
type="number"
step="0.1"
class="input"
x-model="form.VolumeRequired"
placeholder="2.0"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Volume Unit</span>
</label>
<select class="select" x-model="form.VolumeUnit">
<option value="mL">mL</option>
<option value="uL">µL</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Result Unit</span>
</label>
<input
type="text"
class="input"
x-model="form.ResultUnit"
placeholder="cells/µL"
/>
</div>
</div>
<!-- Description -->
<div>
<label class="label">
<span class="label-text font-medium">Description</span>
</label>
<textarea
class="input h-20 pt-2"
x-model="form.Description"
placeholder="Test description..."
></textarea>
</div>
<!-- Sequence & Visibility -->
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-2">
<!-- Tab: Reference Range -->
<div x-show="activeTab === 'reference'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<div class="grid grid-cols-4 gap-3">
<div>
<label class="label">
<span class="label-text font-medium">Seq (Screen)</span>
<span class="label-text font-medium text-sm">Min Normal</span>
</label>
<input type="number" class="input text-center" x-model="form.SeqScr" />
<input type="number" class="input input-bordered w-full" x-model.number="form.RefMin" placeholder="0"
step="any" />
</div>
<div>
<label class="label">
<span class="label-text font-medium">Seq (Report)</span>
<span class="label-text font-medium text-sm">Max Normal</span>
</label>
<input type="number" class="input text-center" x-model="form.SeqRpt" />
<input type="number" class="input input-bordered w-full" x-model.number="form.RefMax" placeholder="100"
step="any" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Critical Low</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.CriticalLow"
placeholder="Alert low value" step="any" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Critical High</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.CriticalHigh"
placeholder="Alert high value" step="any" />
</div>
</div>
<div>
<div class="mt-4">
<label class="label">
<span class="label-text font-medium">Site</span>
<span class="label-text font-medium text-sm">Reference Text (for text-based reference)</span>
</label>
<select class="select" x-model="form.SiteID">
<template x-for="s in sitesList" :key="s.SiteID">
<option :value="s.SiteID" x-text="s.SiteName"></option>
</template>
</select>
<input type="text" class="input input-bordered w-full" x-model="form.RefText"
placeholder="e.g., Negative/Positive, Normal/Abnormal" />
</div>
</div>
<!-- Options -->
<div class="flex items-center gap-6 p-4 rounded-xl border border-slate-100 bg-slate-50/50">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.VisibleScr" :true-value="1" :false-value="0" />
<span class="label-text">Visible in Screen</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.VisibleRpt" :true-value="1" :false-value="0" />
<span class="label-text">Visible in Report</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.CountStat" :true-value="1" :false-value="0" />
<span class="label-text">Count in Statistics</span>
</label>
<!-- Tab: Value Set Selection -->
<div x-show="activeTab === 'valueset'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<template x-if="form.ResultType === 'VSET'">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Value Set</span>
</label>
<select class="select select-bordered w-full" x-model="form.ValueSetID">
<option value="">Select Value Set</option>
<option value="1">Positive/Negative</option>
<option value="2">+1 to +4</option>
<option value="3">Absent/Present</option>
<option value="4">Normal/Abnormal</option>
<option value="5">Trace/+/++/+++</option>
<option value="6">Yes/No</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Default Value</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.DefaultValue"
placeholder="Default selection" />
</div>
</div>
</template>
<template x-if="form.ResultType !== 'VSET'">
<div class="p-8 text-center rounded-lg border bg-opacity-30"
style="background: rgb(var(--color-bg-secondary)); border-color: rgb(var(--color-border));">
<i class="fa-solid fa-list-ul text-4xl opacity-40 mb-2"></i>
<p class="opacity-60">Value Set configuration is only available when Result Type is "Value Set (Select)"</p>
</div>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-8 pt-6" style="border-top: 1px solid rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">Cancel</button>
<button class="btn btn-info flex-1" @click="save()" :disabled="saving">
<span x-show="saving" class="spinner spinner-sm"></span>
<div class="flex gap-3 mt-6 pt-4 border-t" style="border-color: rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">
<i class="fa-solid fa-times mr-2"></i> Cancel
</button>
<button class="btn btn-success flex-1" @click="save()" :disabled="saving">
<span x-show="saving" class="loading loading-spinner loading-sm mr-2"></span>
<i x-show="!saving" class="fa-solid fa-save mr-2"></i>
<span x-text="saving ? 'Saving...' : 'Save Test'"></span>
<span x-text="saving ? 'Saving...' : (isEditing ? 'Update Parameter' : 'Create Parameter')"></span>
</button>
</div>
</div>
</div>
</div>

View File

@ -1,262 +1,375 @@
<!-- Lab Test Form Modal -->
<div
x-show="showModal && currentDialogType === 'TEST'"
x-cloak
class="modal-overlay"
@click.self="closeModal()"
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"
>
<div
class="modal-content p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"
@click.stop
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Test Dialog (Base - for TEST type) -->
<div x-show="showModal && (getTypeCode(form.TestType) === 'TEST' || form.TypeCode === 'TEST')" x-cloak
class="modal-overlay" @click.self="closeModal()" 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">
<div class="modal-content p-6 max-w-3xl w-full max-h-[90vh] overflow-y-auto" @click.stop
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-microscope" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Lab Test' : 'New Lab Test'"></span>
</h3>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-indigo-100 flex items-center justify-center">
<i class="fa-solid fa-flask text-indigo-600 text-lg"></i>
</div>
<div>
<h3 class="font-bold text-lg" style="color: rgb(var(--color-text));">
<span x-text="isEditing ? 'Edit Test' : 'New Test'"></span>
</h3>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">Laboratory Test Definition</p>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-square" @click="closeModal()">
<i class="fa-solid fa-times"></i>
</button>
</div>
<!-- Tabs -->
<div class="flex border-b mb-6" style="border-color: rgb(var(--color-border));">
<button
class="px-6 py-2 font-medium text-sm transition-colors border-b-2"
:class="form.dialogTab === 'general' ? 'border-primary text-primary' : 'border-transparent text-slate-500 hover:text-slate-700'"
style="--tw-text-opacity: 1; border-color: transition;"
@click="form.dialogTab = 'general'"
:style="form.dialogTab === 'general' ? 'border-color: rgb(var(--color-primary)); color: rgb(var(--color-primary));' : ''"
>
General
<!-- Test Type Badge -->
<div class="mb-4">
<span class="badge badge-info gap-1">
<i class="fa-solid fa-flask"></i>
<span x-text="form.TypeCode || getTypeName(form.TestType)"></span>
</span>
</div>
<!-- Tabs Navigation -->
<div class="flex flex-wrap gap-1 mb-4 p-1 rounded-lg"
style="background: rgb(var(--color-bg-secondary)); border: 1px solid rgb(var(--color-border));">
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'basic' ? 'bg-indigo-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'basic'">
<i class="fa-solid fa-info-circle mr-1"></i> Basic
</button>
<button
class="px-6 py-2 font-medium text-sm transition-colors border-b-2"
:class="form.dialogTab === 'reff' ? 'border-primary text-primary' : 'border-transparent text-slate-500 hover:text-slate-700'"
@click="form.dialogTab = 'reff'"
:style="form.dialogTab === 'reff' ? 'border-color: rgb(var(--color-primary)); color: rgb(var(--color-primary));' : ''"
>
Reff
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'results' ? 'bg-indigo-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'results'">
<i class="fa-solid fa-flask mr-1"></i> Results
</button>
<button class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="activeTab === 'reference' ? 'bg-indigo-500 text-white shadow' : 'hover:bg-opacity-50'"
style="color: rgb(var(--color-text));" @click="activeTab = 'reference'">
<i class="fa-solid fa-ruler-combined mr-1"></i> Ref
</button>
</div>
<!-- Form Content -->
<!-- Form -->
<div class="space-y-4">
<!-- General Tab -->
<div x-show="form.dialogTab === 'general'" class="space-y-4">
<!-- Tab: Basic Information (includes Org, Sample, and Seq) -->
<div x-show="activeTab === 'basic'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<!-- Basic Info Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-info-circle text-indigo-500"></i> Basic Information
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Test Code <span class="text-error">*</span></span>
</label>
<input type="text" class="input input-bordered font-mono uppercase w-full"
:class="errors.TestSiteCode && 'input-error'" x-model="form.TestSiteCode"
placeholder="e.g., CBC, GLU, HB" maxlength="10" />
<label class="label" x-show="errors.TestSiteCode">
<span class="label-text-alt text-error text-xs" x-text="errors.TestSiteCode"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Test Name <span class="text-error">*</span></span>
</label>
<input type="text" class="input input-bordered w-full" :class="errors.TestSiteName && 'input-error'"
x-model="form.TestSiteName" placeholder="e.g., Complete Blood Count" />
<label class="label" x-show="errors.TestSiteName">
<span class="label-text-alt text-error text-xs">Test name is required</span>
</label>
</div>
</div>
<div class="mt-3">
<label class="label">
<span class="label-text font-medium text-sm">Description</span>
</label>
<textarea class="textarea textarea-bordered w-full" x-model="form.Description"
placeholder="Optional description..." rows="2"></textarea>
</div>
</div>
<!-- Organization Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-building text-indigo-500"></i> Organization
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Discipline</span>
</label>
<select class="select select-bordered w-full" x-model="form.DisciplineID">
<option value="">Select Discipline</option>
<option value="1">Hematology</option>
<option value="2">Chemistry</option>
<option value="3">Microbiology</option>
<option value="4">Urinalysis</option>
<option value="5">Immunology</option>
<option value="6">Serology</option>
<option value="10">General</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Department</span>
</label>
<select class="select select-bordered w-full" x-model="form.DepartmentID">
<option value="">Select Department</option>
<option value="1">Lab Hematology</option>
<option value="2">Lab Chemistry</option>
<option value="3">Lab Microbiology</option>
<option value="4">Lab Urinalysis</option>
<option value="5">Lab Immunology</option>
</select>
</div>
</div>
</div>
<!-- Sample & Method Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-vial text-indigo-500"></i> Sample & Method
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Sample Type</span>
</label>
<select class="select select-bordered w-full" x-model="form.SampleType">
<option value="">Select Sample Type</option>
<option value="SERUM">Serum</option>
<option value="PLASMA">Plasma</option>
<option value="BLOOD">Whole Blood</option>
<option value="URINE">Urine</option>
<option value="CSF">CSF</option>
<option value="OTHER">Other</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Method</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.Method"
placeholder="e.g., CBC Analyzer, Hexokinase" />
</div>
</div>
</div>
<!-- Sequencing Section -->
<div>
<label class="label">
<span class="label-text font-medium">Test Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.TestSiteName && 'input-error'"
x-model="form.TestSiteName"
placeholder="Glucose Fasting"
/>
<label class="label" x-show="errors.TestSiteName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.TestSiteName"></span>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Test Code <span style="color: rgb(var(--color-error));">*</span></span>
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-list-ol text-indigo-500"></i> Sequencing & Visibility
</h4>
<div class="grid grid-cols-4 gap-3">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Seq (Screen)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.SeqScr" placeholder="0" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Seq (Report)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.SeqRpt" placeholder="0" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Indent</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.IndentLeft"
placeholder="0" />
</div>
<div class="flex items-center">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.CountStat" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Count in Statistics</span>
</label>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleScr" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Visible on Screen</span>
</label>
<input
type="text"
class="input font-mono"
:class="errors.TestSiteCode && 'input-error'"
x-model="form.TestSiteCode"
placeholder="GLUC"
/>
<label class="label" x-show="errors.TestSiteCode">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.TestSiteCode"></span>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleRpt" :true-value="1"
:false-value="0" />
<span class="label-text text-sm">Visible on Report</span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Test Type <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<select class="select" x-model="form.TestType" :class="errors.TestType && 'input-error'">
<option value="">Select Type</option>
<template x-for="t in typesList" :key="t.VID">
<option :value="t.VID" x-text="t.VDesc"></option>
</template>
</select>
<label class="label" x-show="errors.TestType">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.TestType"></span>
</label>
</div>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Description</span>
</label>
<textarea
class="input h-20 pt-2"
x-model="form.Description"
placeholder="Internal test description..."
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Site</span>
</label>
<select class="select" x-model="form.SiteID">
<template x-for="s in sitesList" :key="s.SiteID">
<option :value="s.SiteID" x-text="s.SiteName"></option>
</template>
</select>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="label">
<span class="label-text font-medium">Seq (Scr)</span>
</label>
<input type="number" class="input text-center" x-model="form.SeqScr" />
</div>
<div>
<label class="label">
<span class="label-text font-medium">Seq (Rpt)</span>
</label>
<input type="number" class="input text-center" x-model="form.SeqRpt" />
</div>
</div>
</div>
<div class="flex items-center gap-6 p-4 rounded-xl border border-slate-100 bg-slate-50/50">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.VisibleScr" :true-value="1" :false-value="0" />
<span class="label-text">Visible in Screen</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.VisibleRpt" :true-value="1" :false-value="0" />
<span class="label-text">Visible in Report</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.CountStat" :true-value="1" :false-value="0" />
<span class="label-text">Count in Statistics</span>
</label>
</div>
</div>
<!-- Reff Tab -->
<div x-show="form.dialogTab === 'reff'" class="space-y-4" x-cloak>
<div>
<label class="label">
<span class="label-text font-medium">Ref Type</span>
</label>
<select class="select" x-model="form.RefType">
<option value="">Select Ref Type</option>
<template x-for="rt in refTypesList" :key="rt.VID">
<option :value="rt.VValue" x-text="rt.VDesc"></option>
</template>
</select>
<!-- Tab: Result Configuration (includes Sample & Method) -->
<div x-show="activeTab === 'results'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<!-- Result Configuration -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-flask text-indigo-500"></i> Result Configuration
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Result Type</span>
</label>
<select class="select select-bordered w-full" x-model="form.ResultType">
<option value="">Select Result Type</option>
<option value="NMRIC">Numeric</option>
<option value="TEXT">Text</option>
<option value="VSET">Value Set (Select)</option>
<option value="RANGE">Range with Reference</option>
<option value="CALC">Calculated</option>
<option value="DTTM">Date/Time</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Reference Type</span>
</label>
<select class="select select-bordered w-full" x-model="form.RefType">
<option value="">Select Reference Type</option>
<option value="NMRC">Numeric Range</option>
<option value="TEXT">Text Reference</option>
<option value="AGE">Age-based</option>
<option value="GENDER">Gender-based</option>
<option value="NONE">No Reference</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Unit 1</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.Unit1"
placeholder="e.g., mg/dL, U/L, %" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Unit 2</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.Unit2"
placeholder="e.g., mmol/L (optional)" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Decimal Places</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.Decimal" placeholder="2"
min="0" max="10" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Expected TAT (min)</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.ExpectedTAT"
placeholder="60" />
</div>
</div>
</div>
<!-- Numeric Reference Range -->
<template x-if="form.RefType === 'NMRC'">
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label"><span class="label-text font-medium">Ref Low</span></label>
<input type="text" class="input" x-model="form.RefLow" placeholder="0.00" />
</div>
<div>
<label class="label"><span class="label-text font-medium">Ref High</span></label>
<input type="text" class="input" x-model="form.RefHigh" placeholder="10.00" />
</div>
<!-- Sample & Method -->
<div>
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-vial text-indigo-500"></i> Sample & Method
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Sample Type</span>
</label>
<select class="select select-bordered w-full" x-model="form.SampleType">
<option value="">Select Sample Type</option>
<option value="SERUM">Serum</option>
<option value="PLASMA">Plasma</option>
<option value="BLOOD">Whole Blood</option>
<option value="URINE">Urine</option>
<option value="CSF">CSF</option>
<option value="OTHER">Other</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4 border-t pt-4" style="border-color: rgb(var(--color-border));">
<div>
<label class="label"><span class="label-text font-medium text-error">Crit Low</span></label>
<input type="text" class="input border-error/30" x-model="form.CritLow" placeholder="0.00" />
</div>
<div>
<label class="label"><span class="label-text font-medium text-error">Crit High</span></label>
<input type="text" class="input border-error/30" x-model="form.CritHigh" placeholder="20.00" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label"><span class="label-text font-medium">Unit</span></label>
<input type="text" class="input" x-model="form.Unit1" placeholder="mg/dL" />
</div>
<div>
<label class="label"><span class="label-text font-medium">Decimals</span></label>
<input type="number" class="input" x-model="form.Decimal" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Method</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.Method"
placeholder="e.g., CBC Analyzer, Hexokinase" />
</div>
</div>
</template>
<!-- Descriptive Text -->
<template x-if="form.RefType === 'TEXT'">
<div>
<label class="label">
<span class="label-text font-medium">Default Reference Text</span>
</label>
<textarea
class="input h-32 pt-2"
x-model="form.RefText"
placeholder="e.g. Negative"
></textarea>
</div>
</template>
<!-- List / Value Set -->
<template x-if="form.RefType === 'LIST'">
<div>
<label class="label">
<span class="label-text font-medium">Select Value Set</span>
</label>
<select class="select" x-model="form.RefVSet">
<option value="">Select a value set...</option>
<template x-for="v in vsetDefsList" :key="v.VSetDefID">
<option :value="v.VSetDefID" x-text="v.VSDesc"></option>
</template>
</select>
<div class="mt-4 p-4 rounded-lg bg-blue-50 border border-blue-100 flex items-start gap-3">
<i class="fa-solid fa-circle-info text-blue-500 mt-0.5"></i>
<p class="text-xs text-blue-700 leading-relaxed">
Selecting a value set will restrict result entry to predefined values and use them for reference matching.
</p>
</div>
</div>
</template>
</div>
</div>
<!-- Tab: Reference Range -->
<div x-show="activeTab === 'reference'" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-x-4"
x-transition:enter-end="opacity-100 transform translate-x-0">
<div class="grid grid-cols-4 gap-3">
<div>
<label class="label">
<span class="label-text font-medium text-sm">Min Normal</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.RefMin" placeholder="0"
step="any" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Max Normal</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.RefMax" placeholder="100"
step="any" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Critical Low</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.CriticalLow"
placeholder="Optional" step="any" />
</div>
<div>
<label class="label">
<span class="label-text font-medium text-sm">Critical High</span>
</label>
<input type="number" class="input input-bordered w-full" x-model.number="form.CriticalHigh"
placeholder="Optional" step="any" />
</div>
</div>
<div class="mt-4">
<label class="label">
<span class="label-text font-medium text-sm">Reference Text</span>
</label>
<input type="text" class="input input-bordered w-full" x-model="form.RefText"
placeholder="e.g., 70-100 mg/dL (for text reference type)" />
</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-8 pt-6" style="border-top: 1px solid rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">Cancel</button>
<div class="flex gap-3 mt-6 pt-4 border-t" style="border-color: rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">
<i class="fa-solid fa-times mr-2"></i> Cancel
</button>
<button class="btn btn-primary flex-1" @click="save()" :disabled="saving">
<span x-show="saving" class="spinner spinner-sm"></span>
<span x-show="saving" class="loading loading-spinner loading-sm mr-2"></span>
<i x-show="!saving" class="fa-solid fa-save mr-2"></i>
<span x-text="saving ? 'Saving...' : 'Save Test'"></span>
<span x-text="saving ? 'Saving...' : (isEditing ? 'Update Test' : 'Create Test')"></span>
</button>
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,120 +0,0 @@
<!-- Title Test Form Modal -->
<div
x-show="showModal && currentDialogType === 'TITLE'"
x-cloak
class="modal-overlay"
@click.self="closeModal()"
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"
>
<div
class="modal-content p-6 max-w-2xl w-full"
@click.stop
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-heading" style="color: rgb(var(--color-warning));"></i>
<span x-text="isEditing ? 'Edit Report Title' : 'New Report Title'"></span>
</h3>
<button class="btn btn-ghost btn-sm btn-square" @click="closeModal()">
<i class="fa-solid fa-times"></i>
</button>
</div>
<!-- Form -->
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Title Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.TestSiteName && 'input-error'"
x-model="form.TestSiteName"
placeholder="Hematology Results"
/>
<label class="label" x-show="errors.TestSiteName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.TestSiteName"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Title Code <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input font-mono"
:class="errors.TestSiteCode && 'input-error'"
x-model="form.TestSiteCode"
placeholder="HEMO"
/>
<label class="label" x-show="errors.TestSiteCode">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.TestSiteCode"></span>
</label>
</div>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Description</span>
</label>
<textarea
class="input h-16 pt-2"
x-model="form.Description"
placeholder="Title description..."
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Seq (Screen)</span>
</label>
<input type="number" class="input text-center" x-model="form.SeqScr" />
</div>
<div>
<label class="label">
<span class="label-text font-medium">Seq (Report)</span>
</label>
<input type="number" class="input text-center" x-model="form.SeqRpt" />
</div>
</div>
<div class="flex items-center gap-6 p-4 rounded-xl border border-slate-100 bg-slate-50/50">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.VisibleScr" :true-value="1" :false-value="0" />
<span class="label-text">Visible in Screen</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox" x-model="form.VisibleRpt" :true-value="1" :false-value="0" />
<span class="label-text">Visible in Report</span>
</label>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-8 pt-6" style="border-top: 1px solid rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">Cancel</button>
<button class="btn btn-warning flex-1" @click="save()" :disabled="saving">
<span x-show="saving" class="spinner spinner-sm"></span>
<i x-show="!saving" class="fa-solid fa-save mr-2"></i>
<span x-text="saving ? 'Saving...' : 'Save Title'"></span>
</button>
</div>
</div>
</div>

View File

@ -1,272 +0,0 @@
CREATE TABLE `coding_sys` (
`coding_sys_id` integer PRIMARY KEY AUTO_INCREMENT,
`abb` varchar(10) UNIQUE,
`name` varchar(255),
`description` text,
`create_date` datetime,
`end_date` datetime
);
CREATE TABLE `races` (
`race_id` integer PRIMARY KEY AUTO_INCREMENT,
`site_id` integer,
`coding_sys_id` integer,
`name` varchar(255),
`create_date` datetime,
`end_date` datetime
);
CREATE TABLE `religions` (
`religion_id` integer PRIMARY KEY AUTO_INCREMENT,
`site_id` integer,
`coding_sys_id` integer,
`name` varchar(255),
`create_date` datetime,
`end_date` datetime
);
CREATE TABLE `ethnics` (
`ethnic_id` integer PRIMARY KEY AUTO_INCREMENT,
`site_id` integer,
`coding_sys_id` integer,
`name` varchar(255),
`create_date` datetime,
`end_date` datetime
);
CREATE TABLE `countries` (
`country_id` integer PRIMARY KEY AUTO_INCREMENT,
`site_id` integer,
`coding_sys_id` integer,
`name` varchar(255),
`create_date` datetime,
`end_date` datetime
);
CREATE TABLE `patients` (
`pat_id` integer PRIMARY KEY AUTO_INCREMENT,
`pat_num` varchar(255) UNIQUE,
`pat_altnum` varchar(255) UNIQUE,
`prefix` varchar(255),
`name_first` varchar(255),
`name_middle` varchar(255),
`name_maiden` varchar(255),
`name_last` varchar(255),
`suffix` varchar(255),
`name_alias` varchar(255),
`gender` varchar(255),
`birth_place` varchar(255),
`birth_date` date,
`address_1` varchar(255),
`address_2` varchar(255),
`address_3` varchar(255),
`city` varchar(255),
`province` varchar(255),
`zip` varchar(255),
`email_1` varchar(255),
`email_2` varchar(255),
`phone` varchar(255),
`mobile_phone` varchar(255),
`mother` varchar(255),
`account_number` varchar(255),
`marital_status` varchar(255),
`country_id` integer,
`race_id` integer,
`religion_id` integer,
`ethnic_id` integer,
`citizenship` varchar(255),
`death` bit,
`death_date` datetime,
`link_to` integer,
`create_date` datetime,
`del_date` datetime
);
CREATE TABLE `pat_comments` (
`pat_com_id` integer PRIMARY KEY AUTO_INCREMENT,
`pat_id` integer,
`comment_text` text,
`user_id` integer,
`create_date` datetime,
`del_date` datetime
);
CREATE TABLE `pat_identities` (
`pat_idt_id` integer PRIMARY KEY AUTO_INCREMENT,
`pat_id` integer,
`identity_type` varchar(255),
`identity_num` varchar(255),
`effective_date` datetime,
`expiration_date` datetime,
`create_date` datetime,
`del_date` datetime
);
CREATE TABLE `pat_diagnose` (
`pat_dia_id` integer PRIMARY KEY AUTO_INCREMENT,
`pat_id` integer,
`diag_code` varchar(255),
`diag_comment` varchar(255),
`create_date` datetime,
`end_date` datetime,
`archive_date` datetime,
`del_date` datetime
);
CREATE TABLE `pat_visits` (
`pv_id` integer PRIMARY KEY AUTO_INCREMENT,
`pv_num` varchar(255) UNIQUE,
`pat_id` integer,
`episode_number` integer,
`pv_class_id` integer,
`bill_account` varchar(255),
`bill_status` integer,
`create_date` datetime,
`end_date` datetime,
`archive_date` datetime,
`del_date` datetime
);
CREATE TABLE `pv_adts` (
`pv_adt_id` integer PRIMARY KEY AUTO_INCREMENT,
`pv_id` integer,
`pv_adt_num` varchar(255),
`pv_adt_code` varchar(255),
`locid` integer,
`docid` integer,
`reff_docid` integer,
`adm_docid` integer,
`cns_docid` integer,
`create_date` datetime,
`end_date` datetime,
`archive_date` datetime,
`del_date` datetime
);
CREATE TABLE `pv_log` (
`pv_log_id` integer PRIMARY KEY AUTO_INCREMENT
);
CREATE TABLE `requests` (
`req_id` integer PRIMARY KEY AUTO_INCREMENT,
`req_num` varchar(255) UNIQUE,
`req_altnum` varchar(255) UNIQUE,
`pat_id` integer,
`pv_id` integer,
`req_app` varchar(255),
`req_entity` varchar(255),
`req_entity_id` integer,
`loc_id` integer,
`priority` varchar(255),
`att_doid` integer,
`reff_docid` integer,
`adm_docid` integer,
`cns_docid` integer,
`entered_by` varchar(255),
`req_date` datetime,
`eff_date` datetime,
`create_date` datetime,
`end_date` datetime,
`archive_date` datetime,
`del_date` datetime
);
CREATE TABLE `req_comments` (
`req_com_id` integer PRIMARY KEY AUTO_INCREMENT,
`req_id` integer,
`comment_text` text,
`user_id` integer,
`create_date` datetime,
`end_date` datetime,
`archive_date` datetime,
`del_date` datetime
);
CREATE TABLE `req_atts` (
`req_att_id` integer PRIMARY KEY AUTO_INCREMENT,
`req_id` integer,
`address` varchar(255),
`user_id` integer,
`create_date` datetime,
`end_date` datetime,
`archive_date` datetime,
`del_date` datetime
);
CREATE TABLE `req_status` (
`req_status_id` integer PRIMARY KEY AUTO_INCREMENT,
`req_id` integer,
`req_status` varchar(255),
`create_date` datetime,
`end_date` datetime,
`archive_date` datetime,
`del_date` datetime
);
CREATE TABLE `req_logs` (
`req_log_id` integer PRIMARY KEY AUTO_INCREMENT,
`tbl_name` varchar(255),
`record_id` integer,
`fld_name` varchar(255),
`fld_value_prev` varchar(255),
`user_id` integer,
`site_id` integer,
`machine_id` integer,
`session_id` integer,
`app_id` integer,
`process_id` integer,
`webpage_id` integer,
`event_id` integer,
`act_id` integer,
`reason` varchar(255),
`log_date` datetime
);
CREATE TABLE `users` (
`user_id` integer PRIMARY KEY AUTO_INCREMENT,
`username` varchar(255) UNIQUE,
`fullname` varchar(255),
`password` varchar(255),
`create_date` datetime,
`end_date` datetime,
`archive_date` datetime,
`del_date` datetime
);
ALTER TABLE `races` ADD FOREIGN KEY (`coding_sys_id`) REFERENCES `coding_sys` (`coding_sys_id`);
ALTER TABLE `religions` ADD FOREIGN KEY (`coding_sys_id`) REFERENCES `coding_sys` (`coding_sys_id`);
ALTER TABLE `ethnics` ADD FOREIGN KEY (`coding_sys_id`) REFERENCES `coding_sys` (`coding_sys_id`);
ALTER TABLE `countries` ADD FOREIGN KEY (`coding_sys_id`) REFERENCES `coding_sys` (`coding_sys_id`);
ALTER TABLE `patients` ADD FOREIGN KEY (`country_id`) REFERENCES `countries` (`country_id`);
ALTER TABLE `patients` ADD FOREIGN KEY (`race_id`) REFERENCES `races` (`race_id`);
ALTER TABLE `patients` ADD FOREIGN KEY (`religion_id`) REFERENCES `religions` (`religion_id`);
ALTER TABLE `patients` ADD FOREIGN KEY (`ethnic_id`) REFERENCES `ethnics` (`ethnic_id`);
ALTER TABLE `pat_comments` ADD FOREIGN KEY (`pat_id`) REFERENCES `patients` (`pat_id`);
ALTER TABLE `pat_identities` ADD FOREIGN KEY (`pat_id`) REFERENCES `patients` (`pat_id`);
ALTER TABLE `pat_diagnose` ADD FOREIGN KEY (`pat_id`) REFERENCES `patients` (`pat_id`);
ALTER TABLE `pat_visits` ADD FOREIGN KEY (`pat_id`) REFERENCES `patients` (`pat_id`);
ALTER TABLE `pv_adts` ADD FOREIGN KEY (`pv_id`) REFERENCES `pat_visits` (`pv_id`);
ALTER TABLE `requests` ADD FOREIGN KEY (`pat_id`) REFERENCES `patients` (`pat_id`);
ALTER TABLE `requests` ADD FOREIGN KEY (`pv_id`) REFERENCES `pat_visits` (`pv_id`);
ALTER TABLE `req_comments` ADD FOREIGN KEY (`req_id`) REFERENCES `requests` (`req_id`);
ALTER TABLE `req_atts` ADD FOREIGN KEY (`req_id`) REFERENCES `requests` (`req_id`);
ALTER TABLE `req_status` ADD FOREIGN KEY (`req_id`) REFERENCES `requests` (`req_id`);
ALTER TABLE `req_logs` ADD FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`);

View File

@ -1,234 +0,0 @@
table coding_sys {
coding_sys_id integer [pk]
abb varchar(10) [unique]
name varchar
description text
create_date datetime
end_date datetime
}
table races {
race_id integer [pk]
site_id integer
coding_sys_id integer [ref:>coding_sys.coding_sys_id]
name varchar
create_date datetime
end_date datetime
}
table religions {
religion_id integer [pk]
site_id integer
coding_sys_id integer [ref:>coding_sys.coding_sys_id]
name varchar
create_date datetime
end_date datetime
}
table ethnics {
ethnic_id integer [pk]
site_id integer
coding_sys_id integer [ref:>coding_sys.coding_sys_id]
name varchar
create_date datetime
end_date datetime
}
table countries {
country_id integer [pk]
site_id integer
coding_sys_id integer [ref:>coding_sys.coding_sys_id]
name varchar
create_date datetime
end_date datetime
}
table patients {
pat_id integer [pk]
pat_num varchar [unique]
pat_altnum varchar [unique]
prefix varchar
name_first varchar
name_middle varchar
name_maiden varchar
name_last varchar
suffix varchar
name_alias varchar
gender varchar
birth_place varchar
birth_date date
address_1 varchar
address_2 varchar
address_3 varchar
city varchar
province varchar
zip varchar
email_1 varchar
email_2 varchar
phone varchar
mobile_phone varchar
mother varchar
account_number varchar
marital_status varchar
country_id integer [ref:>countries.country_id]
race_id integer [ref:>races.race_id]
religion_id integer [ref:>religions.religion_id]
ethnic_id integer [ref:>ethnics.ethnic_id]
citizenship varchar
death bit
death_date datetime
link_to integer
create_date datetime
del_date datetime
}
table pat_comments {
pat_com_id integer [pk]
pat_id integer [ref:>patients.pat_id]
comment_text text
user_id integer
create_date datetime
del_date datetime
}
table pat_identities {
pat_idt_id integer [pk]
pat_id integer [ref:>patients.pat_id]
identity_type varchar
identity_num varchar
effective_date datetime
expiration_date datetime
create_date datetime
del_date datetime
}
table pat_diagnose {
pat_dia_id integer [pk]
pat_id integer [ref:>patients.pat_id]
diag_code varchar
diag_comment varchar
create_date datetime
end_date datetime
archive_date datetime
del_date datetime
}
table pat_visits {
pv_id integer [pk]
pv_num varchar [unique]
pat_id integer [ref:>patients.pat_id]
episode_number integer
pv_class_id integer
bill_account varchar
bill_status integer
create_date datetime
end_date datetime
archive_date datetime
del_date datetime
}
table pv_adts {
pv_adt_id integer [pk]
pv_id integer [ref:>pat_visits.pv_id]
pv_adt_num varchar
pv_adt_code varchar
locid integer
docid integer
reff_docid integer
adm_docid integer
cns_docid integer
create_date datetime
end_date datetime
archive_date datetime
del_date datetime
}
table pv_log {
pv_log_id integer [pk]
}
table requests {
req_id integer [pk]
req_num varchar [unique]
req_altnum varchar [unique]
pat_id integer [ref:>patients.pat_id]
pv_id integer [ref:>pat_visits.pv_id]
req_app varchar
req_entity varchar
req_entity_id integer
loc_id integer
priority varchar
att_doid integer
reff_docid integer
adm_docid integer
cns_docid integer
entered_by varchar
req_date datetime
eff_date datetime
create_date datetime
end_date datetime
archive_date datetime
del_date datetime
}
table req_comments {
req_com_id integer [pk]
req_id integer [ref:>requests.req_id]
comment_text text
user_id integer
create_date datetime
end_date datetime
archive_date datetime
del_date datetime
}
table req_atts {
req_att_id integer [pk]
req_id integer [ref:>requests.req_id]
address varchar
user_id integer
create_date datetime
end_date datetime
archive_date datetime
del_date datetime
}
table req_status {
req_status_id integer [pk]
req_id integer [ref:>requests.req_id]
req_status varchar
create_date datetime
end_date datetime
archive_date datetime
del_date datetime
}
table req_logs {
req_log_id integer [pk]
tbl_name varchar
record_id integer
fld_name varchar
fld_value_prev varchar
user_id integer [ref:>users.user_id]
site_id integer
machine_id integer
session_id integer
app_id integer
process_id integer
webpage_id integer
event_id integer
act_id integer
reason varchar
log_date datetime
}
table users {
user_id integer [pk]
username varchar [unique]
fullname varchar
password varchar
create_date datetime
end_date datetime
archive_date datetime
del_date datetime
}

View File

@ -1,689 +0,0 @@
# Plan: Multiple Reference Ranges with Advanced Dialog
## Overview
Refactor the "Reff" tab to support multiple reference ranges using the existing `refnum` table schema.
## Existing Database Schema (refnum table)
| Field | Type | Description |
|-------|------|-------------|
| RefNumID | INT AUTO_INCREMENT | Primary key |
| SiteID | INT | Site identifier |
| TestSiteID | INT | Links to test |
| SpcType | INT | Specimen type |
| Sex | INT | Gender (from valueset) |
| Criteria | VARCHAR(100) | Additional criteria |
| AgeStart | INT | Age range start |
| AgeEnd | INT | Age range end |
| **NumRefType** | INT | **Input format: 1=NMRC, 2=TH, 3=TEXT, 4=LIST** |
| **RangeType** | INT | **Result category: 1=REF, 2=CRTC, 3=VAL, 4=RERUN** |
| LowSign | INT | Low operator: 1='<', 2='<=', 3='>=', 4='>', 5='<>' |
| Low | INT | Low value |
| HighSign | INT | High operator |
| High | INT | High value |
| Display | INT | Display order |
| **Flag** | VARCHAR(10) | **Like Label (e.g., "Negative", "Borderline")** |
| Interpretation | VARCHAR(255) | Interpretation text |
| Notes | VARCHAR(255) | Notes |
| CreateDate | Datetime | Creation timestamp |
| StartDate | Datetime | Start date |
| EndDate | Datetime | Soft delete |
---
## Key Concept: NumRefType vs RangeType
| Aspect | NumRefType | RangeType |
|--------|------------|-----------|
| **Location** | Main Reff Tab + Advanced Dialog | Advanced Dialog |
| **Purpose** | Input format | Result categorization |
| **Values** | 1=NMRC, 2=TH, 3=TEXT, 4=LIST | 1=REF, 2=CRTC, 3=VAL, 4=RERUN |
| **Database Field** | NumRefType | RangeType |
---
## UI Design
### Main Reff Tab (Simple)
```
┌─────────────────────────────────────────────────────────────────┐
│ Reference Ranges │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Ref Type: [ Numeric (NMRC) ▼ ] │
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ For Numeric (with operators > < <= >=): ││
│ │ Ref Low: [0.00 ] Ref High: [100.00 ] ││
│ │ Crit Low: [<55.00 ] Crit High: [>115.00 ] ││
│ │ ││
│ │ Examples: 0-100, <50, >=100, <>0 (not equal to 0) ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ For Threshold: ││
│ │ Below Text: [Below Normal] Below Value: [<] [50] ││
│ │ Above Text: [Above Normal] Above Value: [>] [150] ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ [Advanced Settings ▼] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Advanced Dialog (Multiple Reference Ranges)
```
┌───────────────────────────────────────────────────────────────────────────────┐
│ Advanced Reference Ranges [X]Close│
├───────────────────────────────────────────────────────────────────────────────┤
│ │
│ [Add RefType ▼] [Add Button] │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ RefType │ Flag/Label │ RangeType │ Sex │ Age │ Low │ High │ [×] │ │
│ │─────────┼────────────┼───────────┼─────┼─────┼────────┼────────┼───────│ │
│ │ NMRC │ Negative │ REF (1) │ All │ 0-150│ 0 │ 25 │ [×] │ │
│ │ NMRC │ Borderline │ REF (1) │ All │ 0-150│ 25 │ 50 │ [×] │ │
│ │ NMRC │ Positive │ REF (1) │ All │ 0-150│ 50 │ │ [×] │ │
│ │ TEXT │ Negative │ REF (1) │ All │ 0-150│ │ │ [×] │ │
│ │ TH │ Low │ REF (1) │ All │ 0-150│ <50 [×]
│ │ NMRC │ Critical │ CRTC (2) │ All │ 0-150│ <55 >115 │ [×] │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ [Cancel] [Save Advanced Ranges] │
│ │
└───────────────────────────────────────────────────────────────────────────────┘
```
**Key Features:**
- **RefType** column: NMRC (1), TH (2), TEXT (3), LIST (4)
- **RangeType** column: REF (1), CRTC (2), VAL (3), RERUN (4)
- **Flag** column: Display label for the result (e.g., "Negative", "Borderline")
- **Low/High** columns: Support operators via LowSign/HighSign fields
---
## Implementation Plan
### Phase 1: Backend Changes (Tests.php Controller)
#### 1.1 Add RefType and RangeType constants
```php
// At top of Tests.php
const REFTYPE_NMRC = 1;
const REFTYPE_TH = 2;
const REFTYPE_TEXT = 3;
const REFTYPE_LIST = 4;
const RANGETYPE_REF = 1;
const RANGETYPE_CRTC = 2;
const RANGETYPE_VAL = 3;
const RANGETYPE_RERUN = 4;
const LOWSIGN_LT = 1;
const LOWSIGN_LTE = 2;
const LOWSIGN_GTE = 3;
const LOWSIGN_GT = 4;
const LOWSIGN_NE = 5;
```
#### 1.2 Update `show()` method to load refnum data
```php
// Add after loading testdeftech/testdefcal
$row['refnum'] = $this->RefNumModel->where('TestSiteID', $id)
->where('EndDate IS NULL')
->orderBy('Display', 'ASC')
->findAll();
```
#### 1.3 Update `saveRefNum()` helper method
```php
private function saveRefNum($testSiteID, $refRanges, $action, $siteID = 1) {
if ($action === 'update') {
// Soft delete existing refnums
$this->RefNumModel->where('TestSiteID', $testSiteID)
->set('EndDate', date('Y-m-d H:i:s'))
->update();
}
foreach ($refRanges as $index => $ref) {
$refData = [
'TestSiteID' => $testSiteID,
'SiteID' => $siteID,
'NumRefType' => $ref['RefType'] ?? self::REFTYPE_NMRC,
'RangeType' => $ref['RangeType'] ?? self::RANGETYPE_REF,
'Flag' => $ref['Flag'] ?? null, // Label for display
'Sex' => $ref['Sex'] ?? 0, // 0=All, 1=M, 2=F (from valueset)
'AgeStart' => $ref['AgeStart'] ?? 0,
'AgeEnd' => $ref['AgeEnd'] ?? 150,
'LowSign' => $this->parseSign($ref['Low'] ?? ''),
'Low' => $this->parseValue($ref['Low'] ?? ''),
'HighSign' => $this->parseSign($ref['High'] ?? ''),
'High' => $this->parseValue($ref['High'] ?? ''),
'Display' => $index,
'CreateDate' => date('Y-m-d H:i:s')
];
$this->RefNumModel->insert($refData);
}
}
// Helper to extract operator from value like "<=50"
private function parseSign($value) {
if (str_starts_with($value, '<>')) return self::LOWSIGN_NE;
if (str_starts_with($value, '<=')) return self::LOWSIGN_LTE;
if (str_starts_with($value, '<')) return self::LOWSIGN_LT;
if (str_starts_with($value, '>=')) return self::LOWSIGN_GTE;
if (str_starts_with($value, '>')) return self::LOWSIGN_GT;
return null;
}
// Helper to extract numeric value from operator-prefixed string
private function parseValue($value) {
return preg_replace('/^[<>=<>]+/', '', $value) ?: null;
}
```
#### 1.4 Update `handleDetails()` to save refnum
```php
// Add in handleDetails method, after saving tech/calc details
if (isset($input['refnum']) && is_array($input['refnum'])) {
$this->saveRefNum($testSiteID, $input['refnum'], $action, $input['SiteID'] ?? 1);
}
```
#### 1.5 Update `delete()` to soft delete refnum
```php
// Add in delete method
$now = date('Y-m-d H:i:s');
$this->RefNumModel->where('TestSiteID', $id)
->set('EndDate', $now)
->update();
```
---
### Phase 2: Frontend Changes (tests_index.php)
#### 2.1 Update form state to include advanced ref ranges
```javascript
form: {
// ... existing fields ...
// Advanced ranges
refRanges: [], // Array of advanced reference range objects
// Dialog states
showAdvancedRefModal: false,
advancedRefRanges: [],
newRefType: 1 // Default: NMRC
}
// RefType options for select
refTypeOptions: [
{ value: 1, label: 'Numeric (NMRC)' },
{ value: 2, label: 'Threshold (TH)' },
{ value: 3, label: 'Text (TEXT)' },
{ value: 4, label: 'Value Set (LIST)' }
]
// RangeType options
rangeTypeOptions: [
{ value: 1, label: 'REF' },
{ value: 2, label: 'CRTC' },
{ value: 3, label: 'VAL' },
{ value: 4, label: 'RERUN' }
]
// Sex options
sexOptions: [
{ value: 0, label: 'All' },
{ value: 1, label: 'Male' },
{ value: 2, label: 'Female' }
]
```
#### 2.2 Update `editTest()` to load refnum data
```javascript
if (testData.refnum && testData.refnum.length > 0) {
this.form.refRanges = testData.refnum.map(r => ({
RefNumID: r.RefNumID,
RefType: r.NumRefType || 1,
RangeType: r.RangeType || 1,
Flag: r.Flag || '',
Sex: r.Sex || 0,
AgeStart: r.AgeStart || 0,
AgeEnd: r.AgeEnd || 150,
Low: this.formatValueWithSign(r.LowSign, r.Low),
High: this.formatValueWithSign(r.HighSign, r.High)
}));
} else {
this.form.refRanges = [];
}
// Format value with operator sign for display
formatValueWithSign(sign, value) {
if (!value && value !== 0) return '';
const signs = {
1: '<', 2: '<=', 3: '>=', 4: '>', 5: '<>'
};
return (signs[sign] || '') + value;
}
```
#### 2.3 Update `save()` to include refnum in payload
```javascript
if (this.form.refRanges && this.form.refRanges.length > 0) {
payload.refnum = this.form.refRanges.map(r => ({
RefType: r.RefType,
RangeType: r.RangeType,
Flag: r.Flag,
Sex: r.Sex,
AgeStart: r.AgeStart,
AgeEnd: r.AgeEnd,
Low: r.Low,
High: r.High
}));
}
```
#### 2.4 Add helper methods for advanced ref ranges
```javascript
// Open advanced dialog
openAdvancedRefDialog() {
this.advancedRefRanges = this.form.refRanges.length > 0
? [...this.form.refRanges]
: [{
RefNumID: null,
RefType: this.form.RefType || 1,
RangeType: 1,
Flag: '',
Sex: 0,
AgeStart: 0,
AgeEnd: 150,
Low: this.form.RefLow || '',
High: this.form.RefHigh || ''
}];
// Add CRTC if critical values exist
if ((this.form.CritLow || this.form.CritHigh) &&
!this.advancedRefRanges.some(r => r.RangeType === 2)) {
this.advancedRefRanges.push({
RefNumID: null,
RefType: 1,
RangeType: 2,
Flag: 'Critical',
Sex: 0,
AgeStart: 0,
AgeEnd: 150,
Low: this.form.CritLow || '',
High: this.form.CritHigh || ''
});
}
this.showAdvancedRefModal = true;
},
// Add new advanced range
addAdvancedRefRange() {
this.advancedRefRanges.push({
RefNumID: null,
RefType: this.newRefType,
RangeType: 1,
Flag: '',
Sex: 0,
AgeStart: 0,
AgeEnd: 150,
Low: '',
High: ''
});
},
// Remove advanced range
removeAdvancedRefRange(index) {
this.advancedRefRanges.splice(index, 1);
},
// Save advanced ranges and close
saveAdvancedRefRanges() {
this.form.refRanges = [...this.advancedRefRanges];
this.showAdvancedRefModal = false;
},
// Cancel advanced dialog
cancelAdvancedRefDialog() {
this.showAdvancedRefModal = false;
}
```
---
### Phase 3: UI Changes (test_dialog.php)
#### 3.1 Keep main Reff tab with RefType selector
```html
<!-- Reff Tab - Main (Simple) -->
<div x-show="form.dialogTab === 'reff'" class="space-y-4" x-cloak>
<!-- RefType Selector -->
<div>
<label class="label"><span class="label-text font-medium">Reference Type</span></label>
<select class="select w-full" x-model="form.RefType">
<option value="1">Numeric Range (NMRC)</option>
<option value="2">Threshold (TH)</option>
<option value="3">Text Result (TEXT)</option>
<option value="4">Value Set (LIST)</option>
</select>
</div>
<!-- Numeric Range Fields -->
<template x-if="form.RefType == '1'">
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label"><span class="label-text">Ref Low</span></label>
<input type="text" class="input" x-model="form.RefLow" placeholder="0.00" />
</div>
<div>
<label class="label"><span class="label-text">Ref High</span></label>
<input type="text" class="input" x-model="form.RefHigh" placeholder="10.00" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label"><span class="label-text text-error">Crit Low</span></label>
<input type="text" class="input border-error/30" x-model="form.CritLow" placeholder="0.00" />
</div>
<div>
<label class="label"><span class="label-text text-error">Crit High</span></label>
<input type="text" class="input border-error/30" x-model="form.CritHigh" placeholder="20.00" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label"><span class="label-text">Unit</span></label>
<input type="text" class="input" x-model="form.Unit1" placeholder="mg/dL" />
</div>
<div>
<label class="label"><span class="label-text">Decimals</span></label>
<input type="number" class="input" x-model="form.Decimal" />
</div>
</div>
</div>
</template>
<!-- Threshold Fields -->
<template x-if="form.RefType == '2'">
<div class="space-y-4">
<div class="p-4 bg-info/10 border border-info/20 rounded-lg">
<p class="text-sm mb-3"><strong>Below Threshold:</strong></p>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="label"><span class="label-text">Below Text</span></label>
<input type="text" class="input" x-model="form.RefText" placeholder="Below Normal" />
</div>
<div>
<label class="label"><span class="label-text">Operator</span></label>
<select class="select" x-model="form.BelowOp">
<option value="<"><</option>
<option value="<="><=</option>
<option value="<>"><></option>
</select>
</div>
<div>
<label class="label"><span class="label-text">Value</span></label>
<input type="text" class="input" x-model="form.BelowVal" placeholder="0.00" />
</div>
</div>
</div>
<div class="p-4 bg-warning/10 border border-warning/20 rounded-lg">
<p class="text-sm mb-3"><strong>Above Threshold:</strong></p>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="label"><span class="label-text">Above Text</span></label>
<input type="text" class="input" x-model="form.AboveText" placeholder="Above Normal" />
</div>
<div>
<label class="label"><span class="label-text">Operator</span></label>
<select class="select" x-model="form.AboveOp">
<option value=">">></option>
<option value=">=">>=</option>
</select>
</div>
<div>
<label class="label"><span class="label-text">Value</span></label>
<input type="text" class="input" x-model="form.AboveVal" placeholder="0.00" />
</div>
</div>
</div>
</div>
</template>
<!-- Text Result Fields -->
<template x-if="form.RefType == '3'">
<div class="space-y-4">
<div>
<label class="label"><span class="label-text">Default Text</span></label>
<input type="text" class="input" x-model="form.RefText" placeholder="e.g., Negative" />
</div>
</div>
</template>
<!-- Value Set Fields -->
<template x-if="form.RefType == '4'">
<div class="space-y-4">
<div>
<label class="label"><span class="label-text">Value Set</span></label>
<select class="select w-full" x-model="form.VSetDefID">
<option value="">Select Value Set...</option>
<template x-for="v in vsetDefsList" :key="v.VSetDefID">
<option :value="v.VSetDefID" x-text="v.VSDesc"></option>
</template>
</select>
</div>
</div>
</template>
<!-- Advanced Button -->
<div class="mt-4 pt-4 border-t" style="border-color: rgb(var(--color-border));">
<button class="btn btn-outline btn-sm" @click="openAdvancedRefDialog()">
<i class="fa-solid fa-gear mr-1"></i>
Advanced Settings
</button>
<span class="ml-2 text-xs opacity-60" x-show="form.refRanges.length > 0">
<i class="fa-solid fa-check text-success mr-1"></i>
<span x-text="form.refRanges.length + ' advanced ranges configured'"></span>
</span>
</div>
</div>
```
#### 3.2 Add Advanced RefRanges Modal
```html
<!-- Advanced Reference Ranges Modal -->
<div x-show="showAdvancedRefModal" x-cloak class="modal-overlay" @click.self="cancelAdvancedRefDialog()">
<div class="modal-content p-6 max-w-5xl w-full max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg">Advanced Reference Ranges</h3>
<button class="btn btn-ghost btn-sm btn-square" @click="cancelAdvancedRefDialog()">
<i class="fa-solid fa-times"></i>
</button>
</div>
<!-- Add Row Controls -->
<div class="flex gap-2 mb-4 p-3 bg-base-200 rounded-lg">
<select class="select select-sm" x-model="newRefType">
<option :value="1">Numeric (NMRC)</option>
<option :value="2">Threshold (TH)</option>
<option :value="3">Text (TEXT)</option>
<option :value="4">Value Set (LIST)</option>
</select>
<button class="btn btn-sm btn-outline" @click="addAdvancedRefRange()">
<i class="fa-solid fa-plus mr-1"></i> Add Range
</button>
</div>
<!-- Advanced Ranges Table -->
<div class="overflow-x-auto mb-4">
<table class="table table-sm table-compact w-full">
<thead>
<tr>
<th style="width: 80px;">RefType</th>
<th style="width: 120px;">Flag/Label</th>
<th style="width: 80px;">RangeType</th>
<th style="width: 60px;">Sex</th>
<th style="width: 70px;">Age From</th>
<th style="width: 70px;">Age To</th>
<th style="width: 100px;">Low</th>
<th style="width: 100px;">High</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody>
<template x-for="(ref, index) in advancedRefRanges" :key="index">
<tr :class="{ 'bg-error/5': ref.RangeType == 2 }">
<!-- RefType -->
<td>
<select
class="select select-xs w-full"
x-model="ref.RefType"
>
<option :value="1">NMRC</option>
<option :value="2">TH</option>
<option :value="3">TEXT</option>
<option :value="4">LIST</option>
</select>
</td>
<!-- Flag/Label -->
<td>
<input
type="text"
class="input input-xs w-full"
x-model="ref.Flag"
placeholder="e.g., Negative"
/>
</td>
<!-- RangeType -->
<td>
<select
class="select select-xs w-full"
:class="{ 'border-error/30 bg-error/10': ref.RangeType == 2 }"
x-model="ref.RangeType"
>
<option :value="1">REF</option>
<option :value="2">CRTC</option>
<option :value="3">VAL</option>
<option :value="4">RERUN</option>
</select>
</td>
<!-- Sex -->
<td>
<select class="select select-xs w-full" x-model="ref.Sex">
<option :value="0">All</option>
<option :value="1">M</option>
<option :value="2">F</option>
</select>
</td>
<!-- Age From -->
<td>
<input type="number" class="input input-xs w-full text-center" x-model="ref.AgeStart" />
</td>
<!-- Age To -->
<td>
<input type="number" class="input input-xs w-full text-center" x-model="ref.AgeEnd" />
</td>
<!-- Low -->
<td>
<input
type="text"
class="input input-xs w-full text-center"
:class="{ 'border-error/30': ref.RangeType == 2 }"
x-model="ref.Low"
placeholder="0.00 or <=10"
/>
</td>
<!-- High -->
<td>
<input
type="text"
class="input input-xs w-full text-center"
:class="{ 'border-error/30': ref.RangeType == 2 }"
x-model="ref.High"
placeholder="0.00"
/>
</td>
<!-- Delete -->
<td>
<button class="btn btn-ghost btn-xs btn-square text-error" @click="removeAdvancedRefRange(index)">
<i class="fa-solid fa-times"></i>
</button>
</td>
</tr>
</template>
<!-- Empty State -->
<template x-if="advancedRefRanges.length === 0">
<tr>
<td colspan="9" class="text-center py-8 text-base-400">
<i class="fa-solid fa-layer-group mr-2"></i>
No advanced ranges. Click "Add Range" to create one.
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Legend -->
<div class="text-xs opacity-60 p-2 border rounded bg-base-200 flex gap-4 mb-4">
<span><strong>1</strong>=NMRC</span>
<span><strong>2</strong>=TH</span>
<span><strong>3</strong>=TEXT</span>
<span><strong>4</strong>=LIST</span>
<span class="ml-4"><strong>1</strong>=REF</span>
<span><strong>2</strong>=CRTC</span>
<span><strong>3</strong>=VAL</span>
<span><strong>4</strong>=RERUN</span>
</div>
<!-- Actions -->
<div class="flex gap-2 pt-4 border-t" style="border-color: rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="cancelAdvancedRefDialog()">Cancel</button>
<button class="btn btn-primary flex-1" @click="saveAdvancedRefRanges()">
<i class="fa-solid fa-check mr-1"></i> Save Advanced Ranges
</button>
</div>
</div>
</div>
```
---
## Summary of Files to Modify
| File | Changes |
|------|---------|
| `app/Controllers/Tests.php` | Add constants, refnum loading/save helper, delete update |
| `app/Models/RefRange/RefNumModel.php` | Ensure allowedFields includes all needed fields |
| `app/Views/v2/master/tests/tests_index.php` | Add refRanges state, helper methods, modal state |
| `app/Views/v2/master/tests/test_dialog.php` | Update Reff tab with numeric RefType, add Advanced modal |
## Workflow
1. **Basic users** - Use global RefLow/RefHigh fields on main tab
2. **Advanced users** - Click "Advanced Settings" to open modal
3. **Modal** - Add/edit/remove multiple ranges with criteria
4. **Save** - Advanced ranges saved to refnum table, global fields saved to testdeftech
---
## Next Steps
1. Review this plan
2. Provide feedback or request changes
3. Once approved, switch to Code mode for implementation

View File

@ -0,0 +1,325 @@
<?php
namespace Tests\Support\v2;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
use Firebase\JWT\JWT;
/**
* Base test case for v2 Master Data tests
*
* Provides common setup, authentication, and helper methods
* for all v2 master test feature and unit tests.
*/
abstract class MasterTestCase extends CIUnitTestCase
{
use FeatureTestTrait;
/**
* JWT token for authentication
*/
protected ?string $token = null;
/**
* Test site ID
*/
protected int $testSiteId = 1;
/**
* Test site code
*/
protected string $testSiteCode = 'TEST01';
/**
* Valueset IDs for test types
*/
public const VALUESET_TEST_TYPE = 27; // VSetID for Test Types
public const VALUESET_RESULT_TYPE = 43; // VSetID for Result Types
public const VALUESET_REF_TYPE = 44; // VSetID for Reference Types
public const VALUESET_ENTITY_TYPE = 39; // VSetID for Entity Types
/**
* Test Type VIDs
*/
public const TEST_TYPE_TEST = 1; // VID for TEST
public const TEST_TYPE_PARAM = 2; // VID for PARAM
public const TEST_TYPE_CALC = 3; // VID for CALC
public const TEST_TYPE_GROUP = 4; // VID for GROUP
public const TEST_TYPE_TITLE = 5; // VID for TITLE
/**
* Setup test environment
*/
protected function setUp(): void
{
parent::setUp();
$this->token = $this->generateTestToken();
}
/**
* Cleanup after test
*/
protected function tearDown(): void
{
parent::tearDown();
}
/**
* Generate JWT token for testing
*/
protected function generateTestToken(): string
{
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
'aud' => 'localhost',
'iat' => time(),
'nbf' => time(),
'exp' => time() + 3600,
'uid' => 1,
'email' => 'admin@admin.com'
];
return JWT::encode($payload, $key, 'HS256');
}
/**
* Make authenticated GET request
*/
protected function get(string $path, array $options = [])
{
$this->withHeaders(['Authorization' => 'Bearer ' . $this->token]);
return $this->call('get', $path, $options);
}
/**
* Make authenticated POST request
*/
protected function post(string $path, array $options = [])
{
$this->withHeaders(['Authorization' => 'Bearer ' . $this->token]);
return $this->call('post', $path, $options);
}
/**
* Make authenticated PUT request
*/
protected function put(string $path, array $options = [])
{
$this->withHeaders(['Authorization' => 'Bearer ' . $this->token]);
return $this->call('put', $path, $options);
}
/**
* Make authenticated DELETE request
*/
protected function delete(string $path, array $options = [])
{
$this->withHeaders(['Authorization' => 'Bearer ' . $this->token]);
return $this->call('delete', $path, $options);
}
/**
* Create a TEST type test definition
*/
protected function createTestData(): array
{
return [
'SiteID' => 1,
'TestSiteCode' => $this->testSiteCode,
'TestSiteName' => 'Test Definition ' . time(),
'TestType' => self::TEST_TYPE_TEST,
'Description' => 'Test description',
'SeqScr' => 10,
'SeqRpt' => 10,
'IndentLeft' => 0,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'ResultType' => 1, // Numeric
'RefType' => 1, // NMRC
'Unit1' => 'mg/dL',
'Decimal' => 2,
'Method' => 'Test Method',
'ExpectedTAT' => 60
],
'testmap' => [
[
'HostType' => 'HIS',
'HostID' => 'TEST001',
'HostTestCode' => 'TEST001',
'HostTestName' => 'Test (HIS)'
]
]
];
}
/**
* Create a PARAM type test definition
*/
protected function createParamData(): array
{
return [
'SiteID' => 1,
'TestSiteCode' => 'PARM' . substr(time(), -4),
'TestSiteName' => 'Parameter Test ' . time(),
'TestType' => self::TEST_TYPE_PARAM,
'Description' => 'Parameter test description',
'SeqScr' => 5,
'SeqRpt' => 5,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'ResultType' => 1,
'RefType' => 1,
'Unit1' => 'unit',
'Decimal' => 1,
'Method' => 'Parameter Method'
]
];
}
/**
* Create a GROUP type test definition with members
*/
protected function createGroupData(array $memberIds = []): array
{
return [
'SiteID' => 1,
'TestSiteCode' => 'GRUP' . substr(time(), -4),
'TestSiteName' => 'Group Test ' . time(),
'TestType' => self::TEST_TYPE_GROUP,
'Description' => 'Group test description',
'SeqScr' => 100,
'SeqRpt' => 100,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'Members' => $memberIds ?: [1, 2],
'testmap' => [
[
'HostType' => 'LIS',
'HostID' => 'LIS001',
'HostTestCode' => 'PANEL',
'HostTestName' => 'Test Panel (LIS)'
]
]
];
}
/**
* Create a CALC type test definition
*/
protected function createCalcData(): array
{
return [
'SiteID' => 1,
'TestSiteCode' => 'CALC' . substr(time(), -4),
'TestSiteName' => 'Calculated Test ' . time(),
'TestType' => self::TEST_TYPE_CALC,
'Description' => 'Calculated test description',
'SeqScr' => 50,
'SeqRpt' => 50,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'FormulaInput' => '["TEST1", "TEST2"]',
'FormulaCode' => 'TEST1 + TEST2',
'FormulaLang' => 'SQL',
'RefType' => 1,
'Unit1' => 'mg/dL',
'Decimal' => 0,
'Method' => 'Calculation Method'
],
'testmap' => [
[
'HostType' => 'LIS',
'HostID' => 'LIS001',
'HostTestCode' => 'CALCR',
'HostTestName' => 'Calculated Result (LIS)'
]
]
];
}
/**
* Assert API response has success status
*/
protected function assertSuccessResponse($response, string $message = 'Response should be successful'): void
{
$body = json_decode($response->response()->getBody(), true);
$this->assertArrayHasKey('status', $body, $message);
$this->assertEquals('success', $body['status'], $message);
}
/**
* Assert API response has error status
*/
protected function assertErrorResponse($response, string $message = 'Response should be an error'): void
{
$body = json_decode($response->response()->getBody(), true);
$this->assertArrayHasKey('status', $body, $message);
$this->assertNotEquals('success', $body['status'], $message);
}
/**
* Assert response has data key
*/
protected function assertHasData($response, string $message = 'Response should have data'): void
{
$body = json_decode($response->response()->getBody(), true);
$this->assertArrayHasKey('data', $body, $message);
}
/**
* Get test type name from VID
*/
protected function getTestTypeName(int $vid): string
{
return match ($vid) {
self::TEST_TYPE_TEST => 'TEST',
self::TEST_TYPE_PARAM => 'PARAM',
self::TEST_TYPE_CALC => 'CALC',
self::TEST_TYPE_GROUP => 'GROUP',
self::TEST_TYPE_TITLE => 'TITLE',
default => 'UNKNOWN'
};
}
/**
* Skip test if database not available
*/
protected function requireDatabase(): void
{
$db = \Config\Database::connect();
try {
$db->connect();
} catch (\Exception $e) {
$this->markTestSkipped('Database not available: ' . $e->getMessage());
}
}
/**
* Skip test if required seeded data not found
*/
protected function requireSeededData(): void
{
$db = \Config\Database::connect();
$count = $db->table('valueset')
->where('VSetID', self::VALUESET_TEST_TYPE)
->countAllResults();
if ($count === 0) {
$this->markTestSkipped('Test type valuesets not seeded');
}
}
}

View File

@ -2,258 +2,373 @@
namespace Tests\Feature\TestDef;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use App\Models\Test\TestDefSiteModel;
use App\Models\Test\TestDefTechModel;
use App\Models\Test\TestDefCalModel;
use App\Models\Test\TestDefGrpModel;
/**
* Integration tests for Test Definitions API
*
* Tests the CRUD operations for test definitions through the API
*/
class TestDefSiteTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
protected $endpoint = 'api/tests';
protected $token;
protected function setUp(): void
{
parent::setUp();
// Generate Token
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
'aud' => 'localhost',
'iat' => time(),
'nbf' => time(),
'exp' => time() + 3600,
'uid' => 1,
'email' => 'admin@admin.com'
];
$this->token = \Firebase\JWT\JWT::encode($payload, $key, 'HS256');
}
public function get(string $path, array $options = []) {
$this->withHeaders(['Cookie' => 'token=' . $this->token]);
return $this->call('get', $path, $options);
}
public function post(string $path, array $options = []) {
$this->withHeaders(['Cookie' => 'token=' . $this->token]);
return $this->call('post', $path, $options);
}
public function put(string $path, array $options = []) {
$this->withHeaders(['Cookie' => 'token=' . $this->token]);
return $this->call('put', $path, $options);
}
public function delete(string $path, array $options = []) {
$this->withHeaders(['Cookie' => 'token=' . $this->token]);
return $this->call('delete', $path, $options);
}
protected $seed = 'App\Database\Seeds\TestSeeder';
protected $refresh = true;
/**
* Test index endpoint returns list of tests
* Test listing all tests returns success response
*/
public function testIndexReturnsTestList()
public function testIndexReturnsSuccessResponse(): void
{
$result = $this->get($this->endpoint);
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get('api/tests');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertArrayHasKey('status', $body);
$this->assertArrayHasKey('data', $body);
$this->assertArrayHasKey('message', $body);
$result->assertJSONExact([
'status' => 'success',
'message' => 'Data fetched successfully',
'data' => $result->getJSON(true)['data']
]);
}
/**
* Test index with SiteID filter
* Test listing all tests returns array
*/
public function testIndexWithSiteFilter()
public function testIndexReturnsArray(): void
{
$result = $this->get($this->endpoint . '?SiteID=1');
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get('api/tests');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
$response = $result->getJSON(true);
$this->assertIsArray($response['data']);
}
/**
* Test index with TestType filter
* Test index contains test type information
*/
public function testIndexWithTypeFilter()
public function testIndexContainsTypeInformation(): void
{
$result = $this->get($this->endpoint . '?TestType=TEST');
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get('api/tests');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
$response = $result->getJSON(true);
if (!empty($response['data'])) {
$test = $response['data'][0];
$this->assertArrayHasKey('TypeCode', $test);
$this->assertArrayHasKey('TypeName', $test);
}
}
/**
* Test show endpoint returns single test
* Test filtering by test type
*/
public function testShowReturnsSingleTest()
public function testIndexFiltersByTestType(): void
{
// First get the list to find a valid ID
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$firstItem = $indexBody['data'][0];
$testSiteID = $firstItem['TestSiteID'] ?? 1;
$showResult = $this->get($this->endpoint . '/' . $testSiteID);
$showResult->assertStatus(200);
$body = json_decode($showResult->response()->getBody(), true);
$this->assertArrayHasKey('data', $body);
$this->assertEquals('success', $body['status']);
// Check that related details are loaded based on TestType
if ($body['data'] !== null) {
$typeCode = $body['data']['TypeCode'] ?? '';
if ($typeCode === 'CALC') {
$this->assertArrayHasKey('testdefcal', $body['data']);
} elseif ($typeCode === 'GROUP') {
$this->assertArrayHasKey('testdefgrp', $body['data']);
} elseif (in_array($typeCode, ['TEST', 'PARAM'])) {
$this->assertArrayHasKey('testdeftech', $body['data']);
}
// All types should have testmap
$this->assertArrayHasKey('testmap', $body['data']);
// Test filtering by TEST type (VID = 1)
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get('api/tests?TestType=1');
$result->assertStatus(200);
$response = $result->getJSON(true);
foreach ($response['data'] as $test) {
$this->assertEquals('TEST', $test['TypeCode']);
}
}
/**
* Test filtering by keyword
*/
public function testIndexFiltersByKeyword(): void
{
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get('api/tests?TestSiteName=HB');
$result->assertStatus(200);
$response = $result->getJSON(true);
if (!empty($response['data'])) {
foreach ($response['data'] as $test) {
$this->assertStringContainsString('HB', $test['TestSiteName']);
}
}
}
/**
* Test show with non-existent ID returns null data
* Test showing single test returns success
*/
public function testShowWithInvalidIDReturnsNull()
public function testShowReturnsSuccess(): void
{
$result = $this->get($this->endpoint . '/9999999');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertArrayHasKey('data', $body);
$this->assertNull($body['data']);
// Get a test ID from the seeder data
$model = new TestDefSiteModel();
$test = $model->first();
if ($test) {
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get("api/tests/{$test['TestSiteID']}");
$result->assertStatus(200);
$response = $result->getJSON(true);
$this->assertArrayHasKey('data', $response);
} else {
$this->markTestSkipped('No test data available');
}
}
/**
* Test create new test definition
* Test showing single test includes type-specific details for TEST type
*/
public function testCreateTest()
public function testShowIncludesTechDetailsForTestType(): void
{
$model = new TestDefSiteModel();
$test = $model->first();
if ($test && $test['TypeCode'] === 'TEST') {
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get("api/tests/{$test['TestSiteID']}");
$result->assertStatus(200);
$response = $result->getJSON(true);
$this->assertArrayHasKey('testdeftech', $response['data']);
} else {
$this->markTestSkipped('No TEST type data available');
}
}
/**
* Test showing single test includes type-specific details for CALC type
*/
public function testShowIncludesCalcDetailsForCalcType(): void
{
$model = new TestDefSiteModel();
$test = $model->first();
if ($test && $test['TypeCode'] === 'CALC') {
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get("api/tests/{$test['TestSiteID']}");
$result->assertStatus(200);
$response = $result->getJSON(true);
$this->assertArrayHasKey('testdefcal', $response['data']);
} else {
$this->markTestSkipped('No CALC type data available');
}
}
/**
* Test showing single test includes type-specific details for GROUP type
*/
public function testShowIncludesGrpDetailsForGroupType(): void
{
$model = new TestDefSiteModel();
$test = $model->first();
if ($test && $test['TypeCode'] === 'GROUP') {
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get("api/tests/{$test['TestSiteID']}");
$result->assertStatus(200);
$response = $result->getJSON(true);
$this->assertArrayHasKey('testdefgrp', $response['data']);
} else {
$this->markTestSkipped('No GROUP type data available');
}
}
/**
* Test creating a new test
*/
public function testCreateTest(): void
{
$testData = [
'SiteID' => 1,
'TestSiteCode' => 'HB',
'TestSiteName' => 'Hemoglobin',
'TestType' => 'TEST',
'Description' => 'Hemoglobin concentration test',
'SeqScr' => 3,
'SeqRpt' => 3,
'IndentLeft' => 0,
'FontStyle' => 'Bold',
'TestSiteCode' => 'NEWTEST',
'TestSiteName' => 'New Test',
'TestType' => 1, // TEST type
'Description' => 'Test description',
'SeqScr' => 100,
'SeqRpt' => 100,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'StartDate' => date('Y-m-d H:i:s')
'CountStat' => 1
];
$result = $this->post($this->endpoint, ['body' => json_encode($testData)]);
// If validation fails due to duplicate code, that's expected
$status = $result->response()->getStatusCode();
$this->assertTrue(in_array($status, [201, 400]), "Expected 201 or 400, got $status");
if ($status === 201) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('created', $body['status']);
$this->assertArrayHasKey('TestSiteId', $body['data']);
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->post('api/tests', $testData);
$result->assertStatus(201);
$response = $result->getJSON(true);
$this->assertArrayHasKey('data', $response);
$this->assertArrayHasKey('TestSiteId', $response['data']);
}
/**
* Test creating test with validation error (missing required fields)
*/
public function testCreateTestValidationError(): void
{
$testData = [
'SiteID' => 1
// Missing required fields
];
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->post('api/tests', $testData);
$result->assertStatus(400);
}
/**
* Test updating a test
*/
public function testUpdateTest(): void
{
$model = new TestDefSiteModel();
$test = $model->first();
if ($test) {
$updateData = [
'TestSiteID' => $test['TestSiteID'],
'TestSiteName' => 'Updated Test Name',
'Description' => 'Updated description'
];
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->patch('api/tests', $updateData);
$result->assertStatus(200);
$response = $result->getJSON(true);
$this->assertEquals('success', $response['status']);
} else {
$this->markTestSkipped('No test data available');
}
}
/**
* Test update existing test
* Test deleting a test (soft delete)
*/
public function testUpdateTest()
public function testDeleteTest(): void
{
// Get a valid test ID first
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$firstItem = $indexBody['data'][0];
$testSiteID = $firstItem['TestSiteID'] ?? null;
if ($testSiteID) {
$updateData = [
'TestSiteName' => 'Updated Test Name',
'Description' => 'Updated description'
];
$result = $this->put($this->endpoint . '/' . $testSiteID, ['body' => json_encode($updateData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(in_array($status, [200, 404]), "Expected 200 or 404, got $status");
if ($status === 200) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
$model = new TestDefSiteModel();
$test = $model->first();
if ($test) {
$deleteData = [
'TestSiteID' => $test['TestSiteID']
];
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->delete('api/tests', $deleteData);
$result->assertStatus(200);
$response = $result->getJSON(true);
$this->assertEquals('success', $response['status']);
$this->assertArrayHasKey('EndDate', $response['data']);
} else {
$this->markTestSkipped('No test data available');
}
}
/**
* Test getting non-existent test returns empty data
*/
public function testShowNonExistentTest(): void
{
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get('api/tests/999999');
$result->assertStatus(200);
$response = $result->getJSON(true);
$this->assertNull($response['data']);
}
/**
* Test test types are correctly mapped from valueset
*/
public function testTestTypesAreMapped(): void
{
$model = new TestDefSiteModel();
$tests = $model->findAll();
$validTypes = ['TEST', 'PARAM', 'CALC', 'GROUP', 'TITLE'];
foreach ($tests as $test) {
if (isset($test['TypeCode'])) {
$this->assertContains($test['TypeCode'], $validTypes);
}
}
}
/**
* Test soft delete (disable) test
* Test filtering by visible on screen
*/
public function testDeleteTest()
public function testIndexFiltersByVisibleScr(): void
{
// Get a valid test ID first
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$firstItem = $indexBody['data'][0];
$testSiteID = $firstItem['TestSiteID'] ?? null;
if ($testSiteID) {
$result = $this->delete($this->endpoint . '/' . $testSiteID);
$status = $result->response()->getStatusCode();
$this->assertTrue(in_array($status, [200, 404]), "Expected 200 or 404, got $status");
if ($status === 200) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
$this->assertArrayHasKey('EndDate', $body['data']);
}
}
$result = $this->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json'
])->get('api/tests?VisibleScr=1');
$result->assertStatus(200);
$response = $result->getJSON(true);
foreach ($response['data'] as $test) {
$this->assertEquals(1, $test['VisibleScr']);
}
}
/**
* Test validation - missing required fields
* Test all test types from seeder are present
*/
public function testCreateValidation()
public function testAllTestTypesArePresent(): void
{
$invalidData = [
'TestSiteName' => 'Test without code'
];
$result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]);
$result->assertStatus(400);
}
$model = new TestDefSiteModel();
$tests = $model->findAll();
/**
* Test that TestSiteCode is max 6 characters
*/
public function testTestSiteCodeLength()
{
$invalidData = [
'SiteID' => 1,
'TestSiteCode' => 'HB123456', // 8 characters - invalid
'TestSiteName' => 'Test with too long code',
'TestType' => 'TEST'
];
$result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]);
$result->assertStatus(400);
$typeCodes = array_column($tests, 'TypeCode');
$uniqueTypes = array_unique($typeCodes);
// Check that we have at least TEST and PARAM types from seeder
$this->assertContains('TEST', $uniqueTypes);
$this->assertContains('PARAM', $uniqueTypes);
$this->assertContains('CALC', $uniqueTypes);
$this->assertContains('GROUP', $uniqueTypes);
}
}

View File

@ -0,0 +1,328 @@
<?php
namespace Tests\Feature\v2\master\TestDef;
use Tests\Support\v2\MasterTestCase;
/**
* Feature tests for CALC type test definitions
*
* Tests CALC-specific functionality including formula configuration
*/
class TestDefCalcTest extends MasterTestCase
{
protected string $endpoint = 'v2/master/tests';
/**
* Test create CALC with formula
*/
public function testCreateCalcWithFormula(): void
{
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'CALC' . substr(time(), -4),
'TestSiteName' => 'Calculated Test ' . time(),
'TestType' => $this::TEST_TYPE_CALC,
'Description' => 'Calculated test with formula',
'SeqScr' => 50,
'SeqRpt' => 50,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'FormulaInput' => '["CHOL", "HDL", "TG"]',
'FormulaCode' => 'CHOL - HDL - (TG / 5)',
'FormulaLang' => 'SQL',
'RefType' => 1, // NMRC
'Unit1' => 'mg/dL',
'Decimal' => 0,
'Method' => 'Friedewald Formula'
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
if ($status === 201) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('created', $body['status']);
// Verify calc details were created
$calcId = $body['data']['TestSiteId'];
$showResult = $this->get($this->endpoint . '/' . $calcId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null) {
$this->assertArrayHasKey('testdefcal', $showBody['data']);
}
}
}
/**
* Test CALC with different formula languages
*/
public function testCalcWithDifferentFormulaLanguages(): void
{
$languages = ['Phyton', 'CQL', 'FHIRP', 'SQL'];
foreach ($languages as $lang) {
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'C' . substr(time(), -5) . strtoupper(substr($lang, 0, 1)),
'TestSiteName' => "Calc with $lang",
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'FormulaInput' => '["TEST1"]',
'FormulaCode' => 'TEST1 * 2',
'FormulaLang' => $lang
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"CALC with $lang: Expected 201, 400, or 500, got $status"
);
}
}
/**
* Test CALC with JSON formula input
*/
public function testCalcWithJsonFormulaInput(): void
{
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'CJSN' . substr(time(), -3),
'TestSiteName' => 'Calc with JSON Input',
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'FormulaInput' => '["parameter1", "parameter2", "parameter3"]',
'FormulaCode' => '(param1 + param2) / param3',
'FormulaLang' => 'FHIRP'
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test CALC with complex formula
*/
public function testCalcWithComplexFormula(): void
{
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'CCMP' . substr(time(), -3),
'TestSiteName' => 'Calc with Complex Formula',
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'FormulaInput' => '["WBC", "NEUT", "LYMPH", "MONO", "EOS", "BASO"]',
'FormulaCode' => 'if WBC > 0 then (NEUT + LYMPH + MONO + EOS + BASO) / WBC * 100 else 0',
'FormulaLang' => 'Phyton'
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test update CALC formula
*/
public function testUpdateCalcFormula(): void
{
// Create a CALC first
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'UPCL' . substr(time(), -4),
'TestSiteName' => 'Update Calc Test',
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'FormulaInput' => '["A", "B"]',
'FormulaCode' => 'A + B',
'FormulaLang' => 'SQL'
]
];
$createResult = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$calcId = $createBody['data']['TestSiteId'] ?? null;
if ($calcId) {
// Update formula
$updateData = [
'TestSiteName' => 'Updated Calc Test Name',
'details' => [
'FormulaInput' => '["A", "B", "C"]',
'FormulaCode' => 'A + B + C'
]
];
$updateResult = $this->put($this->endpoint . '/' . $calcId, ['body' => json_encode($updateData)]);
$updateStatus = $updateResult->response()->getStatusCode();
$this->assertTrue(
in_array($updateStatus, [200, 400, 500]),
"Expected 200, 400, or 500, got $updateStatus"
);
}
}
}
/**
* Test CALC has correct TypeCode in response
*/
public function testCalcTypeCodeInResponse(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=CALC');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$calc = $indexBody['data'][0];
// Verify TypeCode is CALC
$this->assertEquals('CALC', $calc['TypeCode'] ?? '');
}
}
/**
* Test CALC details structure
*/
public function testCalcDetailsStructure(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=CALC');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$calc = $indexBody['data'][0];
$calcId = $calc['TestSiteID'] ?? null;
if ($calcId) {
$showResult = $this->get($this->endpoint . '/' . $calcId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null && isset($showBody['data']['testdefcal'])) {
$calcDetails = $showBody['data']['testdefcal'];
if (is_array($calcDetails) && !empty($calcDetails)) {
$firstDetail = $calcDetails[0];
// Check required fields in calc structure
$this->assertArrayHasKey('TestCalID', $firstDetail);
$this->assertArrayHasKey('TestSiteID', $firstDetail);
$this->assertArrayHasKey('FormulaInput', $firstDetail);
$this->assertArrayHasKey('FormulaCode', $firstDetail);
// Check for joined discipline/department
if (isset($firstDetail['DisciplineName'])) {
$this->assertArrayHasKey('DepartmentName', $firstDetail);
}
}
}
}
}
}
/**
* Test CALC delete cascades to details
*/
public function testCalcDeleteCascadesToDetails(): void
{
// Create a CALC
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'CDEL' . substr(time(), -4),
'TestSiteName' => 'Calc to Delete',
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'FormulaInput' => '["TEST1"]',
'FormulaCode' => 'TEST1 * 2'
]
];
$createResult = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$calcId = $createBody['data']['TestSiteId'] ?? null;
if ($calcId) {
// Delete the CALC
$deleteResult = $this->delete($this->endpoint . '/' . $calcId);
$deleteStatus = $deleteResult->response()->getStatusCode();
$this->assertTrue(
in_array($deleteStatus, [200, 404, 500]),
"Expected 200, 404, or 500, got $deleteStatus"
);
if ($deleteStatus === 200) {
// Verify CALC details are also soft deleted
$showResult = $this->get($this->endpoint . '/' . $calcId);
$showBody = json_decode($showResult->response()->getBody(), true);
// CALC should show EndDate set
if ($showBody['data'] !== null) {
$this->assertNotNull($showBody['data']['EndDate']);
}
}
}
}
}
/**
* Test CALC with result unit configuration
*/
public function testCalcWithResultUnit(): void
{
$units = ['mg/dL', 'g/L', 'mmol/L', '%', 'IU/L'];
foreach ($units as $unit) {
$calcData = [
'SiteID' => 1,
'TestSiteCode' => 'CUNT' . substr(time(), -3) . substr($unit, 0, 1),
'TestSiteName' => "Calc with $unit",
'TestType' => $this::TEST_TYPE_CALC,
'details' => [
'Unit1' => $unit,
'Decimal' => 2,
'FormulaInput' => '["TEST1"]',
'FormulaCode' => 'TEST1'
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"CALC with unit $unit: Expected 201, 400, or 500, got $status"
);
}
}
}

View File

@ -0,0 +1,291 @@
<?php
namespace Tests\Feature\v2\master\TestDef;
use Tests\Support\v2\MasterTestCase;
/**
* Feature tests for GROUP type test definitions
*
* Tests GROUP-specific functionality including member management
*/
class TestDefGroupTest extends MasterTestCase
{
protected string $endpoint = 'v2/master/tests';
/**
* Test create GROUP with members
*/
public function testCreateGroupWithMembers(): void
{
// Get existing test IDs to use as members
$memberIds = $this->getExistingTestIds();
$groupData = [
'SiteID' => 1,
'TestSiteCode' => 'GRUP' . substr(time(), -4),
'TestSiteName' => 'Test Group ' . time(),
'TestType' => $this::TEST_TYPE_GROUP,
'Description' => 'Group test with members',
'SeqScr' => 100,
'SeqRpt' => 100,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'Members' => $memberIds
];
$result = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
if ($status === 201) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('created', $body['status']);
// Verify members were created
$groupId = $body['data']['TestSiteId'];
$showResult = $this->get($this->endpoint . '/' . $groupId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null) {
$this->assertArrayHasKey('testdefgrp', $showBody['data']);
}
}
}
/**
* Test create GROUP without members
*/
public function testCreateGroupWithoutMembers(): void
{
$groupData = [
'SiteID' => 1,
'TestSiteCode' => 'GREM' . substr(time(), -4),
'TestSiteName' => 'Empty Group ' . time(),
'TestType' => $this::TEST_TYPE_GROUP,
'Members' => [] // Empty members
];
$result = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
// Should still succeed but with warning or empty members
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400]),
"Expected 201 or 400, got $status"
);
}
/**
* Test update GROUP members
*/
public function testUpdateGroupMembers(): void
{
// Create a group first
$memberIds = $this->getExistingTestIds();
$groupData = $this->createGroupData($memberIds);
$groupData['TestSiteCode'] = 'UPMB' . substr(time(), -4);
$createResult = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$groupId = $createBody['data']['TestSiteId'] ?? null;
if ($groupId) {
// Update with new members
$updateData = [
'Members' => array_slice($memberIds, 0, 1) // Only one member
];
$updateResult = $this->put($this->endpoint . '/' . $groupId, ['body' => json_encode($updateData)]);
$updateStatus = $updateResult->response()->getStatusCode();
$this->assertTrue(
in_array($updateStatus, [200, 400, 500]),
"Expected 200, 400, or 500, got $updateStatus"
);
}
}
}
/**
* Test add member to existing GROUP
*/
public function testAddMemberToGroup(): void
{
// Get existing test
$indexResult = $this->get($this->endpoint . '?TestType=GROUP');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$group = $indexBody['data'][0];
$groupId = $group['TestSiteID'] ?? null;
if ($groupId) {
// Get a test ID to add
$testIds = $this->getExistingTestIds();
$newMemberId = $testIds[0] ?? 1;
$updateData = [
'Members' => [$newMemberId]
];
$result = $this->put($this->endpoint . '/' . $groupId, ['body' => json_encode($updateData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [200, 400, 500]),
"Expected 200, 400, or 500, got $status"
);
}
}
}
/**
* Test GROUP with single member
*/
public function testGroupWithSingleMember(): void
{
$memberIds = $this->getExistingTestIds();
$groupData = [
'SiteID' => 1,
'TestSiteCode' => 'GSGL' . substr(time(), -4),
'TestSiteName' => 'Single Member Group ' . time(),
'TestType' => $this::TEST_TYPE_GROUP,
'Members' => [array_slice($memberIds, 0, 1)[0] ?? 1]
];
$result = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test GROUP members have correct structure
*/
public function testGroupMembersStructure(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=GROUP');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$group = $indexBody['data'][0];
$groupId = $group['TestSiteID'] ?? null;
if ($groupId) {
$showResult = $this->get($this->endpoint . '/' . $groupId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null && isset($showBody['data']['testdefgrp'])) {
$members = $showBody['data']['testdefgrp'];
if (is_array($members) && !empty($members)) {
$firstMember = $members[0];
// Check required fields in member structure
$this->assertArrayHasKey('TestGrpID', $firstMember);
$this->assertArrayHasKey('TestSiteID', $firstMember);
$this->assertArrayHasKey('Member', $firstMember);
// Check for joined test details (if loaded)
if (isset($firstMember['TestSiteCode'])) {
$this->assertArrayHasKey('TestSiteName', $firstMember);
}
}
}
}
}
}
/**
* Test GROUP delete cascades to members
*/
public function testGroupDeleteCascadesToMembers(): void
{
// Create a group
$memberIds = $this->getExistingTestIds();
$groupData = $this->createGroupData($memberIds);
$groupData['TestSiteCode'] = 'GDEL' . substr(time(), -4);
$createResult = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$groupId = $createBody['data']['TestSiteId'] ?? null;
if ($groupId) {
// Delete the group
$deleteResult = $this->delete($this->endpoint . '/' . $groupId);
$deleteStatus = $deleteResult->response()->getStatusCode();
$this->assertTrue(
in_array($deleteStatus, [200, 404, 500]),
"Expected 200, 404, or 500, got $deleteStatus"
);
if ($deleteStatus === 200) {
// Verify group members are also soft deleted
$showResult = $this->get($this->endpoint . '/' . $groupId);
$showBody = json_decode($showResult->response()->getBody(), true);
// Group should show EndDate set
if ($showBody['data'] !== null) {
$this->assertNotNull($showBody['data']['EndDate']);
}
}
}
}
}
/**
* Test GROUP type has correct TypeCode in response
*/
public function testGroupTypeCodeInResponse(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=GROUP');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$group = $indexBody['data'][0];
// Verify TypeCode is GROUP
$this->assertEquals('GROUP', $group['TypeCode'] ?? '');
}
}
/**
* Helper to get existing test IDs
*/
private function getExistingTestIds(): array
{
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
$ids = [];
if (isset($indexBody['data']) && is_array($indexBody['data'])) {
foreach ($indexBody['data'] as $item) {
if (isset($item['TestSiteID'])) {
$ids[] = $item['TestSiteID'];
}
if (count($ids) >= 3) {
break;
}
}
}
return $ids ?: [1, 2, 3];
}
}

View File

@ -0,0 +1,288 @@
<?php
namespace Tests\Feature\v2\master\TestDef;
use Tests\Support\v2\MasterTestCase;
/**
* Feature tests for PARAM type test definitions
*
* Tests PARAM-specific functionality as sub-test components
*/
class TestDefParamTest extends MasterTestCase
{
protected string $endpoint = 'v2/master/tests';
/**
* Test create PARAM type test
*/
public function testCreateParamTypeTest(): void
{
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PARM' . substr(time(), -4),
'TestSiteName' => 'Parameter Test ' . time(),
'TestType' => $this::TEST_TYPE_PARAM,
'Description' => 'Parameter/sub-test description',
'SeqScr' => 5,
'SeqRpt' => 5,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'ResultType' => 1, // Numeric
'RefType' => 1, // NMRC
'Unit1' => 'unit',
'Decimal' => 1,
'Method' => 'Parameter Method'
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
if ($status === 201) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('created', $body['status']);
// Verify tech details were created
$paramId = $body['data']['TestSiteId'];
$showResult = $this->get($this->endpoint . '/' . $paramId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null) {
$this->assertArrayHasKey('testdeftech', $showBody['data']);
}
}
}
/**
* Test PARAM has correct TypeCode in response
*/
public function testParamTypeCodeInResponse(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=PARAM');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$param = $indexBody['data'][0];
// Verify TypeCode is PARAM
$this->assertEquals('PARAM', $param['TypeCode'] ?? '');
}
}
/**
* Test PARAM details structure
*/
public function testParamDetailsStructure(): void
{
$indexResult = $this->get($this->endpoint . '?TestType=PARAM');
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$param = $indexBody['data'][0];
$paramId = $param['TestSiteID'] ?? null;
if ($paramId) {
$showResult = $this->get($this->endpoint . '/' . $paramId);
$showBody = json_decode($showResult->response()->getBody(), true);
if ($showBody['data'] !== null && isset($showBody['data']['testdeftech'])) {
$techDetails = $showBody['data']['testdeftech'];
if (is_array($techDetails) && !empty($techDetails)) {
$firstDetail = $techDetails[0];
// Check required fields in tech structure
$this->assertArrayHasKey('TestTechID', $firstDetail);
$this->assertArrayHasKey('TestSiteID', $firstDetail);
$this->assertArrayHasKey('ResultType', $firstDetail);
$this->assertArrayHasKey('RefType', $firstDetail);
// Check for joined discipline/department
if (isset($firstDetail['DisciplineName'])) {
$this->assertArrayHasKey('DepartmentName', $firstDetail);
}
}
}
}
}
}
/**
* Test PARAM with different result types
*/
public function testParamWithDifferentResultTypes(): void
{
$resultTypes = [
1 => 'NMRIC', // Numeric
2 => 'RANGE', // Range
3 => 'TEXT', // Text
4 => 'VSET' // Value Set
];
foreach ($resultTypes as $resultTypeId => $resultTypeName) {
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PR' . substr(time(), -4) . substr($resultTypeName, 0, 1),
'TestSiteName' => "Param with $resultTypeName",
'TestType' => $this::TEST_TYPE_PARAM,
'details' => [
'ResultType' => $resultTypeId,
'RefType' => 1
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"PARAM with ResultType $resultTypeName: Expected 201, 400, or 500, got $status"
);
}
}
/**
* Test PARAM with different reference types
*/
public function testParamWithDifferentRefTypes(): void
{
$refTypes = [
1 => 'NMRC', // Numeric
2 => 'TEXT' // Text
];
foreach ($refTypes as $refTypeId => $refTypeName) {
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PR' . substr(time(), -4) . 'R' . substr($refTypeName, 0, 1),
'TestSiteName' => "Param with RefType $refTypeName",
'TestType' => $this::TEST_TYPE_PARAM,
'details' => [
'ResultType' => 1,
'RefType' => $refTypeId
]
];
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"PARAM with RefType $refTypeName: Expected 201, 400, or 500, got $status"
);
}
}
/**
* Test PARAM delete cascades to details
*/
public function testParamDeleteCascadesToDetails(): void
{
// Create a PARAM
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PDEL' . substr(time(), -4),
'TestSiteName' => 'Param to Delete',
'TestType' => $this::TEST_TYPE_PARAM,
'details' => [
'ResultType' => 1,
'RefType' => 1
]
];
$createResult = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$paramId = $createBody['data']['TestSiteId'] ?? null;
if ($paramId) {
// Delete the PARAM
$deleteResult = $this->delete($this->endpoint . '/' . $paramId);
$deleteStatus = $deleteResult->response()->getStatusCode();
$this->assertTrue(
in_array($deleteStatus, [200, 404, 500]),
"Expected 200, 404, or 500, got $deleteStatus"
);
if ($deleteStatus === 200) {
// Verify PARAM details are also soft deleted
$showResult = $this->get($this->endpoint . '/' . $paramId);
$showBody = json_decode($showResult->response()->getBody(), true);
// PARAM should show EndDate set
if ($showBody['data'] !== null) {
$this->assertNotNull($showBody['data']['EndDate']);
}
}
}
}
}
/**
* Test PARAM visibility settings
*/
public function testParamVisibilitySettings(): void
{
$visibilityCombinations = [
['VisibleScr' => 1, 'VisibleRpt' => 1],
['VisibleScr' => 1, 'VisibleRpt' => 0],
['VisibleScr' => 0, 'VisibleRpt' => 1],
['VisibleScr' => 0, 'VisibleRpt' => 0]
];
foreach ($visibilityCombinations as $vis) {
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PVIS' . substr(time(), -4),
'TestSiteName' => 'Visibility Test',
'TestType' => $this::TEST_TYPE_PARAM,
'VisibleScr' => $vis['VisibleScr'],
'VisibleRpt' => $vis['VisibleRpt']
];
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"PARAM visibility ({$vis['VisibleScr']}, {$vis['VisibleRpt']}): Expected 201, 400, or 500, got $status"
);
}
}
/**
* Test PARAM sequence ordering
*/
public function testParamSequenceOrdering(): void
{
$paramData = [
'SiteID' => 1,
'TestSiteCode' => 'PSEQ' . substr(time(), -4),
'TestSiteName' => 'Sequenced Param',
'TestType' => $this::TEST_TYPE_PARAM,
'SeqScr' => 25,
'SeqRpt' => 30
];
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
}

View File

@ -0,0 +1,375 @@
<?php
namespace Tests\Feature\v2\master\TestDef;
use Tests\Support\v2\MasterTestCase;
/**
* Feature tests for v2 Test Definition API endpoints
*
* Tests CRUD operations for TEST, PARAM, GROUP, and CALC types
*/
class TestDefSiteTest extends MasterTestCase
{
protected string $endpoint = 'v2/master/tests';
/**
* Test index endpoint returns list of tests
*/
public function testIndexReturnsTestList(): void
{
$result = $this->get($this->endpoint);
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertArrayHasKey('status', $body);
$this->assertArrayHasKey('data', $body);
$this->assertArrayHasKey('message', $body);
}
/**
* Test index with SiteID filter
*/
public function testIndexWithSiteFilter(): void
{
$result = $this->get($this->endpoint . '?SiteID=1');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
/**
* Test index with TestType filter
*/
public function testIndexWithTestTypeFilter(): void
{
// Filter by TEST type
$result = $this->get($this->endpoint . '?TestType=TEST');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
/**
* Test index with Visibility filter
*/
public function testIndexWithVisibilityFilter(): void
{
$result = $this->get($this->endpoint . '?VisibleScr=1&VisibleRpt=1');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
/**
* Test index with keyword search
*/
public function testIndexWithKeywordSearch(): void
{
$result = $this->get($this->endpoint . '?TestSiteName=hemoglobin');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
/**
* Test show endpoint returns single test
*/
public function testShowReturnsSingleTest(): void
{
// First get the list to find a valid ID
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$firstItem = $indexBody['data'][0];
$testSiteID = $firstItem['TestSiteID'] ?? null;
if ($testSiteID) {
$showResult = $this->get($this->endpoint . '/' . $testSiteID);
$showResult->assertStatus(200);
$body = json_decode($showResult->response()->getBody(), true);
$this->assertArrayHasKey('data', $body);
$this->assertEquals('success', $body['status']);
// Check that related details are loaded based on TestType
if ($body['data'] !== null) {
$typeCode = $body['data']['TypeCode'] ?? '';
if ($typeCode === 'CALC') {
$this->assertArrayHasKey('testdefcal', $body['data']);
} elseif ($typeCode === 'GROUP') {
$this->assertArrayHasKey('testdefgrp', $body['data']);
} elseif (in_array($typeCode, ['TEST', 'PARAM'])) {
$this->assertArrayHasKey('testdeftech', $body['data']);
}
// All types should have testmap
$this->assertArrayHasKey('testmap', $body['data']);
}
}
}
}
/**
* Test show with non-existent ID returns null data
*/
public function testShowWithInvalidIDReturnsNull(): void
{
$result = $this->get($this->endpoint . '/9999999');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertArrayHasKey('data', $body);
$this->assertNull($body['data']);
}
/**
* Test create new TEST type test definition
*/
public function testCreateTestTypeTest(): void
{
$testData = $this->createTestData();
$result = $this->post($this->endpoint, ['body' => json_encode($testData)]);
$status = $result->response()->getStatusCode();
// Expect 201 (created) or 400 (validation error) or 500 (server error)
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
if ($status === 201) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('created', $body['status']);
$this->assertArrayHasKey('TestSiteId', $body['data']);
}
}
/**
* Test create new PARAM type test definition
*/
public function testCreateParamTypeTest(): void
{
$paramData = $this->createParamData();
$result = $this->post($this->endpoint, ['body' => json_encode($paramData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test create new GROUP type test definition
*/
public function testCreateGroupTypeTest(): void
{
// First create some member tests
$memberIds = $this->getExistingTestIds();
$groupData = $this->createGroupData($memberIds);
$result = $this->post($this->endpoint, ['body' => json_encode($groupData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test create new CALC type test definition
*/
public function testCreateCalcTypeTest(): void
{
$calcData = $this->createCalcData();
$result = $this->post($this->endpoint, ['body' => json_encode($calcData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [201, 400, 500]),
"Expected 201, 400, or 500, got $status"
);
}
/**
* Test update existing test
*/
public function testUpdateTest(): void
{
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$firstItem = $indexBody['data'][0];
$testSiteID = $firstItem['TestSiteID'] ?? null;
if ($testSiteID) {
$updateData = [
'TestSiteName' => 'Updated Test Name ' . time(),
'Description' => 'Updated description'
];
$result = $this->put($this->endpoint . '/' . $testSiteID, ['body' => json_encode($updateData)]);
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [200, 404, 500]),
"Expected 200, 404, or 500, got $status"
);
if ($status === 200) {
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
}
}
}
/**
* Test soft delete (disable) test
*/
public function testDeleteTest(): void
{
// Create a test first to delete
$testData = $this->createTestData();
$testData['TestSiteCode'] = 'DEL' . substr(time(), -4);
$createResult = $this->post($this->endpoint, ['body' => json_encode($testData)]);
$createStatus = $createResult->response()->getStatusCode();
if ($createStatus === 201) {
$createBody = json_decode($createResult->response()->getBody(), true);
$testSiteID = $createBody['data']['TestSiteId'] ?? null;
if ($testSiteID) {
$deleteResult = $this->delete($this->endpoint . '/' . $testSiteID);
$deleteStatus = $deleteResult->response()->getStatusCode();
$this->assertTrue(
in_array($deleteStatus, [200, 404, 500]),
"Expected 200, 404, or 500, got $deleteStatus"
);
if ($deleteStatus === 200) {
$deleteBody = json_decode($deleteResult->response()->getBody(), true);
$this->assertEquals('success', $deleteBody['status']);
$this->assertArrayHasKey('EndDate', $deleteBody['data']);
}
}
}
}
/**
* Test validation - missing required fields
*/
public function testCreateValidationRequiredFields(): void
{
$invalidData = [
'TestSiteName' => 'Test without required fields'
];
$result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]);
$result->assertStatus(400);
}
/**
* Test that TestSiteCode is max 6 characters
*/
public function testTestSiteCodeLength(): void
{
$invalidData = [
'SiteID' => 1,
'TestSiteCode' => 'HB123456', // 8 characters - invalid
'TestSiteName' => 'Test with too long code',
'TestType' => $this::TEST_TYPE_TEST
];
$result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]);
$result->assertStatus(400);
}
/**
* Test that TestSiteCode is at least 3 characters
*/
public function testTestSiteCodeMinLength(): void
{
$invalidData = [
'SiteID' => 1,
'TestSiteCode' => 'HB', // 2 characters - invalid
'TestSiteName' => 'Test with too short code',
'TestType' => $this::TEST_TYPE_TEST
];
$result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]);
$result->assertStatus(400);
}
/**
* Test that duplicate TestSiteCode is rejected
*/
public function testDuplicateTestSiteCode(): void
{
// First create a test
$testData = $this->createTestData();
$testData['TestSiteCode'] = 'DUP' . substr(time(), -3);
$this->post($this->endpoint, ['body' => json_encode($testData)]);
// Try to create another test with the same code
$duplicateData = $testData;
$duplicateData['TestSiteName'] = 'Different Name';
$result = $this->post($this->endpoint, ['body' => json_encode($duplicateData)]);
// Should fail with 400 or 500
$status = $result->response()->getStatusCode();
$this->assertTrue(
in_array($status, [400, 500]),
"Expected 400 or 500 for duplicate, got $status"
);
}
/**
* Test filtering by multiple parameters
*/
public function testIndexWithMultipleFilters(): void
{
$result = $this->get($this->endpoint . '?SiteID=1&TestType=TEST&VisibleScr=1');
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertEquals('success', $body['status']);
}
/**
* Helper method to get existing test IDs for group members
*/
private function getExistingTestIds(): array
{
$indexResult = $this->get($this->endpoint);
$indexBody = json_decode($indexResult->response()->getBody(), true);
$ids = [];
if (isset($indexBody['data']) && is_array($indexBody['data'])) {
foreach ($indexBody['data'] as $item) {
if (isset($item['TestSiteID'])) {
$ids[] = $item['TestSiteID'];
}
if (count($ids) >= 2) {
break;
}
}
}
return $ids ?: [1, 2];
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefCalModel;
/**
* Unit tests for TestDefCalModel
*
* Tests the calculation definition model for CALC type tests
*/
class TestDefCalModelTest extends CIUnitTestCase
{
protected TestDefCalModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestDefCalModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testdefcal', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestCalID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Foreign key
$this->assertContains('TestSiteID', $allowedFields);
// Calculation fields
$this->assertContains('DisciplineID', $allowedFields);
$this->assertContains('DepartmentID', $allowedFields);
$this->assertContains('FormulaInput', $allowedFields);
$this->assertContains('FormulaCode', $allowedFields);
// Result fields
$this->assertContains('RefType', $allowedFields);
$this->assertContains('Unit1', $allowedFields);
$this->assertContains('Factor', $allowedFields);
$this->assertContains('Unit2', $allowedFields);
$this->assertContains('Decimal', $allowedFields);
$this->assertContains('Method', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test FormulaInput field is in allowed fields
*/
public function testFormulaInputFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('FormulaInput', $allowedFields);
}
/**
* Test FormulaCode field is in allowed fields
*/
public function testFormulaCodeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('FormulaCode', $allowedFields);
}
/**
* Test RefType field is in allowed fields
*/
public function testRefTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('RefType', $allowedFields);
}
/**
* Test Method field is in allowed fields
*/
public function testMethodFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('Method', $allowedFields);
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefGrpModel;
/**
* Unit tests for TestDefGrpModel
*
* Tests the group definition model for GROUP type tests
*/
class TestDefGrpModelTest extends CIUnitTestCase
{
protected TestDefGrpModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestDefGrpModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testdefgrp', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestGrpID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Foreign keys
$this->assertContains('TestSiteID', $allowedFields);
$this->assertContains('Member', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test TestSiteID field is in allowed fields
*/
public function testTestSiteIDFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteID', $allowedFields);
}
/**
* Test Member field is in allowed fields
*/
public function testMemberFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('Member', $allowedFields);
}
/**
* Test CreateDate field is in allowed fields
*/
public function testCreateDateFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('CreateDate', $allowedFields);
}
/**
* Test EndDate field is in allowed fields
*/
public function testEndDateFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('EndDate', $allowedFields);
}
}

View File

@ -0,0 +1,220 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefSiteModel;
/**
* Unit tests for TestDefSiteModel - Master Data Tests CRUD operations
*
* Tests the model configuration and behavior for test definition management
*/
class TestDefSiteModelMasterTest extends CIUnitTestCase
{
protected TestDefSiteModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestDefSiteModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testdefsite', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestSiteID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields for master data
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Core required fields
$this->assertContains('SiteID', $allowedFields);
$this->assertContains('TestSiteCode', $allowedFields);
$this->assertContains('TestSiteName', $allowedFields);
$this->assertContains('TestType', $allowedFields);
// Display and ordering fields
$this->assertContains('Description', $allowedFields);
$this->assertContains('SeqScr', $allowedFields);
$this->assertContains('SeqRpt', $allowedFields);
$this->assertContains('IndentLeft', $allowedFields);
$this->assertContains('FontStyle', $allowedFields);
// Visibility fields
$this->assertContains('VisibleScr', $allowedFields);
$this->assertContains('VisibleRpt', $allowedFields);
$this->assertContains('CountStat', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('StartDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
$this->assertEquals('StartDate', $this->model->updatedField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test TestSiteCode field is in allowed fields
*/
public function testTestSiteCodeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteCode', $allowedFields);
}
/**
* Test TestSiteName field is in allowed fields
*/
public function testTestSiteNameFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteName', $allowedFields);
}
/**
* Test TestType field is in allowed fields
*/
public function testTestTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestType', $allowedFields);
}
/**
* Test SiteID field is in allowed fields
*/
public function testSiteIDFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('SiteID', $allowedFields);
}
/**
* Test Description field is in allowed fields
*/
public function testDescriptionFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('Description', $allowedFields);
}
/**
* Test SeqScr field is in allowed fields
*/
public function testSeqScrFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('SeqScr', $allowedFields);
}
/**
* Test SeqRpt field is in allowed fields
*/
public function testSeqRptFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('SeqRpt', $allowedFields);
}
/**
* Test VisibleScr field is in allowed fields
*/
public function testVisibleScrFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('VisibleScr', $allowedFields);
}
/**
* Test VisibleRpt field is in allowed fields
*/
public function testVisibleRptFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('VisibleRpt', $allowedFields);
}
/**
* Test CountStat field is in allowed fields
*/
public function testCountStatFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('CountStat', $allowedFields);
}
/**
* Test getTests method exists and is callable
*/
public function testGetTestsMethodExists(): void
{
$this->assertTrue(method_exists($this->model, 'getTests'));
$this->assertIsCallable([$this->model, 'getTests']);
}
/**
* Test getTest method exists and is callable
*/
public function testGetTestMethodExists(): void
{
$this->assertTrue(method_exists($this->model, 'getTest'));
$this->assertIsCallable([$this->model, 'getTest']);
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefSiteModel;
/**
* Unit tests for TestDefSiteModel
*
* Tests the main test definition model configuration and behavior
*/
class TestDefSiteModelTest extends CIUnitTestCase
{
protected TestDefSiteModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestDefSiteModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testdefsite', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestSiteID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Core required fields
$this->assertContains('SiteID', $allowedFields);
$this->assertContains('TestSiteCode', $allowedFields);
$this->assertContains('TestSiteName', $allowedFields);
$this->assertContains('TestType', $allowedFields);
// Optional fields
$this->assertContains('Description', $allowedFields);
$this->assertContains('SeqScr', $allowedFields);
$this->assertContains('SeqRpt', $allowedFields);
$this->assertContains('IndentLeft', $allowedFields);
$this->assertContains('FontStyle', $allowedFields);
$this->assertContains('VisibleScr', $allowedFields);
$this->assertContains('VisibleRpt', $allowedFields);
$this->assertContains('CountStat', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('StartDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
$this->assertEquals('StartDate', $this->model->updatedField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test TestSiteCode field is in allowed fields
*/
public function testTestSiteCodeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteCode', $allowedFields);
}
/**
* Test TestSiteName field is in allowed fields
*/
public function testTestSiteNameFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteName', $allowedFields);
}
/**
* Test TestType field is in allowed fields
*/
public function testTestTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestType', $allowedFields);
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefTechModel;
/**
* Unit tests for TestDefTechModel
*
* Tests the technical definition model for TEST and PARAM types
*/
class TestDefTechModelTest extends CIUnitTestCase
{
protected TestDefTechModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestDefTechModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testdeftech', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestTechID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Foreign key
$this->assertContains('TestSiteID', $allowedFields);
// Technical fields
$this->assertContains('DisciplineID', $allowedFields);
$this->assertContains('DepartmentID', $allowedFields);
$this->assertContains('ResultType', $allowedFields);
$this->assertContains('RefType', $allowedFields);
$this->assertContains('VSet', $allowedFields);
// Quantity and units
$this->assertContains('ReqQty', $allowedFields);
$this->assertContains('ReqQtyUnit', $allowedFields);
$this->assertContains('Unit1', $allowedFields);
$this->assertContains('Factor', $allowedFields);
$this->assertContains('Unit2', $allowedFields);
$this->assertContains('Decimal', $allowedFields);
// Collection and method
$this->assertContains('CollReq', $allowedFields);
$this->assertContains('Method', $allowedFields);
$this->assertContains('ExpectedTAT', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test TestSiteID field is in allowed fields
*/
public function testTestSiteIDFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteID', $allowedFields);
}
/**
* Test ResultType field is in allowed fields
*/
public function testResultTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('ResultType', $allowedFields);
}
/**
* Test RefType field is in allowed fields
*/
public function testRefTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('RefType', $allowedFields);
}
/**
* Test Unit1 field is in allowed fields
*/
public function testUnit1FieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('Unit1', $allowedFields);
}
/**
* Test Method field is in allowed fields
*/
public function testMethodFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('Method', $allowedFields);
}
}

View File

@ -0,0 +1,155 @@
<?php
namespace Tests\Unit\v2\master\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestMapModel;
/**
* Unit tests for TestMapModel
*
* Tests the test mapping model for all test types
*/
class TestMapModelTest extends CIUnitTestCase
{
protected TestMapModel $model;
protected function setUp(): void
{
parent::setUp();
$this->model = new TestMapModel();
}
/**
* Test model has correct table name
*/
public function testModelHasCorrectTableName(): void
{
$this->assertEquals('testmap', $this->model->table);
}
/**
* Test model has correct primary key
*/
public function testModelHasCorrectPrimaryKey(): void
{
$this->assertEquals('TestMapID', $this->model->primaryKey);
}
/**
* Test model uses soft deletes
*/
public function testModelUsesSoftDeletes(): void
{
$this->assertTrue($this->model->useSoftDeletes);
$this->assertEquals('EndDate', $this->model->deletedField);
}
/**
* Test model has correct allowed fields
*/
public function testModelHasCorrectAllowedFields(): void
{
$allowedFields = $this->model->allowedFields;
// Foreign key
$this->assertContains('TestSiteID', $allowedFields);
// Host system mapping
$this->assertContains('HostType', $allowedFields);
$this->assertContains('HostID', $allowedFields);
$this->assertContains('HostDataSource', $allowedFields);
$this->assertContains('HostTestCode', $allowedFields);
$this->assertContains('HostTestName', $allowedFields);
// Client system mapping
$this->assertContains('ClientType', $allowedFields);
$this->assertContains('ClientID', $allowedFields);
$this->assertContains('ClientDataSource', $allowedFields);
$this->assertContains('ConDefID', $allowedFields);
$this->assertContains('ClientTestCode', $allowedFields);
$this->assertContains('ClientTestName', $allowedFields);
// Timestamp fields
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
}
/**
* Test model uses timestamps
*/
public function testModelUsesTimestamps(): void
{
$this->assertTrue($this->model->useTimestamps);
$this->assertEquals('CreateDate', $this->model->createdField);
}
/**
* Test model return type is array
*/
public function testModelReturnTypeIsArray(): void
{
$this->assertEquals('array', $this->model->returnType);
}
/**
* Test model has correct skip validation
*/
public function testModelSkipValidation(): void
{
$this->assertFalse($this->model->skipValidation);
}
/**
* Test model has correct useAutoIncrement
*/
public function testModelUseAutoIncrement(): void
{
$this->assertTrue($this->model->useAutoIncrement);
}
/**
* Test HostType field is in allowed fields
*/
public function testHostTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('HostType', $allowedFields);
}
/**
* Test HostID field is in allowed fields
*/
public function testHostIDFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('HostID', $allowedFields);
}
/**
* Test HostTestCode field is in allowed fields
*/
public function testHostTestCodeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('HostTestCode', $allowedFields);
}
/**
* Test ClientType field is in allowed fields
*/
public function testClientTypeFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('ClientType', $allowedFields);
}
/**
* Test TestSiteID field is in allowed fields
*/
public function testTestSiteIDFieldExists(): void
{
$allowedFields = $this->model->allowedFields;
$this->assertContains('TestSiteID', $allowedFields);
}
}