This commit is contained in:
mikael-zakaria 2026-01-07 08:38:30 +07:00
commit 23681a2dbf
136 changed files with 15690 additions and 5661 deletions

86
AGENTS.md Normal file
View File

@ -0,0 +1,86 @@
# Valueset Data Scanning - Agent Instructions
## Quick Reference
**Database Tables:**
- [`valuesetdef`](app/Models/ValueSet/ValueSetDefModel.php:8) - Category definitions (VSetID, VSName, VSDesc)
- [`valueset`](app/Models/ValueSet/ValueSetModel.php:8) - Actual values (VID, VSetID, VValue, VDesc, VOrder)
## Agent Workflow for Valueset Queries
### Step 1: Identify the Request Type
**Category Search:**
```
User: "Show me all values in [CATEGORY_NAME]"
Agent: Search valuesetdef by VSName → Get VSetID → Query values by VSetID
```
**Reference Search:**
```
User: "Show me values for [TABLE.COLUMN]"
Agent: Search valuesetdef by VSDesc → Get VSetID → Query values by VSetID
```
**ID Search:**
```
User: "Show me values for VSetDefID [ID]"
Agent: Directly query values by VSetID
```
**Filtered Search:**
```
User: "Find values containing [TERM] in [CATEGORY]"
Agent: Search valuesetdef by VSName → Get VSetID → Query values with LIKE filter
```
### Step 2: Execute Query
**Model Methods:**
- [`ValueSetDefModel::getValueSetDefs($param)`](app/Models/ValueSet/ValueSetDefModel.php:18) - Search categories
- [`ValueSetModel::getValueSetByValueSetDef($VSetID)`](app/Models/ValueSet/ValueSetModel.php:52) - Get values by category
- [`ValueSetModel::getValueSets($param, $page, $limit, $VSetID)`](app/Models/ValueSet/ValueSetModel.php:18) - Get values with filters
### Step 3: Return Results
**Response Format:**
```json
{
"VSetID": 27,
"VSName": "Test Type",
"values": [
{ "VID": 1, "VValue": "TEST", "VDesc": "Test", "VOrder": 1 },
{ "VID": 2, "VValue": "PARAM", "VDesc": "Parameter", "VOrder": 2 }
]
}
```
## Common Valuesets
| VSetDefID | VSName | VSDesc | Search Keywords |
|-----------|--------|--------|-----------------|
| 3 | Gender | - | gender, sex |
| 27 | Test Type | `testdefsite.TestType` | test, type, testdefsite |
| 15 | Specimen Type | - | specimen, type, blood, urine |
| 31 | Range Types | `refnum.RangeType` | range, refnum |
| 46 | Num Ref Type | `refnum.NumRefType` | numeric, reference |
## Example Agent Conversations
**User:** "Show me Gender values"
**Agent:**
1. `getValueSetDefs("Gender")` → VSetID = 3
2. `getValueSetByValueSetDef(3)` → Returns values
3. Output: Female, Male, Unknown
**User:** "What values for testdefsite.TestType?"
**Agent:**
1. `getValueSetDefs("testdefsite.TestType")` → VSetID = 27
2. `getValueSetByValueSetDef(27)` → Returns values
3. Output: TEST, PARAM, CALC, GROUP, TITLE
**User:** "Find values with 'STAT' in Priority"
**Agent:**
1. `getValueSetDefs("Priority")` → VSetID = 1
2. `getValueSets("STAT", null, 50, 1)` → Returns matching values
3. Output: STAT, STAT2 (if exists)

205
README.md
View File

@ -34,6 +34,7 @@ The system is currently undergoing a strategic **Architectural Redesign** to con
| **Security** | JWT (JSON Web Tokens) Authorization | | **Security** | JWT (JSON Web Tokens) Authorization |
| **Database** | MySQL (Optimized Schema Migration in progress) | | **Database** | MySQL (Optimized Schema Migration in progress) |
--- ---
## 📂 Documentation & Specifications ## 📂 Documentation & Specifications
@ -49,6 +50,210 @@ Key documents:
--- ---
## 🔧 Valueset Reference (VSetDefID)
When working on UI components or dropdowns, **always check for existing ValueSets** before hardcoding options. Use the API endpoint `/api/valueset/valuesetdef/{ID}` to fetch options dynamically.
| VSetDefID | Purpose | Usage |
|-----------|---------|-------|
| 27 | Test Types | TEST, PARAM, GROUP, CALC, TITLE |
| 28 | Methods | Lab test methods |
| 29 | Specimen Types | Blood, Urine, etc. |
| 30 | Ref Types | NMRC (Numeric), TEXT, LIST |
| 31 | Range Types | STD (Standard), AGSX (Age/Sex), COND |
> **Important:** Always use ValueSet lookups for configurable options. This ensures consistency and allows administrators to modify options without code changes.
---
## 📋 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`).
### Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/edge/results` | Receive instrument results (stored in `edgeres`) |
| `GET` | `/api/edge/orders` | Fetch pending orders for an instrument |
| `POST` | `/api/edge/orders/:id/ack` | Acknowledge order delivery to instrument |
| `POST` | `/api/edge/status` | Log instrument status updates |
### Workflow
```
Instrument → tiny-edge → POST /api/edge/results → edgeres table → [Manual/Auto Processing] → patres table
```
**Key Features:**
- **Staging Table:** All results land in `edgeres` first for validation
- **Rerun Handling:** Duplicate `SampleID` + `TestSiteCode` increments `AspCnt` in `patres`
- **Configurable Processing:** Auto or manual processing based on settings
- **Status Tracking:** Full audit trail via `edgestatus` and `edgeack` tables
---
### 📜 Usage Notice ### 📜 Usage Notice
This repository contains proprietary information intended for the 5Panda Team and authorized collaborators. This repository contains proprietary information intended for the 5Panda Team and authorized collaborators.

View File

@ -16,7 +16,7 @@ class App extends BaseConfig
* *
* E.g., http://example.com/ * E.g., http://example.com/
*/ */
public string $baseURL = 'http://localhost:8080/'; public string $baseURL = '';
/** /**
* Allowed Hostnames in the Site URL other than the hostname in the baseURL. * Allowed Hostnames in the Site URL other than the hostname in the baseURL.
@ -40,7 +40,8 @@ class App extends BaseConfig
* something else. If you have configured your web server to remove this file * something else. If you have configured your web server to remove this file
* from your site URIs, set this variable to an empty string. * from your site URIs, set this variable to an empty string.
*/ */
public string $indexPage = 'index.php'; #public string $indexPage = 'index.php';
public string $indexPage = '';
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------

View File

@ -44,7 +44,7 @@ class Exceptions extends BaseConfig
* *
* Default: APPPATH.'Views/errors' * Default: APPPATH.'Views/errors'
*/ */
public string $errorViewPath = APPPATH . 'Views/errors'; public string $errorViewPath = __DIR__ . '/../Views/errors';
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------

View File

@ -53,7 +53,7 @@ class Filters extends BaseFilters
*/ */
public array $required = [ public array $required = [
'before' => [ 'before' => [
'forcehttps', // Force Global Secure Requests // 'forcehttps', // Force Global Secure Requests - disabled for localhost
'pagecache', // Web Page Caching 'pagecache', // Web Page Caching
], ],
'after' => [ 'after' => [

View File

@ -5,171 +5,287 @@ use CodeIgniter\Router\RouteCollection;
/** /**
* @var RouteCollection $routes * @var RouteCollection $routes
*/ */
$routes->options('(:any)', function() { return ''; }); $routes->get('/', function () {
$routes->get('/', 'Home::index'); return redirect()->to('/v2');
});
// Frontend Pages $routes->options('(:any)', function () {
$routes->get('/login', 'Pages\AuthPage::login'); return '';
$routes->get('/logout', 'Pages\AuthPage::logout'); });
$routes->get('/dashboard', 'Pages\DashboardPage::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)
$routes->get('/v2/login', 'PagesController::login');
// V2 Auth API Routes (public - no auth required)
$routes->group('v2/auth', function ($routes) {
$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)
$routes->group('v2', ['filter' => 'auth'], function ($routes) {
$routes->get('/', 'PagesController::dashboard');
$routes->get('dashboard', 'PagesController::dashboard');
$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');
});
// Faker // Faker
$routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1'); $routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1');
$routes->group('api', ['filter' => 'auth'], function($routes) { $routes->group('api', function ($routes) {
$routes->get('dashboard', 'Dashboard::index'); // Auth
$routes->get('result', 'Result::index'); $routes->group('auth', function ($routes) {
$routes->get('sample', 'Sample::index'); $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\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('/', '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('/', 'PatVisitController::createADT');
$routes->patch('/', 'PatVisitController::updateADT');
});
// Master Data
$routes->group('race', function ($routes) {
$routes->get('/', 'Race::index');
$routes->get('(:num)', 'Race::show/$1');
});
$routes->group('country', function ($routes) {
$routes->get('/', 'Country::index');
$routes->get('(:num)', 'Country::show/$1');
});
$routes->group('religion', function ($routes) {
$routes->get('/', 'Religion::index');
$routes->get('(:num)', 'Religion::show/$1');
});
$routes->group('ethnic', function ($routes) {
$routes->get('/', 'Ethnic::index');
$routes->get('(:num)', 'Ethnic::show/$1');
});
// Location
$routes->group('location', function ($routes) {
$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\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\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\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\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\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('/', '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('/', '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\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\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\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\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\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\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\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\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\SpecimenCollectionController::index');
$routes->get('(:num)', 'Specimen\SpecimenCollectionController::show/$1');
$routes->post('/', 'Specimen\SpecimenCollectionController::create');
$routes->patch('/', 'Specimen\SpecimenCollectionController::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('/', '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', 'EdgeController::results');
$routes->get('orders', 'EdgeController::orders');
$routes->post('orders/(:num)/ack', 'EdgeController::ack/$1');
$routes->post('status', 'EdgeController::status');
});
}); });
$routes->post('/api/auth/login', 'Auth::login');
$routes->post('/api/auth/change_pass', 'Auth::change_pass');
$routes->post('/api/auth/register', 'Auth::register');
$routes->get('/api/auth/check', 'Auth::checkAuth');
$routes->post('/api/auth/logout', 'Auth::logout');
$routes->get('/api/patient', 'Patient\Patient::index');
$routes->post('/api/patient', 'Patient\Patient::create');
$routes->get('/api/patient/(:num)', 'Patient\Patient::show/$1');
$routes->delete('/api/patient', 'Patient\Patient::delete');
$routes->patch('/api/patient', 'Patient\Patient::update');
$routes->get('/api/patient/check', 'Patient\Patient::patientCheck');
$routes->get('/api/patvisit', 'PatVisit::index');
$routes->post('/api/patvisit', 'PatVisit::create');
$routes->get('/api/patvisit/patient/(:num)', 'PatVisit::showByPatient/$1');
$routes->get('/api/patvisit/(:any)', 'PatVisit::show/$1');
$routes->delete('/api/patvisit', 'PatVisit::delete');
$routes->patch('/api/patvisit', 'PatVisit::update');
$routes->post('/api/patvisitadt', 'PatVisit::createADT');
$routes->patch('/api/patvisitadt', 'PatVisit::updateADT');
$routes->get('/api/race', 'Race::index');
$routes->get('/api/race/(:num)', 'Race::show/$1');
$routes->get('/api/country', 'Country::index');
$routes->get('/api/country/(:num)', 'Country::show/$1');
$routes->get('/api/religion', 'Religion::index');
$routes->get('/api/religion/(:num)', 'Religion::show/$1');
$routes->get('/api/ethnic', 'Ethnic::index');
$routes->get('/api/ethnic/(:num)', 'Ethnic::show/$1');
$routes->get('/api/location', 'Location::index');
$routes->get('/api/location/(:num)', 'Location::show/$1');
$routes->post('/api/location', 'Location::create');
$routes->patch('/api/location', 'Location::update');
$routes->delete('/api/location', 'Location::delete');
$routes->get('/api/contact', 'Contact\Contact::index');
$routes->get('/api/contact/(:num)', 'Contact\Contact::show/$1');
$routes->post('/api/contact', 'Contact\Contact::create');
$routes->patch('/api/contact', 'Contact\Contact::update');
$routes->delete('/api/contact', 'Contact\git Contact::delete');
$routes->get('/api/occupation', 'Contact\Occupation::index');
$routes->get('/api/occupation/(:num)', 'Contact\Occupation::show/$1');
$routes->post('/api/occupation', 'Contact\Occupation::create');
$routes->patch('/api/occupation', 'Contact\Occupation::update');
//$routes->delete('/api/occupation', 'Contact\Occupation::delete');
$routes->get('/api/medicalspecialty', 'Contact\MedicalSpecialty::index');
$routes->get('/api/medicalspecialty/(:num)', 'Contact\MedicalSpecialty::show/$1');
$routes->post('/api/medicalspecialty', 'Contact\MedicalSpecialty::create');
$routes->patch('/api/medicalspecialty', 'Contact\MedicalSpecialty::update');
$routes->get('/api/valueset', 'ValueSet\ValueSet::index');
$routes->get('/api/valueset/(:num)', 'ValueSet\ValueSet::show/$1');
$routes->get('/api/valueset/valuesetdef/(:num)', 'ValueSet\ValueSet::showByValueSetDef/$1');
$routes->post('/api/valueset', 'ValueSet\ValueSet::create');
$routes->patch('/api/valueset', 'ValueSet\ValueSet::update');
$routes->delete('/api/valueset', 'ValueSet\ValueSet::delete');
$routes->get('/api/valuesetdef/', 'ValueSet\ValueSetDef::index');
$routes->get('/api/valuesetdef/(:num)', 'ValueSet\ValueSetDef::show/$1');
$routes->post('/api/valuesetdef', 'ValueSet\ValueSetDef::create');
$routes->patch('/api/valuesetdef', 'ValueSet\ValueSetDef::update');
$routes->delete('/api/valuesetdef', 'ValueSet\ValueSetDef::delete');
$routes->get('/api/counter/', 'Counter::index');
$routes->get('/api/counter/(:num)', 'Counter::show/$1');
$routes->post('/api/counter', 'Counter::create');
$routes->patch('/api/counter', 'Counter::update');
$routes->delete('/api/counter', 'Counter::delete');
$routes->get('/api/areageo', 'AreaGeo::index');
$routes->get('/api/areageo/provinces', 'AreaGeo::getProvinces');
$routes->get('/api/areageo/cities', 'AreaGeo::getCities');
//organization
// account
$routes->get('/api/organization/account/', 'Organization\Account::index');
$routes->get('/api/organization/account/(:num)', 'Organization\Account::show/$1');
$routes->post('/api/organization/account', 'Organization\Account::create');
$routes->patch('/api/organization/account', 'Organization\Account::update');
$routes->delete('/api/organization/account', 'Organization\Account::delete');
// site
$routes->get('/api/organization/site/', 'Organization\Site::index');
$routes->get('/api/organization/site/(:num)', 'Organization\Site::show/$1');
$routes->post('/api/organization/site', 'Organization\Site::create');
$routes->patch('/api/organization/site', 'Organization\Site::update');
$routes->delete('/api/organization/site', 'Organization\Site::delete');
// discipline
$routes->get('/api/organization/discipline/', 'Organization\Discipline::index');
$routes->get('/api/organization/discipline/(:num)', 'Organization\Discipline::show/$1');
$routes->post('/api/organization/discipline', 'Organization\Discipline::create');
$routes->patch('/api/organization/discipline', 'Organization\Discipline::update');
$routes->delete('/api/organization/discipline', 'Organization\Discipline::delete');
// department
$routes->get('/api/organization/department/', 'Organization\Department::index');
$routes->get('/api/organization/department/(:num)', 'Organization\Department::show/$1');
$routes->post('/api/organization/department', 'Organization\Department::create');
$routes->patch('/api/organization/department', 'Organization\Department::update');
$routes->delete('/api/organization/department', 'Organization\Department::delete');
// workstation
$routes->get('/api/organization/workstation/', 'Organization\Workstation::index');
$routes->get('/api/organization/workstation/(:num)', 'Organization\Workstation::show/$1');
$routes->post('/api/organization/workstation', 'Organization\Workstation::create');
$routes->patch('/api/organization/workstation', 'Organization\Workstation::update');
$routes->delete('/api/organization/workstation', 'Organization\Workstation::delete');
$routes->group('api/specimen', function($routes) {
$routes->get('containerdef/(:num)', 'Specimen\ContainerDef::show/$1');
$routes->post('containerdef', 'Specimen\ContainerDef::create');
$routes->patch('containerdef', 'Specimen\ContainerDef::update');
$routes->get('containerdef', 'Specimen\ContainerDef::index');
$routes->get('prep/(:num)', 'Specimen\Prep::show/$1');
$routes->post('prep', 'Specimen\Prep::create');
$routes->patch('prep', 'Specimen\Prep::update');
$routes->get('prep', 'Specimen\Prep::index');
$routes->get('status/(:num)', 'Specimen\Status::show/$1');
$routes->post('status', 'Specimen\Status::create');
$routes->patch('status', 'Specimen\Status::update');
$routes->get('status', 'Specimen\Status::index');
$routes->get('collection/(:num)', 'Specimen\Collection::show/$1');
$routes->post('collection', 'Specimen\Collection::create');
$routes->patch('collection', 'Specimen\Collection::update');
$routes->get('collection', 'Specimen\Collection::index');
$routes->get('(:num)', 'Specimen\Specimen::show/$1');
$routes->post('', 'Specimen\Specimen::create');
$routes->patch('', 'Specimen\Specimen::update');
$routes->get('', 'Specimen\Specimen::index');
});
$routes->post('/api/tests', 'Tests::create');
$routes->patch('/api/tests', 'Tests::update');
$routes->get('/api/tests/(:any)', 'Tests::show/$1');
$routes->get('/api/tests', 'Tests::index');
// Khusus // Khusus
/* /*
$routes->get('/api/zones', 'Zones::index'); $routes->get('/api/zones', 'Zones::index');
$routes->get('/api/zones/synchronize', 'Zones::synchronize'); $routes->get('/api/zones/synchronize', 'Zones::synchronize');
$routes->get('/api/zones/provinces', 'Zones::getProvinces'); $routes->get('/api/zones/provinces', 'Zones::getProvinces');
$routes->get('/api/zones/cities', 'Zones::getCities'); $routes->get('/api/zones/cities', 'Zones::getCities');
*/ */

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\AreaGeoModel; use App\Models\AreaGeoModel;
class AreaGeo extends BaseController { class AreaGeoController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $model; protected $model;
@ -44,4 +44,4 @@ class AreaGeo extends BaseController {
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); 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 Firebase\JWT\BeforeValidException;
use CodeIgniter\Cookie\Cookie; use CodeIgniter\Cookie\Cookie;
class Auth extends Controller { class AuthController extends Controller
{
use ResponseTrait; use ResponseTrait;
// ok // ok
public function __construct() { public function __construct()
{
$this->db = \Config\Database::connect(); $this->db = \Config\Database::connect();
} }
// ok // ok
public function checkAuth() { public function checkAuth()
{
$token = $this->request->getCookie('token'); $token = $this->request->getCookie('token');
$key = getenv('JWT_SECRET'); $key = getenv('JWT_SECRET');
// Jika token FE tidak ada langsung kabarkan failed // Jika token FE tidak ada langsung kabarkan failed
if (!$token) { if (!$token) {
return $this->respond([ return $this->respond([
'status' => 'failed', 'status' => 'failed',
'message' => 'No token found' 'message' => 'No token found'
], 401); ], 401);
} }
@ -38,43 +41,101 @@ class Auth extends Controller {
$decodedPayload = JWT::decode($token, new Key($key, 'HS256')); $decodedPayload = JWT::decode($token, new Key($key, 'HS256'));
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'Authenticated', 'message' => 'Authenticated',
'data' => $decodedPayload 'data' => $decodedPayload
], 200); ], 200);
} catch (ExpiredException $e) { } catch (ExpiredException $e) {
return $this->respond([ return $this->respond([
'status' => 'failed', 'status' => 'failed',
'message' => 'Token expired', 'message' => 'Token expired',
'data' => [] 'data' => []
], 401); ], 401);
} catch (SignatureInvalidException $e) { } catch (SignatureInvalidException $e) {
return $this->respond([ return $this->respond([
'status' => 'failed', 'status' => 'failed',
'message' => 'Invalid token signature', 'message' => 'Invalid token signature',
'data' => [] 'data' => []
], 401); ], 401);
} catch (BeforeValidException $e) { } catch (BeforeValidException $e) {
return $this->respond([ return $this->respond([
'status' => 'failed', 'status' => 'failed',
'message' => 'Token not valid yet', 'message' => 'Token not valid yet',
'data' => [] 'data' => []
], 401); ], 401);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->respond([ return $this->respond([
'status' => 'failed', 'status' => 'failed',
'message' => 'Invalid token: ' . $e->getMessage(), 'message' => 'Invalid token: ' . $e->getMessage(),
'data' => [] 'data' => []
], 401); ], 401);
} }
} }
// ok // ok
public function login() { // public function login() {
// // Ambil dari JSON Form dan Key .env
// $username = $this->request->getVar('username');
// $password = $this->request->getVar('password');
// $key = getenv('JWT_SECRET');
// if (!$username) {
// return $this->fail('Username required.', 400);
// }
// $sql = "SELECT * FROM users WHERE username=" . $this->db->escape($username);
// $query = $this->db->query($sql);
// $row = $query->getResultArray();
// if (!$row) { return $this->fail('User not found.', 401); }
// $row = $row[0];
// if (!password_verify($password, $row['password'])) {
// return $this->fail('Invalid password.', 401);
// }
// // Buat JWT payload
// $exp = time() + 864000;
// $payload = [
// 'userid' => $row['id'],
// 'roleid' => $row['role_id'],
// 'username' => $row['username'],
// 'exp' => $exp
// ];
// try {
// // Melakukan Hash terhadap Payload dengan Kunci .env menggunakan Algortima HMAC + SHA-256
// $jwt = JWT::encode($payload, $key, 'HS256');
// } catch (Exception $e) {
// return $this->fail('Error generating JWT: ' . $e->getMessage(), 500);
// }
// // Kirim Respon ke HttpOnly yg akan disimpan di browser dan tidak akan dapat diakses oleh siapapun
// // $isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
// $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' => $isSecure, // true for HTTPS, false for HTTP (localhost)
// // '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([
// 'status' => 'success',
// 'code' => 200,
// 'message' => 'Login successful'
// ], 200);
// }
public function login()
{
// Ambil dari JSON Form dan Key .env // Ambil dari JSON Form dan Key .env
$username = $this->request->getVar('username'); $username = $this->request->getVar('username');
@ -89,7 +150,9 @@ class Auth extends Controller {
$query = $this->db->query($sql); $query = $this->db->query($sql);
$row = $query->getResultArray(); $row = $query->getResultArray();
if (!$row) { return $this->fail('User not found.', 401); } if (!$row) {
return $this->fail('User not found.', 401);
}
$row = $row[0]; $row = $row[0];
if (!password_verify($password, $row['password'])) { if (!password_verify($password, $row['password'])) {
return $this->fail('Invalid password.', 401); return $this->fail('Invalid password.', 401);
@ -98,10 +161,10 @@ class Auth extends Controller {
// Buat JWT payload // Buat JWT payload
$exp = time() + 864000; $exp = time() + 864000;
$payload = [ $payload = [
'userid' => $row['id'], 'userid' => $row['id'],
'roleid' => $row['role_id'], 'roleid' => $row['role_id'],
'username' => $row['username'], 'username' => $row['username'],
'exp' => $exp 'exp' => $exp
]; ];
try { try {
@ -113,44 +176,64 @@ class Auth extends Controller {
// Kirim Respon ke HttpOnly yg akan disimpan di browser dan tidak akan dapat diakses oleh siapapun // Kirim Respon ke HttpOnly yg akan disimpan di browser dan tidak akan dapat diakses oleh siapapun
$this->response->setCookie([ $this->response->setCookie([
'name' => 'token', // nama token 'name' => 'token', // nama token
'value' => $jwt, // value dari jwt yg sudah di hash 'value' => $jwt, // value dari jwt yg sudah di hash
'expire' => 864000, // 10 hari 'expire' => 864000, // 10 hari
'path' => '/', // valid untuk semua path 'path' => '/', // valid untuk semua path
'secure' => true, // set true kalau sudah HTTPS 'secure' => true, // set true kalau sudah HTTPS
'httponly' => true, // dipakai agar cookie berikut tidak dapat diakses oleh javascript 'httponly' => true, // dipakai agar cookie berikut tidak dapat diakses oleh javascript
'samesite' => Cookie::SAMESITE_NONE 'samesite' => Cookie::SAMESITE_NONE
]); ]);
// Response tanpa token di body // Response tanpa token di body
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'code' => 200, 'code' => 200,
'message' => 'Login successful' 'message' => 'Login successful'
], 200); ], 200);
} }
// ok // ok
public function logout() { // public function logout() {
// // Definisikan ini pada cookies browser, harus sama dengan cookies login
// // $isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
// return $this->response->setCookie([
// 'name' => 'token',
// 'value' => '',
// 'expire' => time() - 3600,
// 'path' => '/',
// 'secure' => $isSecure,
// 'httponly' => true,
// 'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX
// ])->setJSON([
// 'status' => 'success',
// 'code' => 200,
// 'message' => 'Logout successful'
// ], 200);
// }
public function logout()
{
// Definisikan ini pada cookies browser, harus sama dengan cookies login // Definisikan ini pada cookies browser, harus sama dengan cookies login
return $this->response->setCookie([ return $this->response->setCookie([
'name' => 'token', 'name' => 'token',
'value' => '', 'value' => '',
'expire' => time() - 3600, 'expire' => time() - 3600,
'path' => '/', 'path' => '/',
'secure' => true, 'secure' => true,
'httponly' => true, 'httponly' => true,
'samesite' => Cookie::SAMESITE_NONE 'samesite' => Cookie::SAMESITE_NONE
])->setJSON([ ])->setJSON([
'status' => 'success', 'status' => 'success',
'code' => 200, 'code' => 200,
'message' => 'Logout successful' 'message' => 'Logout successful'
], 200); ], 200);
} }
// ok // ok
public function register() { public function register()
{
$username = strtolower($this->request->getJsonVar('username')); $username = strtolower($this->request->getJsonVar('username'));
$password = $this->request->getJsonVar('password'); $password = $this->request->getJsonVar('password');
@ -158,7 +241,7 @@ class Auth extends Controller {
// Validasi Awal Dari BE // Validasi Awal Dari BE
if (empty($username) || empty($password)) { if (empty($username) || empty($password)) {
return $this->respond([ return $this->respond([
'status' => 'failed', 'status' => 'failed',
'code' => 400, 'code' => 400,
'message' => 'Username and password are required' 'message' => 'Username and password are required'
], 400); // Gunakan 400 Bad Request ], 400); // Gunakan 400 Bad Request
@ -167,11 +250,11 @@ class Auth extends Controller {
// Cek Duplikasi Username // Cek Duplikasi Username
$exists = $this->db->query("SELECT id FROM users WHERE username = ?", [$username])->getRow(); $exists = $this->db->query("SELECT id FROM users WHERE username = ?", [$username])->getRow();
if ($exists) { 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); $hashedPassword = password_hash($password, PASSWORD_DEFAULT);
// Mulai transaksi Insert // Mulai transaksi Insert
$this->db->transStart(); $this->db->transStart();
$this->db->query( $this->db->query(
@ -183,8 +266,8 @@ class Auth extends Controller {
// Cek status transaksi // Cek status transaksi
if ($this->db->transStatus() === false) { if ($this->db->transStatus() === false) {
return $this->respond([ return $this->respond([
'status' => 'error', 'status' => 'error',
'code' => 500, 'code' => 500,
'message' => 'Failed to create user. Please try again later.' 'message' => 'Failed to create user. Please try again later.'
], 500); ], 500);
} }
@ -194,7 +277,7 @@ class Auth extends Controller {
'status' => 'success', 'status' => 'success',
'code' => 201, 'code' => 201,
'message' => 'User ' . $username . ' successfully created.' 'message' => 'User ' . $username . ' successfully created.'
], 201); ], 201);
} }
@ -219,19 +302,20 @@ class Auth extends Controller {
// return $this->respond($response); // return $this->respond($response);
// } // }
public function coba() { public function coba()
{
$token = $this->request->getCookie('token'); $token = $this->request->getCookie('token');
$key = getenv('JWT_SECRET'); $key = getenv('JWT_SECRET');
// Decode Token dengan Key yg ada di .env // Decode Token dengan Key yg ada di .env
$decodedPayload = JWT::decode($token, new Key($key, 'HS256')); $decodedPayload = JWT::decode($token, new Key($key, 'HS256'));
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'Authenticated', 'message' => 'Authenticated',
'data' => $decodedPayload 'data' => $decodedPayload
], 200); ], 200);
} }
} }

View File

@ -0,0 +1,238 @@
<?php
namespace App\Controllers;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use CodeIgniter\Cookie\Cookie;
/**
* AuthV2 Controller
*
* Handles authentication for V2 UI
* Separate from the main Auth controller to avoid conflicts
*/
class AuthV2Controller extends Controller
{
use ResponseTrait;
protected $db;
public function __construct()
{
$this->db = \Config\Database::connect();
}
/**
* Check authentication status
* GET /v2/auth/check
*/
public function checkAuth()
{
$token = $this->request->getCookie('token');
$key = getenv('JWT_SECRET');
if (!$token) {
return $this->respond([
'status' => 'failed',
'message' => 'No token found'
], 401);
}
try {
$decodedPayload = JWT::decode($token, new Key($key, 'HS256'));
return $this->respond([
'status' => 'success',
'message' => 'Authenticated',
'data' => $decodedPayload
], 200);
} catch (ExpiredException $e) {
return $this->respond([
'status' => 'failed',
'message' => 'Token expired'
], 401);
} catch (SignatureInvalidException $e) {
return $this->respond([
'status' => 'failed',
'message' => 'Invalid token signature'
], 401);
} catch (BeforeValidException $e) {
return $this->respond([
'status' => 'failed',
'message' => 'Token not valid yet'
], 401);
} catch (\Exception $e) {
return $this->respond([
'status' => 'failed',
'message' => 'Invalid token: ' . $e->getMessage()
], 401);
}
}
/**
* Login user
* POST /v2/auth/login
*/
public function login()
{
$username = $this->request->getVar('username');
$password = $this->request->getVar('password');
$key = getenv('JWT_SECRET');
// Validate username
if (!$username) {
return $this->respond([
'status' => 'failed',
'message' => 'Username is required'
], 400);
}
// Find user
$sql = "SELECT * FROM users WHERE username = " . $this->db->escape($username);
$query = $this->db->query($sql);
$row = $query->getResultArray();
if (!$row) {
return $this->respond([
'status' => 'failed',
'message' => 'User not found'
], 401);
}
$row = $row[0];
// Verify password
if (!password_verify($password, $row['password'])) {
return $this->respond([
'status' => 'failed',
'message' => 'Invalid password'
], 401);
}
// Create JWT payload
$exp = time() + 864000; // 10 days
$payload = [
'userid' => $row['id'],
'roleid' => $row['role_id'],
'username' => $row['username'],
'exp' => $exp
];
try {
$jwt = JWT::encode($payload, $key, 'HS256');
} catch (\Exception $e) {
return $this->respond([
'status' => 'failed',
'message' => 'Error generating JWT: ' . $e->getMessage()
], 500);
}
// Detect if HTTPS is being used
$isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
// Set HTTP-only cookie
$this->response->setCookie([
'name' => 'token',
'value' => $jwt,
'expire' => 864000,
'path' => '/',
'secure' => $isSecure, // false for localhost HTTP
'httponly' => true,
'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX
]);
return $this->respond([
'status' => 'success',
'message' => 'Login successful',
'data' => [
'username' => $row['username'],
'role_id' => $row['role_id']
]
], 200);
}
/**
* Logout user
* POST /v2/auth/logout
*/
public function logout()
{
// Detect if HTTPS is being used
$isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
// Clear the token cookie
return $this->response->setCookie([
'name' => 'token',
'value' => '',
'expire' => time() - 3600,
'path' => '/',
'secure' => $isSecure,
'httponly' => true,
'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX
])->setJSON([
'status' => 'success',
'message' => 'Logout successful'
]);
}
/**
* Register new user
* POST /v2/auth/register
*/
public function register()
{
$username = strtolower($this->request->getJsonVar('username'));
$password = $this->request->getJsonVar('password');
// Validate input
if (empty($username) || empty($password)) {
return $this->respond([
'status' => 'failed',
'message' => 'Username and password are required'
], 400);
}
// Check for existing username
$exists = $this->db->query("SELECT id FROM users WHERE username = ?", [$username])->getRow();
if ($exists) {
return $this->respond([
'status' => 'failed',
'message' => 'Username already exists'
], 409);
}
// Hash password
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
// Insert user
$this->db->transStart();
$this->db->query(
"INSERT INTO users(username, password, role_id) VALUES(?, ?, ?)",
[$username, $hashedPassword, 1]
);
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->respond([
'status' => 'failed',
'message' => 'Failed to create user'
], 500);
}
return $this->respond([
'status' => 'success',
'message' => 'User ' . $username . ' successfully created'
], 201);
}
}

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Contact\ContactModel; use App\Models\Contact\ContactModel;
class Contact extends BaseController { class ContactController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -33,13 +33,13 @@ class Contact extends BaseController {
public function show($ContactID = null) { public function show($ContactID = null) {
$model = new ContactModel(); $model = new ContactModel();
$rows = $model->getContactWithDetail($ContactID); $row = $model->getContactWithDetail($ContactID);
if (empty($rows)) { if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
} }
public function delete() { public function delete() {
@ -76,4 +76,4 @@ class Contact extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
} }
} }

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\Contact\MedicalSpecialtyModel; use App\Models\Contact\MedicalSpecialtyModel;
class MedicalSpecialty extends BaseController { class MedicalSpecialtyController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -32,11 +32,11 @@ class MedicalSpecialty extends BaseController {
public function show($SpecialtyID = null) { public function show($SpecialtyID = null) {
$model = new MedicalSpecialtyModel(); $model = new MedicalSpecialtyModel();
$rows = $model->find($SpecialtyID); $row = $model->find($SpecialtyID);
if (empty($rows)) { if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message' => "no Data."], 200); return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
} }
public function create() { public function create() {
@ -61,4 +61,4 @@ class MedicalSpecialty extends BaseController {
} }
} }
} }

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\Contact\OccupationModel; use App\Models\Contact\OccupationModel;
class Occupation extends BaseController { class OccupationController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -32,11 +32,11 @@ class Occupation extends BaseController {
public function show($OccupationID = null) { public function show($OccupationID = null) {
$model = new OccupationModel(); $model = new OccupationModel();
$rows = $model->find($OccupationID); $row = $model->find($OccupationID);
if (empty($rows)) { if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message' => "no Data."], 200); return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
} }
public function create() { public function create() {
@ -61,4 +61,4 @@ class Occupation extends BaseController {
} }
} }
} }

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\CounterModel; use App\Models\CounterModel;
class Counter extends BaseController { class CounterController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $model; protected $model;
@ -24,13 +24,13 @@ class Counter extends BaseController {
} }
public function show($CounterID = null) { public function show($CounterID = null) {
$rows = $this->model->find($CounterID); $row = $this->model->find($CounterID);
if (empty($rows)) { if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message' => "No Data.", 'data' => [] ], 200); return $this->respond([ 'status' => 'success', 'message' => "No Data.", 'data' => null ], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row ], 200);
} }
public function create() { public function create() {
@ -62,4 +62,4 @@ class Counter extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
} }
} }

View File

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

View File

@ -0,0 +1,169 @@
<?php
namespace App\Controllers;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
class EdgeController extends Controller
{
use ResponseTrait;
protected $db;
protected $edgeResModel;
public function __construct()
{
$this->db = \Config\Database::connect();
$this->edgeResModel = new \App\Models\EdgeResModel();
}
/**
* POST /api/edge/results
* Receive results from tiny-edge
*/
public function results()
{
try {
$input = $this->request->getJSON(true);
if (empty($input)) {
return $this->failValidationErrors('Invalid JSON payload');
}
// Extract key fields from payload
$sampleId = $input['sample_id'] ?? null;
$instrumentId = $input['instrument_id'] ?? null;
$patientId = $input['patient_id'] ?? null;
// Store in edgeres table
$data = [
'SiteID' => 1, // Default site, can be configured
'InstrumentID' => $instrumentId,
'SampleID' => $sampleId,
'PatientID' => $patientId,
'Payload' => json_encode($input),
'Status' => 'pending',
'AutoProcess' => 0, // Default to manual processing
'CreateDate' => date('Y-m-d H:i:s')
];
$id = $this->edgeResModel->insert($data);
if (!$id) {
return $this->failServerError('Failed to save result');
}
return $this->respondCreated([
'status' => 'success',
'message' => 'Result received and queued',
'data' => [
'edge_res_id' => $id,
'sample_id' => $sampleId,
'instrument_id' => $instrumentId
]
]);
} catch (\Throwable $e) {
return $this->failServerError('Error processing result: ' . $e->getMessage());
}
}
/**
* GET /api/edge/orders
* Return pending orders for an instrument
*/
public function orders()
{
try {
$instrumentId = $this->request->getGet('instrument');
if (!$instrumentId) {
return $this->failValidationErrors('instrument parameter is required');
}
// TODO: Implement order fetching logic
// For now, return empty array
return $this->respond([
'status' => 'success',
'message' => 'Orders fetched',
'data' => []
]);
} catch (\Throwable $e) {
return $this->failServerError('Error fetching orders: ' . $e->getMessage());
}
}
/**
* POST /api/edge/orders/:id/ack
* Acknowledge order delivery
*/
public function ack($orderId = null)
{
try {
if (!$orderId) {
return $this->failValidationErrors('Order ID is required');
}
$input = $this->request->getJSON(true);
$instrumentId = $input['instrument_id'] ?? null;
// Log acknowledgment
$this->db->table('edgeack')->insert([
'OrderID' => $orderId,
'InstrumentID' => $instrumentId,
'AckDate' => date('Y-m-d H:i:s'),
'CreateDate' => date('Y-m-d H:i:s')
]);
return $this->respond([
'status' => 'success',
'message' => 'Order acknowledged',
'data' => [
'order_id' => $orderId
]
]);
} catch (\Throwable $e) {
return $this->failServerError('Error acknowledging order: ' . $e->getMessage());
}
}
/**
* POST /api/edge/status
* Log instrument status
*/
public function status()
{
try {
$input = $this->request->getJSON(true);
$instrumentId = $input['instrument_id'] ?? null;
$status = $input['status'] ?? null;
$lastActivity = $input['last_activity'] ?? null;
$timestamp = $input['timestamp'] ?? date('Y-m-d H:i:s');
if (!$instrumentId || !$status) {
return $this->failValidationErrors('instrument_id and status are required');
}
// Store status log
$this->db->table('edgestatus')->insert([
'InstrumentID' => $instrumentId,
'Status' => $status,
'LastActivity' => $lastActivity,
'Timestamp' => $timestamp,
'CreateDate' => date('Y-m-d H:i:s')
]);
return $this->respond([
'status' => 'success',
'message' => 'Status logged'
]);
} catch (\Throwable $e) {
return $this->failServerError('Error logging status: ' . $e->getMessage());
}
}
}

View File

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

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\Location\LocationModel; use App\Models\Location\LocationModel;
class Location extends BaseController { class LocationController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $model; protected $model;
@ -31,20 +31,20 @@ class Location extends BaseController {
} }
public function show($LocationID = null) { public function show($LocationID = null) {
$rows = $this->model->getLocation($LocationID); $row = $this->model->getLocation($LocationID);
if (empty($rows)) { if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
} }
public function create() { public function create() {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try { try {
$id = $this->model->saveLocation($input); $result = $this->model->saveLocation($input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data created successfully', 'data' => $id ], 201); return $this->respondCreated([ 'status' => 'success', 'message' => 'data created successfully', 'data' => $result ], 201);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
@ -54,8 +54,8 @@ class Location extends BaseController {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
try { try {
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors()); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors()); }
$id = $this->model->saveLocation($input, true); $result = $this->model->saveLocation($input, true);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201); return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $result ], 201);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
@ -72,4 +72,4 @@ class Location extends BaseController {
} }
} }
} }

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller; use CodeIgniter\Controller;
use CodeIgniter\Database\RawSql; use CodeIgniter\Database\RawSql;
class OrderTest extends Controller { class OrderTestController extends Controller {
use ResponseTrait; use ResponseTrait;
public function __construct() { public function __construct() {
@ -34,13 +34,13 @@ class OrderTest extends Controller {
} }
public function show($OrderID = null) { public function show($OrderID = null) {
$row=$this->db->table('ordertest')->select("*")->where('OrderID=', $OrderID)->get()->getResultArray(); $row=$this->db->table('ordertest')->select("*")->where('OrderID', $OrderID)->get()->getRowArray();
if (empty($row)) { if (empty($row)) {
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => "Data not found.", 'message' => "Data not found.",
'data' => [], 'data' => null,
], 200); ], 200);
} }
@ -214,4 +214,4 @@ class OrderTest extends Controller {
return $data; return $data;
} }
} }

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\AccountModel; use App\Models\Organization\AccountModel;
class Account extends BaseController { class AccountController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -34,13 +34,13 @@ class Account extends BaseController {
public function show($AccountID = null) { public function show($AccountID = null) {
//$rows = $this->model->where('AccountID', $AccountID)->findAll(); //$rows = $this->model->where('AccountID', $AccountID)->findAll();
$rows = $this->model->getAccount($AccountID); $row = $this->model->getAccount($AccountID);
if (empty($rows)) { if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
} }
public function delete() { public function delete() {
@ -76,4 +76,4 @@ class Account extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
} }
} }

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\DepartmentModel; use App\Models\Organization\DepartmentModel;
class Department extends BaseController { class DepartmentController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -32,13 +32,12 @@ class Department extends BaseController {
} }
public function show($DepartmentID = null) { public function show($DepartmentID = null) {
//$rows = $this->model->where('DepartmentID', $DepartmentID)->findAll(); $row = $this->model->getDepartment($DepartmentID);
$rows = $this->model->getDepartment($DepartmentID); if (empty($row)) {
if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200);
return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
} }
public function delete() { public function delete() {
@ -73,4 +72,4 @@ class Department extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
} }
} }

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\DisciplineModel; use App\Models\Organization\DisciplineModel;
class Discipline extends BaseController { class DisciplineController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -32,13 +32,13 @@ class Discipline extends BaseController {
} }
public function show($DisciplineID = null) { public function show($DisciplineID = null) {
$rows = $this->model->where('DisciplineID', $DisciplineID)->findAll(); $row = $this->model->where('DisciplineID', $DisciplineID)->first();
if (empty($rows)) { if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
} }
public function delete() { public function delete() {
@ -79,4 +79,4 @@ class Discipline extends BaseController {
} }
*/ */
} }
} }

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\SiteModel; use App\Models\Organization\SiteModel;
class Site extends BaseController { class SiteController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -33,13 +33,13 @@ class Site extends BaseController {
public function show($SiteID = null) { public function show($SiteID = null) {
//$rows = $this->model->where('SiteID', $SiteID)->findAll(); //$rows = $this->model->where('SiteID', $SiteID)->findAll();
$rows = $this->model->getSite($SiteID); $row = $this->model->getSite($SiteID);
if (empty($rows)) { if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
} }
public function delete() { public function delete() {
@ -75,4 +75,4 @@ class Site extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
} }
} }

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\WorkstationModel; use App\Models\Organization\WorkstationModel;
class Workstation extends BaseController { class WorkstationController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -32,13 +32,13 @@ class Workstation extends BaseController {
} }
public function show($WorkstationID = null) { public function show($WorkstationID = null) {
$rows = $this->model->getWorkstation($WorkstationID); $row = $this->model->getWorkstation($WorkstationID);
if (empty($rows)) { if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
} }
public function delete() { public function delete() {
@ -73,4 +73,4 @@ class Workstation extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
} }
} }

View File

@ -1,43 +0,0 @@
<?php
namespace App\Controllers\Pages;
use CodeIgniter\Controller;
/**
* Auth Pages Controller
* Handles rendering of authentication-related pages
*/
class AuthPage extends Controller
{
/**
* Display the login page
*/
public function login()
{
// Check if user is already authenticated
$token = $this->request->getCookie('token');
if ($token) {
// If token exists, redirect to dashboard
return redirect()->to('/dashboard');
}
return view('pages/login', [
'title' => 'Login',
'description' => 'Sign in to your CLQMS account'
]);
}
/**
* Handle logout - clear cookie and redirect
*/
public function logout()
{
// Delete the token cookie
$response = service('response');
$response->deleteCookie('token');
return redirect()->to('/login');
}
}

View File

@ -1,49 +0,0 @@
<?php
namespace App\Controllers\Pages;
use CodeIgniter\Controller;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
/**
* Dashboard Page Controller
* Handles rendering of the main dashboard
*/
class DashboardPage extends Controller
{
/**
* Display the dashboard page
*/
public function index()
{
// Check authentication
$token = $this->request->getCookie('token');
if (!$token) {
return redirect()->to('/login');
}
try {
$key = getenv('JWT_SECRET');
$decoded = JWT::decode($token, new Key($key, 'HS256'));
return view('pages/dashboard', [
'title' => 'Dashboard',
'description' => 'CLQMS Dashboard - Overview',
'user' => $decoded
]);
} catch (ExpiredException $e) {
// Token expired, redirect to login
$response = service('response');
$response->deleteCookie('token');
return redirect()->to('/login');
} catch (\Exception $e) {
// Invalid token
$response = service('response');
$response->deleteCookie('token');
return redirect()->to('/login');
}
}
}

View File

@ -0,0 +1,178 @@
<?php
namespace App\Controllers;
/**
* PagesController - Serves view pages
*
* This controller only returns views. No business logic.
* All data is fetched via API calls from the frontend.
*/
class PagesController extends BaseController
{
/**
* Dashboard page
*/
public function dashboard()
{
return view('v2/dashboard/dashboard_index', [
'pageTitle' => 'Dashboard',
'activePage' => 'dashboard'
]);
}
/**
* Patients page
*/
public function patients()
{
return view('v2/patients/patients_index', [
'pageTitle' => 'Patients',
'activePage' => 'patients'
]);
}
/**
* Lab Requests page
*/
public function requests()
{
return view('v2/requests/requests_index', [
'pageTitle' => 'Lab Requests',
'activePage' => 'requests'
]);
}
/**
* Settings page
*/
public function settings()
{
return view('v2/settings/settings_index', [
'pageTitle' => 'Settings',
'activePage' => 'settings'
]);
}
// ========================================
// Master Data - Organization
// ========================================
/**
* Master Data - Organization Accounts
*/
public function masterOrgAccounts()
{
return view('v2/master/organization/accounts_index', [
'pageTitle' => 'Organization Accounts',
'activePage' => 'master-org-accounts'
]);
}
/**
* Master Data - Organization Sites
*/
public function masterOrgSites()
{
return view('v2/master/organization/sites_index', [
'pageTitle' => 'Organization Sites',
'activePage' => 'master-org-sites'
]);
}
/**
* Master Data - Organization Disciplines
*/
public function masterOrgDisciplines()
{
return view('v2/master/organization/disciplines_index', [
'pageTitle' => 'Disciplines',
'activePage' => 'master-org-disciplines'
]);
}
/**
* Master Data - Organization Departments
*/
public function masterOrgDepartments()
{
return view('v2/master/organization/departments_index', [
'pageTitle' => 'Departments',
'activePage' => 'master-org-departments'
]);
}
/**
* Master Data - Organization Workstations
*/
public function masterOrgWorkstations()
{
return view('v2/master/organization/workstations_index', [
'pageTitle' => 'Workstations',
'activePage' => 'master-org-workstations'
]);
}
// ========================================
// Master Data - Specimen
// ========================================
/**
* Master Data - Specimen Containers
*/
public function masterSpecimenContainers()
{
return view('v2/master/specimen/containers_index', [
'pageTitle' => 'Container Definitions',
'activePage' => 'master-specimen-containers'
]);
}
/**
* Master Data - Specimen Preparations
*/
public function masterSpecimenPreparations()
{
return view('v2/master/specimen/preparations_index', [
'pageTitle' => 'Specimen Preparations',
'activePage' => 'master-specimen-preparations'
]);
}
// ========================================
// Master Data - Tests & ValueSets
// ========================================
/**
* Master Data - Lab Tests
*/
public function masterTests()
{
return view('v2/master/tests/tests_index', [
'pageTitle' => 'Lab Tests',
'activePage' => 'master-tests'
]);
}
/**
* Master Data - Value Sets
*/
public function masterValueSets()
{
return view('v2/master/valuesets/valuesets_index', [
'pageTitle' => 'Value Sets',
'activePage' => 'master-valuesets'
]);
}
/**
* Login page
*/
public function login()
{
return view('v2/auth/login', [
'pageTitle' => 'Login',
'activePage' => ''
]);
}
}

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\PatVisit\PatVisitModel; use App\Models\PatVisit\PatVisitModel;
use App\Models\PatVisit\PatVisitADTModel; use App\Models\PatVisit\PatVisitADTModel;
class PatVisit extends BaseController { class PatVisitController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $model; protected $model;
@ -18,9 +18,10 @@ class PatVisit extends BaseController {
public function show($PVID = null) { public function show($PVID = null) {
try { try {
$row = $this->model->show($PVID); $row = $this->model->show($PVID);
if($row == []) { $message = "data not found"; } if (empty($row)) {
else { $message = "data found"; } return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200);
return $this->respond([ 'status' => 'success', 'message'=> $message, 'data' => $row ], 200); }
return $this->respond([ 'status' => 'success', 'message'=> "data found", 'data' => $row ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong '.$e->getMessage()); return $this->failServerError('Something went wrong '.$e->getMessage());
} }
@ -81,4 +82,4 @@ class PatVisit extends BaseController {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
} }
} }

View File

@ -6,7 +6,7 @@ use CodeIgniter\Controller;
use App\Models\Patient\PatientModel; use App\Models\Patient\PatientModel;
class Patient extends Controller { class PatientController extends Controller {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -66,9 +66,9 @@ class Patient extends Controller {
public function show($InternalPID = null) { public function show($InternalPID = null) {
try { try {
$rows = $this->model->getPatient($InternalPID); $row = $this->model->getPatient($InternalPID);
if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "data not found." ], 200); } if (empty($row)) { return $this->respond([ 'status' => 'success', 'message' => "data not found.", 'data' => null ], 200); }
return $this->respond([ 'status' => 'success', 'message' => "data fetched successfully", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message' => "data fetched successfully", 'data' => $row ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
@ -214,4 +214,4 @@ class Patient extends Controller {
return $this->failServerError('Something went wrong.'.$e->getMessage()); return $this->failServerError('Something went wrong.'.$e->getMessage());
} }
} }
} }

View File

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

View File

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

View File

@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\Specimen\ContainerDefModel; use App\Models\Specimen\ContainerDefModel;
class ContainerDef extends BaseController { class ContainerDefController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -37,8 +37,11 @@ class ContainerDef extends BaseController {
public function show($ConDefID) { public function show($ConDefID) {
try { try {
$rows = $this->model->getContainer($ConDefID); $row = $this->model->getContainer($ConDefID);
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200); if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200);
}
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $row ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Exception : '.$e->getMessage()); return $this->failServerError('Exception : '.$e->getMessage());
} }
@ -66,4 +69,4 @@ class ContainerDef extends BaseController {
} }
} }
} }

View File

@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\Specimen\SpecimenCollectionModel; use App\Models\Specimen\SpecimenCollectionModel;
class SpecimenCollection extends BaseController { class SpecimenCollectionController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -30,8 +30,11 @@ class SpecimenCollection extends BaseController {
public function show($id) { public function show($id) {
try { try {
$rows = $this->model->where('SpcColID', $id)->findAll(); $row = $this->model->where('SpcColID', $id)->first();
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200); if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200);
}
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $row ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Exception : '.$e->getMessage()); return $this->failServerError('Exception : '.$e->getMessage());
} }
@ -59,4 +62,4 @@ class SpecimenCollection extends BaseController {
} }
} }
} }

View File

@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\Specimen\SpecimenModel; use App\Models\Specimen\SpecimenModel;
class Specimen extends BaseController { class SpecimenController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -30,8 +30,11 @@ class Specimen extends BaseController {
public function show($id) { public function show($id) {
try { try {
$rows = $this->model->where('SID',$id)->findAll(); $row = $this->model->where('SID',$id)->first();
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200); if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200);
}
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $row ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Exception : '.$e->getMessage()); return $this->failServerError('Exception : '.$e->getMessage());
} }
@ -59,4 +62,4 @@ class Specimen extends BaseController {
} }
} }
} }

View File

@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\Specimen\SpecimenPrepModel; use App\Models\Specimen\SpecimenPrepModel;
class SpecimenPrep extends BaseController { class SpecimenPrepController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -30,8 +30,11 @@ class SpecimenPrep extends BaseController {
public function show($id) { public function show($id) {
try { try {
$rows = $this->model->where('SpcPrpID', $id)->findAll(); $row = $this->model->where('SpcPrpID', $id)->first();
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200); if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200);
}
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $row ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Exception : '.$e->getMessage()); return $this->failServerError('Exception : '.$e->getMessage());
} }
@ -59,4 +62,4 @@ class SpecimenPrep extends BaseController {
} }
} }
} }

View File

@ -30,8 +30,11 @@ class ContainerDef extends BaseController {
public function show($id) { public function show($id) {
try { try {
$rows = $this->model->where('SpcStaID', $id)->findAll(); $row = $this->model->where('SpcStaID', $id)->first();
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200); if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200);
}
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $row ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Exception : '.$e->getMessage()); return $this->failServerError('Exception : '.$e->getMessage());
} }
@ -59,4 +62,4 @@ class ContainerDef extends BaseController {
} }
} }
} }

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\Test\TestMapModel; use App\Models\Test\TestMapModel;
class TestMap extends BaseController { class TestMapController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -24,9 +24,9 @@ class TestMap extends BaseController {
} }
public function show($id = null) { public function show($id = null) {
$rows = $this->model->where('TestMapID',$id)->findAll(); $row = $this->model->where('TestMapID',$id)->first();
if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); } if (empty($row)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); }
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row ], 200);
} }
public function create() { public function create() {
@ -53,4 +53,4 @@ class TestMap extends BaseController {
} }
} }
} }

View File

@ -1,186 +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 $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->modelValueSet = new \App\Models\ValueSet\ValueSetModel;
// Basic validation for the main part
$this->rules = [
'TestSiteCode' => 'required',
'TestSiteName' => 'required',
'TestType' => 'required'
];
}
public function index() {
$rows = $this->model->getTests();
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);
}
public function show($id = null) {
if (!$id) return $this->failValidationErrors('ID is required');
$rows = $this->model->getTest($id);
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);
}
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)
$id = $this->model->insert($input);
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' => 'success', 'message' => "data created successfully", 'data'=> $id ]);
} catch (\Exception $e) {
$this->db->transRollback();
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
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.'); }
// Optional validation
// if (!$this->validateData($input, $this->rules)) { ... }
$this->db->transStart();
try {
// 1. Update Main Table
$this->model->update($id, $input);
// 2. Handle Details
$this->handleDetails($id, $input, 'update');
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->failServerError('Transaction failed');
}
return $this->respondCreated([ 'status' => 'success', 'message' => "data updated successfully", 'data'=> $id ]);
} 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; // Should not happen if required
// Get Type Code (TEST, PARAM, CALC, GROUP, TITLE)
$vs = $this->modelValueSet->find($testTypeID);
$typeCode = $vs['VValue'] ?? '';
// Get details data if present (for 'details' key in unified JSON)
// We accept both flat (top-level) and nested 'details' for flexibility, prefer 'details'
$details = $input['details'] ?? $input;
$details['TestSiteID'] = $testSiteID; // Ensure foreign key is set
$details['SiteID'] = $input['SiteID'] ?? 1;
switch ($typeCode) {
case 'CALC':
$this->saveSubTable($this->modelCal, $testSiteID, $details, $action, 'TestCalID');
break;
case 'GROUP':
// Groups are special: List of members
// Payload expected: details: { members: [{Member: 1}, {Member: 2}] }
if ($action === 'update') {
$this->modelGrp->where('TestSiteID', $testSiteID)->delete();
}
$members = $details['members'] ?? ($input['Members'] ?? []);
if (is_array($members)) {
foreach ($members as $m) {
$memberID = is_array($m) ? ($m['Member'] ?? null) : $m;
if ($memberID) {
$this->modelGrp->insert([
'SiteID' => $details['SiteID'],
'TestSiteID' => $testSiteID,
'Member' => $memberID
]);
}
}
}
break;
case 'TEST':
case 'PARAM':
default:
// Default to TestDefTech for 'TEST' and 'PARAM'
$this->saveSubTable($this->modelTech, $testSiteID, $details, $action, 'TestTechID');
break;
}
}
private function saveSubTable($model, $testSiteID, $data, $action, $pkName) {
if ($action === 'update') {
// Check existence
$exists = $model->where('TestSiteID', $testSiteID)->first();
if ($exists) {
$model->update($exists[$pkName], $data);
} else {
$model->insert($data);
}
} else {
$model->insert($data);
}
}
}

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\Controllers\BaseController;
use App\Models\ValueSet\ValueSetModel; use App\Models\ValueSet\ValueSetModel;
class ValueSet extends BaseController { class ValueSetController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -23,17 +23,31 @@ class ValueSet extends BaseController {
public function index() { public function index() {
$param = $this->request->getVar('param'); $param = $this->request->getVar('param');
$rows = $this->model->getValueSets($param); $VSetID = $this->request->getVar('VSetID');
if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); } $page = $this->request->getVar('page') ?? 1;
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); $limit = $this->request->getVar('limit') ?? 20;
$result = $this->model->getValueSets($param, $page, $limit, $VSetID);
return $this->respond([
'status' => 'success',
'message'=> "Data fetched successfully",
'data' => $result['data'],
'pagination' => [
'currentPage' => (int)$page,
'totalPages' => $result['pager']->getPageCount(),
'totalItems' => $result['pager']->getTotal(),
'limit' => (int)$limit
]
], 200);
} }
public function show($VID = null) { public function show($VID = null) {
$rows = $this->model->getValueSet($VID); $row = $this->model->getValueSet($VID);
if (empty($rows)) { if (empty($row)) {
return $this->respond([ 'status' => 'success', 'message' => "ValueSet with ID $VID not found.", 'data' => [] ], 200); return $this->respond([ 'status' => 'success', 'message' => "ValueSet with ID $VID not found.", 'data' => null ], 200);
} }
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows], 200); return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row], 200);
} }
public function showByValueSetDef($VSetID = null) { public function showByValueSetDef($VSetID = null) {
@ -80,4 +94,4 @@ class ValueSet extends BaseController {
} }
} }
} }

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\ValueSet\ValueSetDefModel; use App\Models\ValueSet\ValueSetDefModel;
class ValueSetDef extends BaseController { class ValueSetDefController extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
@ -29,9 +29,9 @@ class ValueSetDef extends BaseController {
} }
public function show($VSetID = null) { public function show($VSetID = null) {
$rows = $this->model->find($VSetID); $row = $this->model->find($VSetID);
if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); } if (empty($row)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); }
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row ], 200);
} }
public function create() { public function create() {
@ -70,4 +70,4 @@ class ValueSetDef extends BaseController {
} }
} }
} }

View File

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

View File

@ -24,12 +24,14 @@ class CreateLocationTable extends Migration {
$this->forge->addField([ $this->forge->addField([
'LocationID' => ['type' => 'INT', 'unsigned' => true], 'LocationID' => ['type' => 'INT', 'unsigned' => true],
'Street1' => ['type' => 'Varchar', 'constraint' => 255, 'null' => true], 'Street1' => ['type' => 'Varchar', 'constraint' => 255, 'null' => true],
'Street2' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false], 'Street2' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'City' => ['type' => 'int', 'null' => true], 'City' => ['type' => 'int', 'null' => true],
'Province' => ['type' => 'int', 'null' => true], 'Province' => ['type' => 'int', 'null' => true],
'PostCode' => ['type' => 'varchar', 'constraint' => 255, 'null' => true], 'PostCode' => ['type' => 'varchar', 'constraint' => 255, 'null' => true],
'GeoLocationSystem' => ['type' => 'varchar', 'constraint' => 255, 'null' => true], 'GeoLocationSystem' => ['type' => 'varchar', 'constraint' => 255, 'null' => true],
'GeoLocationData' => ['type' => 'varchar', 'constraint' => 255, 'null' => true], 'GeoLocationData' => ['type' => 'varchar', 'constraint' => 255, 'null' => true],
'Phone' => ['type' => 'varchar', 'constraint' => 100, 'null' => true],
'Email' => ['type' => 'varchar', 'constraint' => 150, 'null' => true],
'CreateDate' => ['type' => 'DATETIME', 'null' => true], 'CreateDate' => ['type' => 'DATETIME', 'null' => true],
'EndDate' => ['type' => 'DATETIME', 'null' => true] 'EndDate' => ['type' => 'DATETIME', 'null' => true]
]); ]);

View File

@ -6,97 +6,107 @@ use CodeIgniter\Database\Migration;
class CreateTestsTable extends Migration { class CreateTestsTable extends Migration {
public function up() { public function up() {
// testdefsite - Main test definition table per site
$this->forge->addField([ $this->forge->addField([
'TestSiteID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'TestSiteID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'SiteID' => ['type' => 'INT', 'null' => false], 'SiteID' => ['type' => 'INT', 'null' => false],
'TestSiteCode' => ['type' => 'varchar', 'constraint'=> 6, 'null' => false], 'TestSiteCode' => ['type' => 'varchar', 'constraint'=> 6, 'null' => false],
'TestSiteName' => ['type' => 'varchar', 'constraint'=> 50, 'null' => false], 'TestSiteName' => ['type' => 'varchar', 'constraint'=> 100, 'null' => false],
'TestType' => ['type' => 'int', 'null' => false], 'TestType' => ['type' => 'int', 'null' => false],
'Description' => ['type' => 'varchar', 'constraint'=> 150, 'null' => true], 'Description' => ['type' => 'varchar', 'constraint'=> 255, 'null' => true],
'SeqScr' => ['type' => 'int', 'null' => true], 'SeqScr' => ['type' => 'int', 'null' => true],
'SeqRpt' => ['type' => 'int', 'null' => true], 'SeqRpt' => ['type' => 'int', 'null' => true],
'IndentLeft' => ['type' => 'int', 'null' => true], 'IndentLeft' => ['type' => 'int', 'null' => true, 'default' => 0],
'VisibleScr' => ['type' => 'int', 'null' => true], 'FontStyle' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'VisibleRpt' => ['type' => 'int', 'null' => true], 'VisibleScr' => ['type' => 'int', 'null' => true, 'default' => 1],
'CountStat' => ['type' => 'int', 'null' => true], 'VisibleRpt' => ['type' => 'int', 'null' => true, 'default' => 1],
'CountStat' => ['type' => 'int', 'null' => true, 'default' => 1],
'CreateDate' => ['type' => 'Datetime', 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true],
'StartDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true], 'EndDate' => ['type' => 'Datetime', 'null' => true],
]); ]);
$this->forge->addKey('TestSiteID', true); $this->forge->addKey('TestSiteID', true);
$this->forge->createTable('testdefsite'); $this->forge->createTable('testdefsite');
// testdeftech - Technical definition for TEST and PARAM types
$this->forge->addField([ $this->forge->addField([
'TestTechID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'TestTechID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'SiteID' => ['type' => 'INT', 'null' => true], 'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
'TestSiteID' => ['type' => 'INT', 'null' => false],
'DisciplineID' => ['type' => 'int', 'null' => true], 'DisciplineID' => ['type' => 'int', 'null' => true],
'DepartmentID' => ['type' => 'int', 'null' => true], 'DepartmentID' => ['type' => 'int', 'null' => true],
'ResultType' => ['type' => 'int', 'null' => true], 'ResultType' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true],
'RefType' => ['type' => 'int', 'null' => true], 'RefType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => true],
'VSet' => ['type' => 'int', 'null' => true], 'VSet' => ['type' => 'int', 'null' => true],
'SpcType' => ['type' => 'int', 'null' => true], 'ReqQty' => ['type' => 'DECIMAL', 'constraint'=> '10,2', 'null' => true],
'ReqQty' => ['type' => 'int', 'null' => true], 'ReqQtyUnit' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true],
'ReqQtyUnit' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], 'Unit1' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true],
'Unit1' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], 'Factor' => ['type' => 'DECIMAL', 'constraint'=> '10,4', 'null' => true],
'Factor' => ['type' => 'int', 'null' => true], 'Unit2' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true],
'Unit2' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], 'Decimal' => ['type' => 'int', 'null' => true, 'default' => 2],
'Decimal' => ['type' => 'int', 'null' => true], 'CollReq' => ['type' => 'varchar', 'constraint'=> 255, 'null' => true],
'CollReq' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], 'Method' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'Method' => ['type' => 'varchar', 'constraint'=>50, 'null' => true],
'ExpectedTAT' => ['type' => 'INT', 'null' => true], 'ExpectedTAT' => ['type' => 'INT', 'null' => true],
'CreateDate' => ['type' => 'Datetime', 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true] 'EndDate' => ['type' => 'Datetime', 'null' => true]
]); ]);
$this->forge->addKey('TestTechID', true); $this->forge->addKey('TestTechID', true);
$this->forge->addForeignKey('TestSiteID', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE');
$this->forge->createTable('testdeftech'); $this->forge->createTable('testdeftech');
// testdefcal - Calculation definition for CALC type
$this->forge->addField([ $this->forge->addField([
'TestCalID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'TestCalID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'SiteID' => ['type' => 'INT', 'null' => true], 'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
'TestSiteID' => ['type' => 'INT', 'null' => false],
'DisciplineID' => ['type' => 'INT', 'null' => true], 'DisciplineID' => ['type' => 'INT', 'null' => true],
'DepartmentID' => ['type' => 'INT', 'null' => true], 'DepartmentID' => ['type' => 'INT', 'null' => true],
'FormulaInput' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], 'FormulaInput' => ['type' => 'text', 'null' => true],
'FormulaCode' => ['type' => 'varchar', 'constraint'=>150, 'null' => true], 'FormulaCode' => ['type' => 'varchar', 'constraint'=> 255, 'null' => true],
'Unit1' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], 'RefType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => true, 'default' => 'NMRC'],
'Factor' => ['type' => 'int', 'null' => true], 'Unit1' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true],
'Unit2' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], 'Factor' => ['type' => 'DECIMAL', 'constraint'=> '10,4', 'null' => true],
'Decimal' => ['type' => 'int', 'null' => true], 'Unit2' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true],
'Decimal' => ['type' => 'int', 'null' => true, 'default' => 2],
'Method' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'CreateDate' => ['type' => 'Datetime', 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true] 'EndDate' => ['type' => 'Datetime', 'null' => true]
]); ]);
$this->forge->addKey('TestCalID', true); $this->forge->addKey('TestCalID', true);
$this->forge->addForeignKey('TestSiteID', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE');
$this->forge->createTable('testdefcal'); $this->forge->createTable('testdefcal');
// testdefgrp - Group definition for GROUP type
$this->forge->addField([ $this->forge->addField([
'TestGrpID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'TestGrpID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'SiteID' => ['type' => 'INT', 'null' => true], 'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
'TestSiteID' => ['type' => 'INT', 'null' => false], 'Member' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'Member' => ['type' => 'INT', 'null' => true],
'CreateDate' => ['type' => 'Datetime', 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true] 'EndDate' => ['type' => 'Datetime', 'null' => true]
]); ]);
$this->forge->addKey('TestGrpID', true); $this->forge->addKey('TestGrpID', true);
$this->forge->addForeignKey('TestSiteID', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE');
$this->forge->addForeignKey('Member', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE');
$this->forge->createTable('testdefgrp'); $this->forge->createTable('testdefgrp');
// testmap - Test mapping for all types
$this->forge->addField([ $this->forge->addField([
'TestMapID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'TestMapID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'TestSiteID' => ['type' => 'INT', 'null' => false], 'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
'HostType' => ['type' => 'int', 'null' => true], 'HostType' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true],
'HostID' => ['type' => 'int', 'null' => true], 'HostID' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'HostDataSource' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], 'HostDataSource' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'HostTestCode' => ['type' => 'varchar', 'constraint'=>10, 'null' => true], 'HostTestCode' => ['type' => 'varchar', 'constraint'=> 10, 'null' => true],
'HostTestName' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], 'HostTestName' => ['type' => 'varchar', 'constraint'=> 100, 'null' => true],
'ClientType' => ['type' => 'int', 'null' => true], 'ClientType' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true],
'ClientID' => ['type' => 'int', 'null' => true], 'ClientID' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'ClientDataSource' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], 'ClientDataSource' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'ConDefID' => ['type' => 'int', 'null' => true], 'ConDefID' => ['type' => 'INT', 'null' => true],
'ClientTestCode' => ['type' => 'varchar', 'constraint'=>10, 'null' => true], 'ClientTestCode' => ['type' => 'varchar', 'constraint'=> 10, 'null' => true],
'ClientTestName' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], 'ClientTestName' => ['type' => 'varchar', 'constraint'=> 100, 'null' => true],
'CreateDate' => ['type' => 'Datetime', 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true] 'EndDate' => ['type' => 'Datetime', 'null' => true]
]); ]);
$this->forge->addKey('TestMapID', true); $this->forge->addKey('TestMapID', true);
$this->forge->addForeignKey('TestSiteID', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE');
$this->forge->createTable('testmap'); $this->forge->createTable('testmap');
} }
@ -107,4 +117,4 @@ class CreateTestsTable extends Migration {
$this->forge->dropTable('testdefgrp'); $this->forge->dropTable('testdefgrp');
$this->forge->dropTable('testmap'); $this->forge->dropTable('testmap');
} }
} }

View File

@ -11,67 +11,43 @@ class CreateRefRangesTable extends Migration {
'RefNumID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'RefNumID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'SiteID' => ['type' => 'INT', 'null' => true], 'SiteID' => ['type' => 'INT', 'null' => true],
'TestSiteID' => ['type' => 'INT', 'null' => false], 'TestSiteID' => ['type' => 'INT', 'null' => false],
'SpcType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false], 'SpcType' => ['type' => 'INT', 'null' => true],
'Sex' => ['type' => 'INT', 'null' => true], 'Sex' => ['type' => 'INT', 'null' => true],
'Criteria' => ['type' => 'varchar', 'constraint'=>100, 'null' => true], 'Criteria' => ['type' => 'varchar', 'constraint'=>100, 'null' => true],
'AgeStart' => ['type' => 'INT', 'null' => true], 'AgeStart' => ['type' => 'INT', 'null' => true],
'AgeEnd' => ['type' => 'int', 'null' => true], 'AgeEnd' => ['type' => 'int', 'null' => true],
'CriticalLow' => ['type' => 'int', 'null' => true], 'NumRefType' => ['type' => 'INT', 'null' => true],
'Low' => ['type' => 'int', 'null' => true], 'RangeType' => ['type' => 'INT', 'null' => true],
'High' => ['type' => 'int', 'null' => true], 'LowSign' => ['type' => 'INT', 'null' => true],
'CriticalHigh' => ['type' => 'int', 'null' => true], 'Low' => ['type' => 'INT', 'null' => true],
'HighSign' => ['type' => 'INT', 'null' => true],
'High' => ['type' => 'INT', 'null' => true],
'Display' => ['type' => 'INT', 'null' => true],
'Flag' => ['type' => 'varchar', 'constraint'=>10, 'null' => true],
'Interpretation' => ['type' => 'varchar', 'constraint'=>255, 'null' => true],
'Notes' => ['type' => 'varchar', 'constraint'=>255, 'null' => true],
'CreateDate' => ['type' => 'Datetime', 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true],
'StartDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true], 'EndDate' => ['type' => 'Datetime', 'null' => true],
]); ]);
$this->forge->addKey('RefNumID', true); $this->forge->addKey('RefNumID', true);
$this->forge->createTable('refnum'); $this->forge->createTable('refnum');
$this->forge->addField([
'RefTHoldID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'SiteID' => ['type' => 'INT', 'null' => true],
'TestSiteID' => ['type' => 'INT', 'null' => false],
'SpcType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false],
'Sex' => ['type' => 'INT', 'null' => true],
'AgeStart' => ['type' => 'INT', 'null' => true],
'AgeEnd' => ['type' => 'int', 'null' => true],
'TholdSign' => ['type' => 'int', 'null' => true],
'TholdValue' => ['type' => 'int', 'null' => true],
'BelowTxt' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'AboveTxt' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'GrayZoneLow' => ['type' => 'int', 'null' => true],
'GrayZoneHigh' => ['type' => 'int', 'null' => true],
'GrayZoneTxt' => ['type' => 'varchar', 'constraint'=> 10, 'null' => true],
'CreateDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true],
]);
$this->forge->addKey('RefTHoldID', true);
$this->forge->createTable('refthold');
$this->forge->addField([
'RefVSetID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'SiteID' => ['type' => 'INT', 'null' => true],
'TestSiteID' => ['type' => 'INT', 'null' => false],
'SpcType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false],
'Sex' => ['type' => 'INT', 'null' => true],
'AgeStart' => ['type' => 'INT', 'null' => true],
'AgeEnd' => ['type' => 'int', 'null' => true],
'RefTxt' => ['type' => 'varchar', 'constraint'=>255, 'null' => true],
'CreateDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true],
]);
$this->forge->addKey('RefVSetID', true);
$this->forge->createTable('refvset');
$this->forge->addField([ $this->forge->addField([
'RefTxtID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'RefTxtID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'SiteID' => ['type' => 'INT', 'null' => true], 'SiteID' => ['type' => 'INT', 'null' => true],
'TestSiteID' => ['type' => 'INT', 'null' => false], 'TestSiteID' => ['type' => 'INT', 'null' => false],
'SpcType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false], 'SpcType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false],
'Sex' => ['type' => 'INT', 'null' => true], 'Sex' => ['type' => 'INT', 'null' => true],
'Criteria' => ['type' => 'varchar', 'constraint'=>100, 'null' => true],
'AgeStart' => ['type' => 'INT', 'null' => true], 'AgeStart' => ['type' => 'INT', 'null' => true],
'AgeEnd' => ['type' => 'int', 'null' => true], 'AgeEnd' => ['type' => 'int', 'null' => true],
'TxtRefType' => ['type' => 'INT', 'null' => true],
'RefTxt' => ['type' => 'varchar', 'constraint'=>255, 'null' => true], 'RefTxt' => ['type' => 'varchar', 'constraint'=>255, 'null' => true],
'Flag' => ['type' => 'varchar', 'constraint'=>10, 'null' => true],
'Notes' => ['type' => 'varchar', 'constraint'=>255, 'null' => true],
'CreateDate' => ['type' => 'Datetime', 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true],
'StartDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true], 'EndDate' => ['type' => 'Datetime', 'null' => true],
]); ]);
$this->forge->addKey('RefTxtID', true); $this->forge->addKey('RefTxtID', true);
@ -80,8 +56,6 @@ class CreateRefRangesTable extends Migration {
public function down() { public function down() {
$this->forge->dropTable('refnum'); $this->forge->dropTable('refnum');
$this->forge->dropTable('refthold');
$this->forge->dropTable('refvset');
$this->forge->dropTable('reftxt'); $this->forge->dropTable('reftxt');
} }
} }

View File

@ -0,0 +1,58 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateEdgeResTables extends Migration {
public function up() {
// Main edgeres table - staging for instrument results
$this->forge->addField([
'EdgeResID' => ['type' => 'INT', 'auto_increment' => true],
'SiteID' => ['type' => 'INT', 'null' => true],
'InstrumentID' => ['type' => 'varchar', 'constraint' => 100, 'null' => true],
'SampleID' => ['type' => 'varchar', 'constraint' => 30, 'null' => true],
'PatientID' => ['type' => 'varchar', 'constraint' => 50, 'null' => true],
'Payload' => ['type' => 'TEXT', 'null' => true],
'Status' => ['type' => 'varchar', 'constraint' => 20, 'default' => 'pending'],
'AutoProcess' => ['type' => 'TINYINT', 'default' => 0, 'null' => true],
'ProcessedAt' => ['type' => 'DATETIME', 'null' => true],
'ErrorMessage' => ['type' => 'TEXT', 'null' => true],
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
'EndDate' => ['type' => 'DATETIME', 'null' => true],
'ArchiveDate' => ['type' => 'DATETIME', 'null' => true],
'DelDate' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('EdgeResID');
$this->forge->createTable('edgeres');
// Edge status log - for instrument status tracking
$this->forge->addField([
'EdgeStatusID' => ['type' => 'INT', 'auto_increment' => true],
'InstrumentID' => ['type' => 'varchar', 'constraint' => 100, 'null' => true],
'Status' => ['type' => 'varchar', 'constraint' => 50, 'null' => true],
'LastActivity' => ['type' => 'DATETIME', 'null' => true],
'Timestamp' => ['type' => 'DATETIME', 'null' => true],
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('EdgeStatusID');
$this->forge->createTable('edgestatus');
// Edge order acknowledgment log
$this->forge->addField([
'EdgeAckID' => ['type' => 'INT', 'auto_increment' => true],
'OrderID' => ['type' => 'INT', 'null' => true],
'InstrumentID' => ['type' => 'varchar', 'constraint' => 100, 'null' => true],
'AckDate' => ['type' => 'DATETIME', 'null' => true],
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('EdgeAckID');
$this->forge->createTable('edgeack');
}
public function down() {
$this->forge->dropTable('edgeack', true);
$this->forge->dropTable('edgestatus', true);
$this->forge->dropTable('edgeres', true);
}
}

View File

@ -9,10 +9,12 @@ class DummySeeder extends Seeder {
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
// users // users
// Password: 'password' for all users (bcrypt hash)
$passwordHash = password_hash('password', PASSWORD_BCRYPT);
$data = [ $data = [
['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'], ['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => $passwordHash],
['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => '$2y$12$KwPedIPb7K/0IR/8/FcwdOMG4eBNNAXSjXnbkB26SwjH4Nf7PaYBe'], ['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => $passwordHash],
['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'], ['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => $passwordHash],
]; ];
$this->db->table('users')->insertBatch($data); $this->db->table('users')->insertBatch($data);

View File

@ -6,7 +6,7 @@ use CodeIgniter\Database\Seeder;
use App\Models\ValueSet\ValueSetModel; use App\Models\ValueSet\ValueSetModel;
class TestSeeder extends Seeder { class TestSeeder extends Seeder {
public function run() { public function run() {
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$vsModel = new ValueSetModel(); $vsModel = new ValueSetModel();
@ -25,104 +25,104 @@ class TestSeeder extends Seeder {
$data = ['SiteID' => '1', 'TestSiteCode' => 'HB', 'TestSiteName' => 'Hemoglobin', 'TestType' => $vs[27]['TEST'], 'Description' => '', 'SeqScr' => '2', 'SeqRpt' => '2', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'HB', 'TestSiteName' => 'Hemoglobin', 'TestType' => $vs[27]['TEST'], 'Description' => '', 'SeqScr' => '2', 'SeqRpt' => '2', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['HB'] = $this->db->insertID(); $tIDs['HB'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['HB'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['HB'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'HCT', 'TestSiteName' => 'Hematocrit', 'TestType' => $vs[27]['TEST'], 'Description' => '', 'SeqScr' => '3', 'SeqRpt' => '3', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'HCT', 'TestSiteName' => 'Hematocrit', 'TestType' => $vs[27]['TEST'], 'Description' => '', 'SeqScr' => '3', 'SeqRpt' => '3', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['HCT'] = $this->db->insertID(); $tIDs['HCT'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['HCT'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => '%', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['HCT'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => '%', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'RBC', 'TestSiteName' => 'Red Blood Cell', 'TestType' => $vs[27]['TEST'], 'Description' => 'Eritrosit', 'SeqScr' => '4', 'SeqRpt' => '4', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'RBC', 'TestSiteName' => 'Red Blood Cell', 'TestType' => $vs[27]['TEST'], 'Description' => 'Eritrosit', 'SeqScr' => '4', 'SeqRpt' => '4', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['RBC'] = $this->db->insertID(); $tIDs['RBC'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['RBC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^6/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['RBC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^6/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'WBC', 'TestSiteName' => 'White Blood Cell', 'TestType' => $vs[27]['TEST'], 'Description' => 'Leukosit', 'SeqScr' => '5', 'SeqRpt' => '5', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'WBC', 'TestSiteName' => 'White Blood Cell', 'TestType' => $vs[27]['TEST'], 'Description' => 'Leukosit', 'SeqScr' => '5', 'SeqRpt' => '5', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['WBC'] = $this->db->insertID(); $tIDs['WBC'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['WBC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['WBC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'PLT', 'TestSiteName' => 'Platelet', 'TestType' => $vs[27]['TEST'], 'Description' => 'Trombosit', 'SeqScr' => '6', 'SeqRpt' => '6', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'PLT', 'TestSiteName' => 'Platelet', 'TestType' => $vs[27]['TEST'], 'Description' => 'Trombosit', 'SeqScr' => '6', 'SeqRpt' => '6', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['PLT'] = $this->db->insertID(); $tIDs['PLT'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['PLT'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['PLT'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'MCV', 'TestSiteName' => 'MCV', 'TestType' => $vs[27]['TEST'], 'Description' => 'Mean Corpuscular Volume', 'SeqScr' => '7', 'SeqRpt' => '7', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'MCV', 'TestSiteName' => 'MCV', 'TestType' => $vs[27]['TEST'], 'Description' => 'Mean Corpuscular Volume', 'SeqScr' => '7', 'SeqRpt' => '7', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['MCV'] = $this->db->insertID(); $tIDs['MCV'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['MCV'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'fL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['MCV'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'fL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'MCH', 'TestSiteName' => 'MCH', 'TestType' => $vs[27]['TEST'], 'Description' => 'Mean Corpuscular Hemoglobin', 'SeqScr' => '8', 'SeqRpt' => '8', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'MCH', 'TestSiteName' => 'MCH', 'TestType' => $vs[27]['TEST'], 'Description' => 'Mean Corpuscular Hemoglobin', 'SeqScr' => '8', 'SeqRpt' => '8', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['MCH'] = $this->db->insertID(); $tIDs['MCH'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['MCH'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'pg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['MCH'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'pg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'MCHC', 'TestSiteName' => 'MCHC', 'TestType' => $vs[27]['TEST'], 'Description' => 'Mean Corpuscular Hemoglobin Concentration', 'SeqScr' => '9', 'SeqRpt' => '9', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'MCHC', 'TestSiteName' => 'MCHC', 'TestType' => $vs[27]['TEST'], 'Description' => 'Mean Corpuscular Hemoglobin Concentration', 'SeqScr' => '9', 'SeqRpt' => '9', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['MCHC'] = $this->db->insertID(); $tIDs['MCHC'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['MCHC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['MCHC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
// Chemistry Tests // Chemistry Tests
$data = ['SiteID' => '1', 'TestSiteCode' => 'GLU', 'TestSiteName' => 'Glucose', 'TestType' => $vs[27]['TEST'], 'Description' => 'Glukosa Sewaktu', 'SeqScr' => '11', 'SeqRpt' => '11', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'GLU', 'TestSiteName' => 'Glucose', 'TestType' => $vs[27]['TEST'], 'Description' => 'Glukosa Sewaktu', 'SeqScr' => '11', 'SeqRpt' => '11', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['GLU'] = $this->db->insertID(); $tIDs['GLU'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['GLU'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '0.0555', 'Unit2' => 'mmol/L', 'Decimal' => '0', 'Method' => 'Hexokinase', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['GLU'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '0.0555', 'Unit2' => 'mmol/L', 'Decimal' => '0', 'Method' => 'Hexokinase', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'CREA', 'TestSiteName' => 'Creatinine', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kreatinin', 'SeqScr' => '12', 'SeqRpt' => '12', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'CREA', 'TestSiteName' => 'Creatinine', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kreatinin', 'SeqScr' => '12', 'SeqRpt' => '12', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['CREA'] = $this->db->insertID(); $tIDs['CREA'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['CREA'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '88.4', 'Unit2' => 'umol/L', 'Decimal' => '2', 'Method' => 'Enzymatic', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['CREA'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '88.4', 'Unit2' => 'umol/L', 'Decimal' => '2', 'Method' => 'Enzymatic', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'UREA', 'TestSiteName' => 'Blood Urea Nitrogen', 'TestType' => $vs[27]['TEST'], 'Description' => 'BUN', 'SeqScr' => '13', 'SeqRpt' => '13', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'UREA', 'TestSiteName' => 'Blood Urea Nitrogen', 'TestType' => $vs[27]['TEST'], 'Description' => 'BUN', 'SeqScr' => '13', 'SeqRpt' => '13', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['UREA'] = $this->db->insertID(); $tIDs['UREA'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['UREA'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Urease-GLDH', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['UREA'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Urease-GLDH', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'SGOT', 'TestSiteName' => 'AST (SGOT)', 'TestType' => $vs[27]['TEST'], 'Description' => 'Aspartate Aminotransferase', 'SeqScr' => '14', 'SeqRpt' => '14', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'SGOT', 'TestSiteName' => 'AST (SGOT)', 'TestType' => $vs[27]['TEST'], 'Description' => 'Aspartate Aminotransferase', 'SeqScr' => '14', 'SeqRpt' => '14', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['SGOT'] = $this->db->insertID(); $tIDs['SGOT'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['SGOT'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['SGOT'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'SGPT', 'TestSiteName' => 'ALT (SGPT)', 'TestType' => $vs[27]['TEST'], 'Description' => 'Alanine Aminotransferase', 'SeqScr' => '15', 'SeqRpt' => '15', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'SGPT', 'TestSiteName' => 'ALT (SGPT)', 'TestType' => $vs[27]['TEST'], 'Description' => 'Alanine Aminotransferase', 'SeqScr' => '15', 'SeqRpt' => '15', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['SGPT'] = $this->db->insertID(); $tIDs['SGPT'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['SGPT'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['SGPT'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'CHOL', 'TestSiteName' => 'Total Cholesterol', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kolesterol Total', 'SeqScr' => '16', 'SeqRpt' => '16', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'CHOL', 'TestSiteName' => 'Total Cholesterol', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kolesterol Total', 'SeqScr' => '16', 'SeqRpt' => '16', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['CHOL'] = $this->db->insertID(); $tIDs['CHOL'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['CHOL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['THOLD'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Enzymatic', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['CHOL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Enzymatic', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'TG', 'TestSiteName' => 'Triglycerides', 'TestType' => $vs[27]['TEST'], 'Description' => 'Trigliserida', 'SeqScr' => '17', 'SeqRpt' => '17', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'TG', 'TestSiteName' => 'Triglycerides', 'TestType' => $vs[27]['TEST'], 'Description' => 'Trigliserida', 'SeqScr' => '17', 'SeqRpt' => '17', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['TG'] = $this->db->insertID(); $tIDs['TG'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['TG'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['THOLD'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'GPO-PAP', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['TG'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'GPO-PAP', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'HDL', 'TestSiteName' => 'HDL Cholesterol', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kolesterol HDL', 'SeqScr' => '18', 'SeqRpt' => '18', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'HDL', 'TestSiteName' => 'HDL Cholesterol', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kolesterol HDL', 'SeqScr' => '18', 'SeqRpt' => '18', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['HDL'] = $this->db->insertID(); $tIDs['HDL'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['HDL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['THOLD'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['HDL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'LDL', 'TestSiteName' => 'LDL Cholesterol', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kolesterol LDL', 'SeqScr' => '19', 'SeqRpt' => '19', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'LDL', 'TestSiteName' => 'LDL Cholesterol', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kolesterol LDL', 'SeqScr' => '19', 'SeqRpt' => '19', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['LDL'] = $this->db->insertID(); $tIDs['LDL'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['LDL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['THOLD'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['LDL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
// ======================================== // ========================================
@ -131,31 +131,31 @@ class TestSeeder extends Seeder {
$data = ['SiteID' => '1', 'TestSiteCode' => 'HEIGHT', 'TestSiteName' => 'Height', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Tinggi Badan', 'SeqScr' => '40', 'SeqRpt' => '40', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'HEIGHT', 'TestSiteName' => 'Height', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Tinggi Badan', 'SeqScr' => '40', 'SeqRpt' => '40', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['HEIGHT'] = $this->db->insertID(); $tIDs['HEIGHT'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['HEIGHT'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'SpcType' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'cm', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['HEIGHT'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'cm', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'WEIGHT', 'TestSiteName' => 'Weight', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Berat Badan', 'SeqScr' => '41', 'SeqRpt' => '41', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'WEIGHT', 'TestSiteName' => 'Weight', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Berat Badan', 'SeqScr' => '41', 'SeqRpt' => '41', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['WEIGHT'] = $this->db->insertID(); $tIDs['WEIGHT'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['WEIGHT'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'SpcType' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'kg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => '', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['WEIGHT'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'kg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'AGE', 'TestSiteName' => 'Age', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Usia', 'SeqScr' => '42', 'SeqRpt' => '42', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'AGE', 'TestSiteName' => 'Age', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Usia', 'SeqScr' => '42', 'SeqRpt' => '42', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['AGE'] = $this->db->insertID(); $tIDs['AGE'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['AGE'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'SpcType' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'years', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['AGE'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'years', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'SYSTL', 'TestSiteName' => 'Systolic BP', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Tekanan Darah Sistolik', 'SeqScr' => '43', 'SeqRpt' => '43', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'SYSTL', 'TestSiteName' => 'Systolic BP', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Tekanan Darah Sistolik', 'SeqScr' => '43', 'SeqRpt' => '43', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['SYSTL'] = $this->db->insertID(); $tIDs['SYSTL'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['SYSTL'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'SpcType' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['SYSTL'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'DIASTL', 'TestSiteName' => 'Diastolic BP', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Tekanan Darah Diastolik', 'SeqScr' => '44', 'SeqRpt' => '44', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'DIASTL', 'TestSiteName' => 'Diastolic BP', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Tekanan Darah Diastolik', 'SeqScr' => '44', 'SeqRpt' => '44', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['DIASTL'] = $this->db->insertID(); $tIDs['DIASTL'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['DIASTL'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'SpcType' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['DIASTL'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
// ======================================== // ========================================
@ -164,19 +164,19 @@ class TestSeeder extends Seeder {
$data = ['SiteID' => '1', 'TestSiteCode' => 'BMI', 'TestSiteName' => 'Body Mass Index', 'TestType' => $vs[27]['CALC'], 'Description' => 'Indeks Massa Tubuh - weight/(height^2)', 'SeqScr' => '45', 'SeqRpt' => '45', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'BMI', 'TestSiteName' => 'Body Mass Index', 'TestType' => $vs[27]['CALC'], 'Description' => 'Indeks Massa Tubuh - weight/(height^2)', 'SeqScr' => '45', 'SeqRpt' => '45', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['BMI'] = $this->db->insertID(); $tIDs['BMI'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['BMI'], 'DisciplineID' => '10', 'DepartmentID' => '', 'FormulaInput' => 'WEIGHT,HEIGHT', 'FormulaCode' => 'WEIGHT / ((HEIGHT/100) * (HEIGHT/100))', 'Unit1' => 'kg/m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['BMI'], 'DisciplineID' => '10', 'DepartmentID' => '', 'FormulaInput' => 'WEIGHT,HEIGHT', 'FormulaCode' => 'WEIGHT / ((HEIGHT/100) * (HEIGHT/100))', 'Unit1' => 'kg/m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'CreateDate' => "$now"];
$this->db->table('testdefcal')->insert($data); $this->db->table('testdefcal')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'EGFR', 'TestSiteName' => 'eGFR (CKD-EPI)', 'TestType' => $vs[27]['CALC'], 'Description' => 'Estimated Glomerular Filtration Rate', 'SeqScr' => '20', 'SeqRpt' => '20', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'EGFR', 'TestSiteName' => 'eGFR (CKD-EPI)', 'TestType' => $vs[27]['CALC'], 'Description' => 'Estimated Glomerular Filtration Rate', 'SeqScr' => '20', 'SeqRpt' => '20', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['EGFR'] = $this->db->insertID(); $tIDs['EGFR'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['EGFR'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CREA,AGE,GENDER', 'FormulaCode' => 'CKD_EPI(CREA,AGE,GENDER)', 'Unit1' => 'mL/min/1.73m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['EGFR'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CREA,AGE,GENDER', 'FormulaCode' => 'CKD_EPI(CREA,AGE,GENDER)', 'Unit1' => 'mL/min/1.73m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"];
$this->db->table('testdefcal')->insert($data); $this->db->table('testdefcal')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'LDLCALC', 'TestSiteName' => 'LDL Cholesterol (Calculated)', 'TestType' => $vs[27]['CALC'], 'Description' => 'Friedewald formula: TC - HDL - (TG/5)', 'SeqScr' => '21', 'SeqRpt' => '21', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'LDLCALC', 'TestSiteName' => 'LDL Cholesterol (Calculated)', 'TestType' => $vs[27]['CALC'], 'Description' => 'Friedewald formula: TC - HDL - (TG/5)', 'SeqScr' => '21', 'SeqRpt' => '21', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['LDLCALC'] = $this->db->insertID(); $tIDs['LDLCALC'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['LDLCALC'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CHOL,HDL,TG', 'FormulaCode' => 'CHOL - HDL - (TG/5)', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['LDLCALC'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CHOL,HDL,TG', 'FormulaCode' => 'CHOL - HDL - (TG/5)', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"];
$this->db->table('testdefcal')->insert($data); $this->db->table('testdefcal')->insert($data);
// ======================================== // ========================================
@ -186,68 +186,68 @@ class TestSeeder extends Seeder {
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['CBC'] = $this->db->insertID(); $tIDs['CBC'] = $this->db->insertID();
$this->db->table('testdefgrp')->insertBatch([ $this->db->table('testdefgrp')->insertBatch([
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HB'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HB'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HCT'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HCT'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['RBC'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['RBC'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['WBC'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['WBC'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['PLT'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['PLT'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCV'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCV'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCH'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCH'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCHC'], 'CreateDate' => "$now"] ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCHC'], 'CreateDate' => "$now"]
]); ]);
$data = ['SiteID' => '1', 'TestSiteCode' => 'LIPID', 'TestSiteName' => 'Lipid Profile', 'TestType' => $vs[27]['GROUP'], 'Description' => 'Profil Lipid', 'SeqScr' => '51', 'SeqRpt' => '51', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'LIPID', 'TestSiteName' => 'Lipid Profile', 'TestType' => $vs[27]['GROUP'], 'Description' => 'Profil Lipid', 'SeqScr' => '51', 'SeqRpt' => '51', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['LIPID'] = $this->db->insertID(); $tIDs['LIPID'] = $this->db->insertID();
$this->db->table('testdefgrp')->insertBatch([ $this->db->table('testdefgrp')->insertBatch([
['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['CHOL'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['CHOL'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['TG'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['TG'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['HDL'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['HDL'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDL'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDL'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDLCALC'], 'CreateDate' => "$now"] ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDLCALC'], 'CreateDate' => "$now"]
]); ]);
$data = ['SiteID' => '1', 'TestSiteCode' => 'LFT', 'TestSiteName' => 'Liver Function Test', 'TestType' => $vs[27]['GROUP'], 'Description' => 'Fungsi Hati', 'SeqScr' => '52', 'SeqRpt' => '52', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'LFT', 'TestSiteName' => 'Liver Function Test', 'TestType' => $vs[27]['GROUP'], 'Description' => 'Fungsi Hati', 'SeqScr' => '52', 'SeqRpt' => '52', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['LFT'] = $this->db->insertID(); $tIDs['LFT'] = $this->db->insertID();
$this->db->table('testdefgrp')->insertBatch([ $this->db->table('testdefgrp')->insertBatch([
['SiteID' => '1', 'TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGOT'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGOT'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGPT'], 'CreateDate' => "$now"] ['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGPT'], 'CreateDate' => "$now"]
]); ]);
$data = ['SiteID' => '1', 'TestSiteCode' => 'RFT', 'TestSiteName' => 'Renal Function Test', 'TestType' => $vs[27]['GROUP'], 'Description' => 'Fungsi Ginjal', 'SeqScr' => '53', 'SeqRpt' => '53', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'RFT', 'TestSiteName' => 'Renal Function Test', 'TestType' => $vs[27]['GROUP'], 'Description' => 'Fungsi Ginjal', 'SeqScr' => '53', 'SeqRpt' => '53', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['RFT'] = $this->db->insertID(); $tIDs['RFT'] = $this->db->insertID();
$this->db->table('testdefgrp')->insertBatch([ $this->db->table('testdefgrp')->insertBatch([
['SiteID' => '1', 'TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['UREA'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['UREA'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['CREA'], 'CreateDate' => "$now"], ['TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['CREA'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['EGFR'], 'CreateDate' => "$now"] ['TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['EGFR'], 'CreateDate' => "$now"]
]); ]);
// Urinalysis Tests (with valueset result type) // Urinalysis Tests (with valueset result type)
$data = ['SiteID' => '1', 'TestSiteCode' => 'UCOLOR', 'TestSiteName' => 'Urine Color', 'TestType' => $vs[27]['TEST'], 'Description' => 'Warna Urine', 'SeqScr' => '31', 'SeqRpt' => '31', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'UCOLOR', 'TestSiteName' => 'Urine Color', 'TestType' => $vs[27]['TEST'], 'Description' => 'Warna Urine', 'SeqScr' => '31', 'SeqRpt' => '31', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['UCOLOR'] = $this->db->insertID(); $tIDs['UCOLOR'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['UCOLOR'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['VSET'], 'VSet' => '1001', 'SpcType' => $vs[15]['UR'], 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Visual', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['UCOLOR'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['TEXT'], 'VSet' => '1001', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Visual', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'UGLUC', 'TestSiteName' => 'Urine Glucose', 'TestType' => $vs[27]['TEST'], 'Description' => 'Glukosa Urine', 'SeqScr' => '32', 'SeqRpt' => '32', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'UGLUC', 'TestSiteName' => 'Urine Glucose', 'TestType' => $vs[27]['TEST'], 'Description' => 'Glukosa Urine', 'SeqScr' => '32', 'SeqRpt' => '32', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['UGLUC'] = $this->db->insertID(); $tIDs['UGLUC'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['UGLUC'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['VSET'], 'VSet' => '1002', 'SpcType' => $vs[15]['UR'], 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['UGLUC'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['TEXT'], 'VSet' => '1002', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'UPROT', 'TestSiteName' => 'Urine Protein', 'TestType' => $vs[27]['TEST'], 'Description' => 'Protein Urine', 'SeqScr' => '33', 'SeqRpt' => '33', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'UPROT', 'TestSiteName' => 'Urine Protein', 'TestType' => $vs[27]['TEST'], 'Description' => 'Protein Urine', 'SeqScr' => '33', 'SeqRpt' => '33', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['UPROT'] = $this->db->insertID(); $tIDs['UPROT'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['UPROT'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['VSET'], 'VSet' => '1003', 'SpcType' => $vs[15]['UR'], 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['UPROT'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['TEXT'], 'VSet' => '1003', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'PH', 'TestSiteName' => 'Urine pH', 'TestType' => $vs[27]['TEST'], 'Description' => 'pH Urine', 'SeqScr' => '34', 'SeqRpt' => '34', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $data = ['SiteID' => '1', 'TestSiteCode' => 'PH', 'TestSiteName' => 'Urine pH', 'TestType' => $vs[27]['TEST'], 'Description' => 'pH Urine', 'SeqScr' => '34', 'SeqRpt' => '34', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data); $this->db->table('testdefsite')->insert($data);
$tIDs['PH'] = $this->db->insertID(); $tIDs['PH'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteID' => $tIDs['PH'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['UR'], 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; $data = ['TestSiteID' => $tIDs['PH'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Dipstick', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data); $this->db->table('testdeftech')->insert($data);
} }
} }

View File

@ -99,7 +99,7 @@ class ValueSetSeeder extends Seeder {
['VSetID' => 15,'VOrder' => 2, 'VValue' =>'BLDA', 'VDesc' => "Blood arterial", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 2, 'VValue' =>'BLDA', 'VDesc' => "Blood arterial", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 15,'VOrder' => 3, 'VValue' =>'BLDCO', 'VDesc' => "Cord blood", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 3, 'VValue' =>'BLDCO', 'VDesc' => "Cord blood", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 15,'VOrder' => 4, 'VValue' =>'FBLOOD', 'VDesc' => "Blood, Fetal", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 4, 'VValue' =>'FBLOOD', 'VDesc' => "Blood, Fetal", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 15,'VOrder' => 5, 'VValue' =>'FBLOOD', 'VDesc' => "Blood, Fetal", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 5, 'VValue' =>'CSF', 'VDesc' => "Cerebral spinal fluid", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 15,'VOrder' => 6, 'VValue' =>'WB', 'VDesc' => "Blood, Whole", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 6, 'VValue' =>'WB', 'VDesc' => "Blood, Whole", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 15,'VOrder' => 7, 'VValue' =>'BBL', 'VDesc' => "Blood bag", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 7, 'VValue' =>'BBL', 'VDesc' => "Blood bag", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 15,'VOrder' => 8, 'VValue' =>'SER', 'VDesc' => "Serum", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 8, 'VValue' =>'SER', 'VDesc' => "Serum", 'VCategory' => 1, 'CreateDate' => "$now"],
@ -306,33 +306,46 @@ class ValueSetSeeder extends Seeder {
['VSetID' => 43,'VOrder' => 2, 'VValue' =>'RANGE', 'VDesc' => "Range", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 43,'VOrder' => 2, 'VValue' =>'RANGE', 'VDesc' => "Range", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 43,'VOrder' => 3, 'VValue' =>'TEXT', 'VDesc' => "Text", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 43,'VOrder' => 3, 'VValue' =>'TEXT', 'VDesc' => "Text", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 43,'VOrder' => 4, 'VValue' =>'VSET', 'VDesc' => "Value set", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 43,'VOrder' => 4, 'VValue' =>'VSET', 'VDesc' => "Value set", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 44,'VOrder' => 1, 'VValue' =>'RANGE', 'VDesc' => "Range", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 44,'VOrder' => 1, 'VValue' =>'NMRC', 'VDesc' => "Numeric", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 44,'VOrder' => 2, 'VValue' =>'THOLD', 'VDesc' => "Threshold", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 44,'VOrder' => 2, 'VValue' =>'TEXT', 'VDesc' => "Text", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 44,'VOrder' => 3, 'VValue' =>'VSET', 'VDesc' => "Value Set", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 45,'VOrder' => 1, 'VValue' =>'REF', 'VDesc' => "Reference Range", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 44,'VOrder' => 4, 'VValue' =>'TEXT', 'VDesc' => "Text.", 'VCategory' => 1, 'CreateDate' => "$now"] ['VSetID' => 45,'VOrder' => 2, 'VValue' =>'CRTC', 'VDesc' => "Critical Range", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 45,'VOrder' => 3, 'VValue' =>'VAL', 'VDesc' => "Validation Range", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 45,'VOrder' => 4, 'VValue' =>'RERUN', 'VDesc' => "Rerun Range", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 46,'VOrder' => 1, 'VValue' =>'RANGE', 'VDesc' => "Range", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 46,'VOrder' => 2, 'VValue' =>'THOLD', 'VDesc' => "Threshold", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 47,'VOrder' => 1, 'VValue' =>'VSET', 'VDesc' => "Value Set", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 47,'VOrder' => 2, 'VValue' =>'TEXT', 'VDesc' => "Text.", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 1001,'VOrder' => 1, 'VValue' =>'NEG', 'VDesc' => "Negative", 'VCategory' => 2, 'CreateDate' => "$now"],
['VSetID' => 1001,'VOrder' => 2, 'VValue' =>'POS', 'VDesc' => "Positive", 'VCategory' => 2, 'CreateDate' => "$now"],
['VSetID' => 1001,'VOrder' => 3, 'VValue' =>'GZ', 'VDesc' => "Grayzone", 'VCategory' => 2, 'CreateDate' => "$now"],
['VSetID' => 1002,'VOrder' => 1, 'VValue' =>'KNG', 'VDesc' => "Kuning", 'VCategory' => 2, 'CreateDate' => "$now"],
['VSetID' => 1002,'VOrder' => 2, 'VValue' =>'JNG', 'VDesc' => "Jingga", 'VCategory' => 2, 'CreateDate' => "$now"],
['VSetID' => 1002,'VOrder' => 3, 'VValue' =>'MRH', 'VDesc' => "Merah", 'VCategory' => 2, 'CreateDate' => "$now"],
['VSetID' => 1002,'VOrder' => 4, 'VValue' =>'CKLT', 'VDesc' => "Coklat tua", 'VCategory' => 2, 'CreateDate' => "$now"]
]; ];
$this->db->table('valueset')->insertBatch($data); $this->db->table('valueset')->insertBatch($data);
$data = [ $data = [
['VSName' => 'WSType','VSDesc' =>'workstation.Type', 'VSetID' => '1', 'CreateDate' => "$now"], ['VSName' => 'WSType','VSDesc' =>'workstation.Type', 'VSetID' => '1', 'CreateDate' => "$now"],
['VSName' => 'Enable/Disable','VSDesc' =>'workstation.Enable equipmentlist.Enable testdef.CountStat testdefsite.CountStat testdefsite.VisibleScr testdefsite.VisibleRpt', 'VSetID' => '2', 'CreateDate' => "$now"], ['VSName' => 'Enable/Disable','VSDesc' =>'workstation.Enable, equipmentlist.Enable, testdef.CountStat, testdefsite.CountStat, testdefsite.VisibleScr, testdefsite.VisibleRpt', 'VSetID' => '2', 'CreateDate' => "$now"],
['VSName' => 'Gender','VSDesc' =>'patient.Gender', 'VSetID' => '3', 'CreateDate' => "$now"], ['VSName' => 'Gender','VSDesc' =>'patient.Gender, refnum.Sex', 'VSetID' => '3', 'CreateDate' => "$now"],
['VSName' => 'Marital Status','VSDesc' =>'patient.MaritalStatus', 'VSetID' => '4', 'CreateDate' => "$now"], ['VSName' => 'Marital Status','VSDesc' =>'patient.MaritalStatus', 'VSetID' => '4', 'CreateDate' => "$now"],
['VSName' => 'Deceased','VSDesc' =>'patient.Deceased', 'VSetID' => '5', 'CreateDate' => "$now"], ['VSName' => 'Death Indicator','VSDesc' =>'patient.DeathIndicator', 'VSetID' => '5', 'CreateDate' => "$now"],
['VSName' => 'Identifier Type','VSDesc' =>'patidt.IdentifierType', 'VSetID' => '6', 'CreateDate' => "$now"], ['VSName' => 'Identifier Type','VSDesc' =>'patidt.IdentifierType', 'VSetID' => '6', 'CreateDate' => "$now"],
['VSName' => 'Operation','VSDesc' =>'patreglog.Operation patvisitlog.Operation orderlog.Operation', 'VSetID' => '7', 'CreateDate' => "$now"], ['VSName' => 'Operation','VSDesc' =>'patreglog.Operation, patvisitlog.Operation, orderlog.Operation', 'VSetID' => '7', 'CreateDate' => "$now"],
['VSName' => 'DID Type','VSDesc' =>'patreglog.DIDType patvisitlog.DIDType', 'VSetID' => '8', 'CreateDate' => "$now"], ['VSName' => 'DID Type','VSDesc' =>'patreglog.DIDType, patvisitlog.DIDType', 'VSetID' => '8', 'CreateDate' => "$now"],
['VSName' => 'Requested Entity','VSDesc' =>'order.ReqEntity', 'VSetID' => '9', 'CreateDate' => "$now"], ['VSName' => 'Requested Entity','VSDesc' =>'order.ReqEntity', 'VSetID' => '9', 'CreateDate' => "$now"],
['VSName' => 'Order Priority','VSDesc' =>'order.Priority', 'VSetID' => '10', 'CreateDate' => "$now"], ['VSName' => 'Order Priority','VSDesc' =>'order.Priority', 'VSetID' => '10', 'CreateDate' => "$now"],
['VSName' => 'Order Status','VSDesc' =>'orderststatus.OrderStatus', 'VSetID' => '11', 'CreateDate' => "$now"], ['VSName' => 'Order Status','VSDesc' =>'orderststatus.OrderStatus', 'VSetID' => '11', 'CreateDate' => "$now"],
['VSName' => 'Location Type','VSDesc' =>'location.LocationType', 'VSetID' => '12', 'CreateDate' => "$now"], ['VSName' => 'Location TypeTable 34 location','VSDesc' =>'location.LocationType', 'VSetID' => '12', 'CreateDate' => "$now"],
['VSName' => 'Additive','VSDesc' =>'containertype.Additive specimenprep.Additive', 'VSetID' => '13', 'CreateDate' => "$now"], ['VSName' => 'Additive','VSDesc' =>'containertype.Additive, specimenprep.Additive', 'VSetID' => '13', 'CreateDate' => "$now"],
['VSName' => 'Container Class','VSDesc' =>'containertype.ConClass', 'VSetID' => '14', 'CreateDate' => "$now"], ['VSName' => 'Container Class','VSDesc' =>'containertype.ConClass', 'VSetID' => '14', 'CreateDate' => "$now"],
['VSName' => 'Specimen Type','VSDesc' =>'testdeftech.SpcType refnum. SpcType refthold.SpcType', 'VSetID' => '15', 'CreateDate' => "$now"], ['VSName' => 'Specimen Type','VSDesc' =>'testdeftech.SpcType, refnum.SpcType, reftxt.SpcType', 'VSetID' => '15', 'CreateDate' => "$now"],
['VSName' => 'Unit','VSDesc' =>'spcdef.Unit specimens.Unit specimenstatus.Unit specimenprep.AddUnit', 'VSetID' => '16', 'CreateDate' => "$now"], ['VSName' => 'Unit','VSDesc' =>'spcdef.Unit, specimens.Unit, specimenstatus.Unit, specimenprep.AddUnit', 'VSetID' => '16', 'CreateDate' => "$now"],
['VSName' => 'GenerateBy','VSDesc' =>'specimens. GenerateBy', 'VSetID' => '17', 'CreateDate' => "$now"], ['VSName' => 'GenerateBy','VSDesc' =>'specimens. GenerateBy', 'VSetID' => '17', 'CreateDate' => "$now"],
['VSName' => 'Specimen Activity','VSDesc' =>'specimenstatus.SpcAct', 'VSetID' => '18', 'CreateDate' => "$now"], ['VSName' => 'Specimen Activity','VSDesc' =>'specimenstatus.SpcAct', 'VSetID' => '18', 'CreateDate' => "$now"],
['VSName' => 'Activity Result','VSDesc' =>'specimenstatus.ActRes patrestatus.ActRes', 'VSetID' => '19', 'CreateDate' => "$now"], ['VSName' => 'Activity Result','VSDesc' =>'specimenstatus.ActRes, patrestatus.ActRes', 'VSetID' => '19', 'CreateDate' => "$now"],
['VSName' => 'Specimen Status','VSDesc' =>'specimenstatus.SpcStatus', 'VSetID' => '20', 'CreateDate' => "$now"], ['VSName' => 'Specimen Status','VSDesc' =>'specimenstatus.SpcStatus', 'VSetID' => '20', 'CreateDate' => "$now"],
['VSName' => 'Specimen Condition','VSDesc' =>'specimenstatus.SpcCon', 'VSetID' => '21', 'CreateDate' => "$now"], ['VSName' => 'Specimen Condition','VSDesc' =>'specimenstatus.SpcCon', 'VSetID' => '21', 'CreateDate' => "$now"],
['VSName' => 'Specimen Role','VSDesc' =>'specimencollection.SpcRole', 'VSetID' => '22', 'CreateDate' => "$now"], ['VSName' => 'Specimen Role','VSDesc' =>'specimencollection.SpcRole', 'VSetID' => '22', 'CreateDate' => "$now"],
@ -340,8 +353,8 @@ class ValueSetSeeder extends Seeder {
['VSName' => 'Body Site','VSDesc' =>'specimencollection.BodySite', 'VSetID' => '24', 'CreateDate' => "$now"], ['VSName' => 'Body Site','VSDesc' =>'specimencollection.BodySite', 'VSetID' => '24', 'CreateDate' => "$now"],
['VSName' => 'Container Size','VSDesc' =>'specimencollection.CntSize', 'VSetID' => '25', 'CreateDate' => "$now"], ['VSName' => 'Container Size','VSDesc' =>'specimencollection.CntSize', 'VSetID' => '25', 'CreateDate' => "$now"],
['VSName' => 'Fasting Status','VSDesc' =>'specimencollection.Fasting', 'VSetID' => '26', 'CreateDate' => "$now"], ['VSName' => 'Fasting Status','VSDesc' =>'specimencollection.Fasting', 'VSetID' => '26', 'CreateDate' => "$now"],
['VSName' => 'Test Type','VSDesc' =>'testdefsite.Type', 'VSetID' => '27', 'CreateDate' => "$now"], ['VSName' => 'Test Type','VSDesc' =>'testdefsite.TestType', 'VSetID' => '27', 'CreateDate' => "$now"],
['VSName' => 'Result Unit','VSDesc' =>'testdefsite.Unit1 testdefsite.Unit2', 'VSetID' => '28', 'CreateDate' => "$now"], ['VSName' => 'Result Unit','VSDesc' =>'testdefsite.Unit1, testdefsite.Unit2', 'VSetID' => '28', 'CreateDate' => "$now"],
['VSName' => 'Formula Languange','VSDesc' =>'testdefcal.FormulaLang', 'VSetID' => '29', 'CreateDate' => "$now"], ['VSName' => 'Formula Languange','VSDesc' =>'testdefcal.FormulaLang', 'VSetID' => '29', 'CreateDate' => "$now"],
['VSName' => 'Race','VSDesc' =>'patient.Race', 'VSetID' => '30', 'CreateDate' => "$now"], ['VSName' => 'Race','VSDesc' =>'patient.Race', 'VSetID' => '30', 'CreateDate' => "$now"],
['VSName' => 'Religion','VSDesc' =>'patient.Religion', 'VSetID' => '31', 'CreateDate' => "$now"], ['VSName' => 'Religion','VSDesc' =>'patient.Religion', 'VSetID' => '31', 'CreateDate' => "$now"],
@ -352,12 +365,17 @@ class ValueSetSeeder extends Seeder {
['VSName' => 'ADT Event','VSDesc' =>'patvisitadt.Code', 'VSetID' => '36', 'CreateDate' => "$now"], ['VSName' => 'ADT Event','VSDesc' =>'patvisitadt.Code', 'VSetID' => '36', 'CreateDate' => "$now"],
['VSName' => 'Site Type','VSDesc' =>'Site.SiteType', 'VSetID' => '37', 'CreateDate' => "$now"], ['VSName' => 'Site Type','VSDesc' =>'Site.SiteType', 'VSetID' => '37', 'CreateDate' => "$now"],
['VSName' => 'Site Class','VSDesc' =>'Site.SiteClass', 'VSetID' => '38', 'CreateDate' => "$now"], ['VSName' => 'Site Class','VSDesc' =>'Site.SiteClass', 'VSetID' => '38', 'CreateDate' => "$now"],
['VSName' => 'Entity Type','VSDesc' =>'testmap.HostType testmap.ClientType', 'VSetID' => '39', 'CreateDate' => "$now"], ['VSName' => 'Entity Type','VSDesc' =>'testmap.HostType, testmap.ClientType', 'VSetID' => '39', 'CreateDate' => "$now"],
['VSName' => 'Area Class','VSDesc' =>'AreaGeo', 'VSetID' => '40', 'CreateDate' => "$now"], ['VSName' => 'Area Class','VSDesc' =>'AreaGeo', 'VSetID' => '40', 'CreateDate' => "$now"],
['VSName' => 'Math Sign','VSDesc' =>'refthold.TholdSign', 'VSetID' => '41', 'CreateDate' => "$now"], ['VSName' => 'Math Sign','VSDesc' =>'refnum.LowSign, refnum.HighSign', 'VSetID' => '41', 'CreateDate' => "$now"],
['VSName' => 'VCategory','VSDesc' =>'valueset. VCategory', 'VSetID' => '42', 'CreateDate' => "$now"], ['VSName' => 'VCategory','VSDesc' =>'valueset. VCategory', 'VSetID' => '42', 'CreateDate' => "$now"],
['VSName' => 'Result Type','VSDesc' =>'testdeftech.ResultType', 'VSetID' => '43', 'CreateDate' => "$now"], ['VSName' => 'Result Type','VSDesc' =>'testdeftech.ResultType', 'VSetID' => '43', 'CreateDate' => "$now"],
['VSName' => 'Reference Type','VSDesc' =>'testdeftech.RefType', 'VSetID' => '44', 'CreateDate' => "$now"], ['VSName' => 'Reference Type','VSDesc' =>'testdeftech.RefType', 'VSetID' => '44', 'CreateDate' => "$now"],
['VSName' => 'Range Type','VSDesc' =>'refnum.RangeType', 'VSetID' => '45', 'CreateDate' => "$now"],
['VSName' => 'Numeric Reference Type','VSDesc' =>'refnum.NumRefType', 'VSetID' => '46', 'CreateDate' => "$now"],
['VSName' => 'Text Reference Type','VSDesc' =>'reftxt. TxtRefType', 'VSetID' => '47', 'CreateDate' => "$now"],
['VSName' => 'HIV','VSDesc' =>'Value set untuk hasil HIV', 'VSetID' => '1001', 'CreateDate' => "$now"],
]; ];
$this->db->table('valuesetdef')->insertBatch($data); $this->db->table('valuesetdef')->insertBatch($data);
} }

View File

@ -16,14 +16,22 @@ class AuthFilter implements FilterInterface
$key = getenv('JWT_SECRET'); $key = getenv('JWT_SECRET');
$token = $request->getCookie('token'); // ambil dari cookie $token = $request->getCookie('token'); // ambil dari cookie
// Check if this is an API request or a page request
$isApiRequest = strpos($request->getUri()->getPath(), '/api/') !== false
|| $request->isAJAX();
// Kalau tidak ada token // Kalau tidak ada token
if (!$token) { if (!$token) {
return Services::response() if ($isApiRequest) {
->setStatusCode(401) return Services::response()
->setJSON([ ->setStatusCode(401)
'status' => 'failed', ->setJSON([
'message' => 'Unauthorized: Token not found' 'status' => 'failed',
]); 'message' => 'Unauthorized: Token not found'
]);
}
// Redirect to login for page requests
return redirect()->to('/v2/login');
} }
try { try {
@ -36,12 +44,16 @@ class AuthFilter implements FilterInterface
// $request->userData = $decoded; // $request->userData = $decoded;
} catch (\Exception $e) { } catch (\Exception $e) {
return Services::response() if ($isApiRequest) {
->setStatusCode(401) return Services::response()
->setJSON([ ->setStatusCode(401)
'status' => 'failed', ->setJSON([
'message' => 'Unauthorized: ' . $e->getMessage() 'status' => 'failed',
]); 'message' => 'Unauthorized: ' . $e->getMessage()
]);
}
// Redirect to login for page requests
return redirect()->to('/v2/login');
} }
} }

View File

@ -10,6 +10,7 @@ class Cors implements FilterInterface
{ {
protected $allowedOrigins = [ protected $allowedOrigins = [
'http://localhost:5173', 'http://localhost:5173',
'http://localhost',
'https://clqms01.services-summit.my.id', 'https://clqms01.services-summit.my.id',
]; ];
@ -19,6 +20,11 @@ class Cors implements FilterInterface
$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$response = service('response'); $response = service('response');
// Allow same-origin requests (when no Origin header is present)
if (empty($origin)) {
return null;
}
if (in_array($origin, $this->allowedOrigins)) { if (in_array($origin, $this->allowedOrigins)) {
$response->setHeader('Access-Control-Allow-Origin', $origin); $response->setHeader('Access-Control-Allow-Origin', $origin);
$response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); $response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class EdgeResModel extends Model {
protected $table = 'edgeres';
protected $primaryKey = 'EdgeResID';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'SiteID',
'InstrumentID',
'SampleID',
'PatientID',
'Payload',
'Status',
'AutoProcess',
'ProcessedAt',
'ErrorMessage',
'CreateDate',
'EndDate',
'ArchiveDate',
'DelDate'
];
protected $useTimestamps = false;
protected $createdField = 'CreateDate';
protected $updatedField = 'EndDate';
/**
* Get pending results for processing
*/
public function getPending($limit = 100) {
return $this->where('Status', 'pending')
->whereNull('DelDate')
->orderBy('CreateDate', 'ASC')
->findAll($limit);
}
/**
* Mark as processed
*/
public function markProcessed($id) {
return $this->update($id, [
'Status' => 'processed',
'ProcessedAt' => date('Y-m-d H:i:s')
]);
}
/**
* Mark as error
*/
public function markError($id, $errorMessage) {
return $this->update($id, [
'Status' => 'error',
'ErrorMessage' => $errorMessage,
'ProcessedAt' => date('Y-m-d H:i:s')
]);
}
}

View File

@ -5,7 +5,8 @@ use App\Models\BaseModel;
class LocationAddressModel extends BaseModel { class LocationAddressModel extends BaseModel {
protected $table = 'locationaddress'; protected $table = 'locationaddress';
protected $primaryKey = 'LocationID'; protected $primaryKey = 'LocationID';
protected $allowedFields = ['LocationID', 'Street1', 'Street2', 'City', 'Province', 'PostCode', 'GeoLocationSystem', 'GeoLocationData', 'CreateDate', 'EndDate']; protected $allowedFields = ['LocationID', 'Street1', 'Street2', 'City', 'Province', 'PostCode',
'GeoLocationSystem', 'GeoLocationData', 'Phone', 'Email', 'CreateDate', 'EndDate'];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'CreateDate'; protected $createdField = 'CreateDate';

View File

@ -24,15 +24,15 @@ class LocationModel extends BaseModel {
public function getLocation($LocationID) { public function getLocation($LocationID) {
//'Street1', 'Street2', 'City', 'Province', 'PostCode', 'GeoLocationSystem', 'GeoLocationData', //'Street1', 'Street2', 'City', 'Province', 'PostCode', 'GeoLocationSystem', 'GeoLocationData',
$rows = $this->select("location.*, la.Street1, la.Street2, la.PostCode, la.GeoLocationSystem, la.GeoLocationData, $row = $this->select("location.*, la.Street1, la.Street2, la.PostCode, la.GeoLocationSystem, la.GeoLocationData,
v.VDesc as LocTypeText, prop.AreaGeoID as ProvinceID, prop.AreaName as Province, city.AreaGeoID as CityID, city.AreaName as City, site.SiteID, site.SiteName") v.VDesc as LocTypeText, prop.AreaGeoID as ProvinceID, prop.AreaName as Province, city.AreaGeoID as CityID, city.AreaName as City, site.SiteID, site.SiteName")
->join("locationaddress la", "location.LocationID=la.LocationID", "left") ->join("locationaddress la", "location.LocationID=la.LocationID", "left")
->join("valueset v", "v.VID=location.loctype", "left") ->join("valueset v", "v.VID=location.loctype", "left")
->join("areageo prop", "la.Province=prop.AreaGeoID", "left") ->join("areageo prop", "la.Province=prop.AreaGeoID", "left")
->join("areageo city", "la.City=city.AreaGeoID", "left") ->join("areageo city", "la.City=city.AreaGeoID", "left")
->join("site", "site.SiteID=location.SiteID", "left") ->join("site", "site.SiteID=location.SiteID", "left")
->where('location.LocationID', (int) $LocationID)->findAll(); ->where('location.LocationID', (int) $LocationID)->first();
return $rows; return $row;
} }
public function saveLocation(array $data): array { public function saveLocation(array $data): array {
@ -50,14 +50,15 @@ class LocationModel extends BaseModel {
$modelAddress->insert($data); $modelAddress->insert($data);
} }
if ($db->transStatus() === false) { if ($db->transStatus() === false) {
$error = $db->error();
$db->transRollback(); $db->transRollback();
throw new \Exception('Transaction failed'); throw new \Exception($error['message'] ?? 'Transaction failed');
} }
$db->transCommit(); $db->transCommit();
return [ 'status' => 'success', 'LocationID' => $LocationID ]; return [ 'status' => 'success', 'LocationID' => $LocationID ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
$db->transRollback(); $db->transRollback();
return [ 'status' => 'error', 'message' => $e->getMessage() ]; throw $e;
} }
} }
@ -70,14 +71,15 @@ class LocationModel extends BaseModel {
$this->delete($LocationID); $this->delete($LocationID);
$modelAddress->delete($LocationID); $modelAddress->delete($LocationID);
if ($db->transStatus() === false) { if ($db->transStatus() === false) {
$error = $db->error();
$db->transRollback(); $db->transRollback();
throw new \Exception('Transaction failed'); throw new \Exception($error['message'] ?? 'Transaction failed');
} }
$db->transCommit(); $db->transCommit();
return [ 'status' => 'success', 'LocationID' => $LocationID ]; return [ 'status' => 'success', 'LocationID' => $LocationID ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
$db->transRollback(); $db->transRollback();
return [ 'status' => 'error', 'message' => $e->getMessage() ]; throw $e;
} }
} }
} }

View File

@ -31,7 +31,7 @@ class AccountModel extends BaseModel {
} }
public function getAccount($AccountID) { public function getAccount($AccountID) {
$rows = $this->select('account.*, pa.AccountName as ParentName, areageo.AreaName, areageo.AreaGeoID, $row = $this->select('account.*, pa.AccountName as ParentName, areageo.AreaName, areageo.AreaGeoID,
city.AreaName as CityName, city.AreaGeoID as City, prov.AreaName as ProvName, prov.AreaGeoID as Prov, city.AreaName as CityName, city.AreaGeoID as City, prov.AreaName as ProvName, prov.AreaGeoID as Prov,
country.VValue as CountryName, country.VID as country') country.VValue as CountryName, country.VID as country')
->join('account pa', 'pa.AccountID=account.Parent', 'left') ->join('account pa', 'pa.AccountID=account.Parent', 'left')
@ -40,7 +40,7 @@ class AccountModel extends BaseModel {
->join('areageo prov', 'prov.AreaGeoID=account.Province', 'left') ->join('areageo prov', 'prov.AreaGeoID=account.Province', 'left')
->join('valueset country', 'country.VID=account.Country', 'left') ->join('valueset country', 'country.VID=account.Country', 'left')
->where('account.AccountID', $AccountID) ->where('account.AccountID', $AccountID)
->findAll(); ->first();
return $rows; return $row;
} }
} }

View File

@ -30,11 +30,11 @@ class DepartmentModel extends BaseModel {
} }
public function getDepartment($DepartmentID) { public function getDepartment($DepartmentID) {
$rows = $this->select('department.*, discipline.DisciplineCode, discipline.DisciplineName, site.SiteCode, site.SiteName') $row = $this->select('department.*, discipline.DisciplineCode, discipline.DisciplineName, site.SiteCode, site.SiteName')
->join('discipline', 'discipline.DisciplineID=department.DisciplineID', 'left') ->join('discipline', 'discipline.DisciplineID=department.DisciplineID', 'left')
->join('site', 'site.SiteID=department.SiteID', 'left') ->join('site', 'site.SiteID=department.SiteID', 'left')
->where('department.DepartmentID', $DepartmentID) ->where('department.DepartmentID', $DepartmentID)
->findAll(); ->first();
return $rows; return $row;
} }
} }

View File

@ -32,13 +32,13 @@ class SiteModel extends BaseModel {
} }
public function getSite($SiteID) { public function getSite($SiteID) {
$rows = $this->select('site.*, account.AccountName, s1.SiteName as ParentName, sitetype.VValue as SiteType, siteclass.VValue as SiteClass') $row = $this->select('site.*, account.AccountName, s1.SiteName as ParentName, sitetype.VValue as SiteType, siteclass.VValue as SiteClass')
->join('account', 'account.AccountID=site.AccountID', 'left') ->join('account', 'account.AccountID=site.AccountID', 'left')
->join('site s1', 's1.SiteID=site.Parent', 'left') ->join('site s1', 's1.SiteID=site.Parent', 'left')
->join('valueset sitetype', 'site.SiteTypeID=sitetype.VID', 'left') ->join('valueset sitetype', 'site.SiteTypeID=sitetype.VID', 'left')
->join('valueset siteclass', 'site.SiteClassID=siteclass.VID', 'left') ->join('valueset siteclass', 'site.SiteClassID=siteclass.VID', 'left')
->where('site.SiteID', $SiteID) ->where('site.SiteID', $SiteID)
->findAll(); ->first();
return $rows; return $row;
} }
} }

View File

@ -30,13 +30,13 @@ class WorkstationModel extends BaseModel {
} }
public function getWorkstation($WorkstationID) { public function getWorkstation($WorkstationID) {
$rows = $this->select("workstation.*, department.DepartmentName, wst1.WorkstationName as LinkToName,enable.VDesc as EnableName, wstype.VDesc as TypeName") $row = $this->select("workstation.*, department.DepartmentName, wst1.WorkstationName as LinkToName,enable.VDesc as EnableName, wstype.VDesc as TypeName")
->join('workstation wst1', 'workstation.LinkTo=wst1.WorkstationID', 'left') ->join('workstation wst1', 'workstation.LinkTo=wst1.WorkstationID', 'left')
->join('department', 'department.DepartmentID=workstation.DepartmentID', 'left') ->join('department', 'department.DepartmentID=workstation.DepartmentID', 'left')
->join('valueset wstype', 'wstype.VID=workstation.Type', 'left') ->join('valueset wstype', 'wstype.VID=workstation.Type', 'left')
->join('valueset enable', 'enable.VID=workstation.Enable', 'left') ->join('valueset enable', 'enable.VID=workstation.Enable', 'left')
->where('workstation.WorkstationID', $WorkstationID) ->where('workstation.WorkstationID', $WorkstationID)
->findAll(); ->first();
return $rows; return $row;
} }
} }

View File

@ -20,11 +20,11 @@ class PatVisitModel extends BaseModel {
protected $visnum_prefix = "DV"; protected $visnum_prefix = "DV";
public function show($PVID) { public function show($PVID) {
$rows = $this->select("*, patvisit.InternalPID, patvisit.CreateDate as PVCreateDate, patdiag.CreateDate as PDCreateDate, patvisitadt.CreateDate as PVACreateDate") $row = $this->select("*, patvisit.InternalPID, patvisit.CreateDate as PVCreateDate, patdiag.CreateDate as PDCreateDate, patvisitadt.CreateDate as PVACreateDate")
->join('patdiag', 'patdiag.InternalPVID=patvisit.InternalPVID and patdiag.DelDate is null', 'left') ->join('patdiag', 'patdiag.InternalPVID=patvisit.InternalPVID and patdiag.DelDate is null', 'left')
->join('patvisitadt', 'patvisitadt.InternalPVID=patvisit.InternalPVID', 'left') ->join('patvisitadt', 'patvisitadt.InternalPVID=patvisit.InternalPVID', 'left')
->where('patvisit.PVID',$PVID)->findAll(); ->where('patvisit.PVID',$PVID)->first();
return $rows; return $row;
} }
public function showByPatient($InternalPID) { public function showByPatient($InternalPID) {

View File

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

@ -33,11 +33,11 @@ class ContainerDefModel extends BaseModel {
} }
public function getContainer($ConDefID) { public function getContainer($ConDefID) {
$rows = $this->select('containerdef.*, vscol.VValue as ColorTxt, vscla.VValue as ConClassTxt, vsadd.VValue as AdditiveTxt') $row = $this->select('containerdef.*, vscol.VValue as ColorTxt, vscla.VValue as ConClassTxt, vsadd.VValue as AdditiveTxt')
->join('valueset vscol', 'vscol.VID=containerdef.Color', 'left') ->join('valueset vscol', 'vscol.VID=containerdef.Color', 'left')
->join('valueset vscla', 'vscla.VID=containerdef.ConClass', 'left') ->join('valueset vscla', 'vscla.VID=containerdef.ConClass', 'left')
->join('valueset vsadd', 'vsadd.VID=containerdef.Additive', 'left') ->join('valueset vsadd', 'vsadd.VID=containerdef.Additive', 'left')
->where('ConDefID', $ConDefID)->findAll(); ->where('ConDefID', $ConDefID)->first();
return $rows; return $row;
} }
} }

View File

@ -7,13 +7,72 @@ use App\Models\BaseModel;
class TestDefCalModel extends BaseModel { class TestDefCalModel extends BaseModel {
protected $table = 'testdefcal'; protected $table = 'testdefcal';
protected $primaryKey = 'TestCalID'; protected $primaryKey = 'TestCalID';
protected $allowedFields = ['SiteID', 'TestSiteID', 'DisciplineID', 'DepartmentID','FormulaCode', 'FormulaInput', protected $allowedFields = [
'Unit1', 'Factor', 'Unit2', 'Decimal' ,'CreateDate', 'EndDate']; 'TestSiteID',
'DisciplineID',
'DepartmentID',
'FormulaInput',
'FormulaCode',
'RefType',
'Unit1',
'Factor',
'Unit2',
'Decimal',
'Method',
'CreateDate',
'EndDate'
];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'CreateDate'; protected $createdField = 'CreateDate';
protected $updatedField = ''; protected $updatedField = '';
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
protected $deletedField = "EndDate"; protected $deletedField = "EndDate";
} /**
* Get calculation details for a test
*/
public function getCalcDetails($testSiteID) {
$db = \Config\Database::connect();
return $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', $testSiteID)
->where('testdefcal.EndDate IS NULL')
->get()->getResultArray();
}
/**
* Get calculated tests by discipline
*/
public function getCalcsByDiscipline($disciplineID, $siteID = null) {
$builder = $this->select('testdefcal.*, testdefsite.TestSiteCode, testdefsite.TestSiteName')
->join('testdefsite', 'testdefsite.TestSiteID=testdefcal.TestSiteID', 'left')
->where('testdefcal.DisciplineID', $disciplineID)
->where('testdefcal.EndDate IS NULL');
if ($siteID) {
$builder->where('testdefsite.SiteID', $siteID);
}
return $builder->findAll();
}
/**
* Get calculated tests by department
*/
public function getCalcsByDepartment($departmentID, $siteID = null) {
$builder = $this->select('testdefcal.*, testdefsite.TestSiteCode, testdefsite.TestSiteName')
->join('testdefsite', 'testdefsite.TestSiteID=testdefcal.TestSiteID', 'left')
->where('testdefcal.DepartmentID', $departmentID)
->where('testdefcal.EndDate IS NULL');
if ($siteID) {
$builder->where('testdefsite.SiteID', $siteID);
}
return $builder->findAll();
}
}

View File

@ -7,12 +7,43 @@ use App\Models\BaseModel;
class TestDefGrpModel extends BaseModel { class TestDefGrpModel extends BaseModel {
protected $table = 'testdefgrp'; protected $table = 'testdefgrp';
protected $primaryKey = 'TestGrpID'; protected $primaryKey = 'TestGrpID';
protected $allowedFields = ['SiteID', 'TestSiteID', 'Member', 'CreateDate', 'EndDate']; protected $allowedFields = [
'TestSiteID',
'Member',
'CreateDate',
'EndDate'
];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'CreateDate'; protected $createdField = 'CreateDate';
protected $updatedField = ''; protected $updatedField = '';
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
protected $deletedField = "EndDate"; protected $deletedField = "EndDate";
} /**
* Get group members for a test group
*/
public function getGroupMembers($testSiteID) {
$db = \Config\Database::connect();
return $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', $testSiteID)
->where('testdefgrp.EndDate IS NULL')
->orderBy('testdefgrp.TestGrpID', 'ASC')
->get()->getResultArray();
}
/**
* Get all groups that contain a specific test
*/
public function getGroupsContainingTest($memberTestSiteID) {
return $this->select('testdefgrp.*, t.TestSiteCode, t.TestSiteName')
->join('testdefsite t', 't.TestSiteID=testdefgrp.TestSiteID', 'left')
->where('testdefgrp.Member', $memberTestSiteID)
->where('testdefgrp.EndDate IS NULL')
->findAll();
}
}

View File

@ -7,23 +7,70 @@ use App\Models\BaseModel;
class TestDefSiteModel extends BaseModel { class TestDefSiteModel extends BaseModel {
protected $table = 'testdefsite'; protected $table = 'testdefsite';
protected $primaryKey = 'TestSiteID'; protected $primaryKey = 'TestSiteID';
protected $allowedFields = ['SiteID', 'TestSiteCode', 'TestSiteName', 'TestType', 'Description', 'SeqScr', 'SeqRpt', 'IndentLeft', protected $allowedFields = [
'VisibleScr', 'VisibleRpt', 'CountStat', 'CreateDate', 'EndDate']; 'SiteID',
'TestSiteCode',
'TestSiteName',
'TestType',
'Description',
'SeqScr',
'SeqRpt',
'IndentLeft',
'FontStyle',
'VisibleScr',
'VisibleRpt',
'CountStat',
'CreateDate',
'StartDate',
'EndDate'
];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'CreateDate'; protected $createdField = 'CreateDate';
protected $updatedField = ''; protected $updatedField = 'StartDate';
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
protected $deletedField = "EndDate"; protected $deletedField = "EndDate";
public function getTests() { /**
$rows = $this->select("TestSiteID, TestSiteCode, TestSiteName, TestType, valueset.VValue as TypeCode, valueset.VDesc as TypeName ") * Get all tests with type information
*/
public function getTests($siteId = null, $testType = null, $visibleScr = null, $visibleRpt = null, $keyword = null) {
$builder = $this->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") ->join("valueset", "valueset.VID=testdefsite.TestType", "left")
->findAll(); ->where('testdefsite.EndDate IS NULL');
return $rows;
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);
}
return $builder->orderBy('testdefsite.SeqScr', 'ASC')->findAll();
} }
/**
* Get single test with all related details based on TestType
*/
public function getTest($TestSiteID) { public function getTest($TestSiteID) {
$db = \Config\Database::connect();
$row = $this->select("testdefsite.*, valueset.VValue as TypeCode, valueset.VDesc as TypeName") $row = $this->select("testdefsite.*, valueset.VValue as TypeCode, valueset.VDesc as TypeName")
->join("valueset", "valueset.VID=testdefsite.TestType", "left") ->join("valueset", "valueset.VID=testdefsite.TestType", "left")
->where("testdefsite.TestSiteID", $TestSiteID) ->where("testdefsite.TestSiteID", $TestSiteID)
@ -31,14 +78,58 @@ class TestDefSiteModel extends BaseModel {
if (!$row) return null; if (!$row) return null;
if ($row['TypeCode'] == 'Calculated') { $typeCode = $row['TypeCode'] ?? '';
$row['testdefcal'] = $this->db->query("select * from testdefcal where TestSiteID='$TestSiteID'")->getResultArray();
} elseif ($row['TypeCode'] == 'GROUP') { // Load related details based on TestType
$row['testdefgrp'] = $this->db->query("select testdefgrp.*, t.TestSiteCode, t.TestSiteName from testdefgrp left join testdefsite t on t.TestSiteID=testdefgrp.Member where testdefgrp.TestSiteID='$TestSiteID'")->getResultArray(); if ($typeCode === 'CALC') {
} else { // Load calculation details with joined discipline and department
$row['testdeftech'] = $this->db->query("select * from testdeftech where TestSiteID='$TestSiteID'")->getResultArray(); $row['testdefcal'] = $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', $TestSiteID)
->where('testdefcal.EndDate IS NULL')
->get()->getResultArray();
// Load test mappings
$testMapModel = new \App\Models\Test\TestMapModel();
$row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll();
} elseif ($typeCode === 'GROUP') {
// Load group members with test details
$row['testdefgrp'] = $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', $TestSiteID)
->where('testdefgrp.EndDate IS NULL')
->orderBy('testdefgrp.TestGrpID', 'ASC')
->get()->getResultArray();
// Load test mappings
$testMapModel = new \App\Models\Test\TestMapModel();
$row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll();
} elseif ($typeCode === 'TITLE') {
// Load test mappings only for TITLE type
$testMapModel = new \App\Models\Test\TestMapModel();
$row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll();
} elseif (in_array($typeCode, ['TEST', 'PARAM'])) {
// TEST or PARAM - load technical details with joined tables
$row['testdeftech'] = $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', $TestSiteID)
->where('testdeftech.EndDate IS NULL')
->get()->getResultArray();
// Load test mappings
$testMapModel = new \App\Models\Test\TestMapModel();
$row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll();
} }
return $row; return $row;
} }
} }

View File

@ -7,13 +7,76 @@ use App\Models\BaseModel;
class TestDefTechModel extends BaseModel { class TestDefTechModel extends BaseModel {
protected $table = 'testdeftech'; protected $table = 'testdeftech';
protected $primaryKey = 'TestTechID'; protected $primaryKey = 'TestTechID';
protected $allowedFields = ['SiteID', 'TestSiteID', 'DisciplineID', 'DepartmentID', 'WorkstationID', 'EquipmentID', 'VSet', 'SpcType', protected $allowedFields = [
'ReqQty', 'ReqQtyUnit', 'Unit1', 'Factor', 'Unit2', 'Decimal', 'CollReq', 'Method', 'ExpectedTAT', 'CreateDate', 'EndDate']; 'TestSiteID',
'DisciplineID',
'DepartmentID',
'ResultType',
'RefType',
'VSet',
'ReqQty',
'ReqQtyUnit',
'Unit1',
'Factor',
'Unit2',
'Decimal',
'CollReq',
'Method',
'ExpectedTAT',
'CreateDate',
'EndDate'
];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'CreateDate'; protected $createdField = 'CreateDate';
protected $updatedField = ''; protected $updatedField = '';
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
protected $deletedField = "EndDate"; protected $deletedField = "EndDate";
} /**
* Get technical details for a test
*/
public function getTechDetails($testSiteID) {
$db = \Config\Database::connect();
return $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', $testSiteID)
->where('testdeftech.EndDate IS NULL')
->get()->getResultArray();
}
/**
* Get tests by discipline
*/
public function getTestsByDiscipline($disciplineID, $siteID = null) {
$builder = $this->select('testdeftech.*, testdefsite.TestSiteCode, testdefsite.TestSiteName')
->join('testdefsite', 'testdefsite.TestSiteID=testdeftech.TestSiteID', 'left')
->where('testdeftech.DisciplineID', $disciplineID)
->where('testdeftech.EndDate IS NULL');
if ($siteID) {
$builder->where('testdefsite.SiteID', $siteID);
}
return $builder->findAll();
}
/**
* Get tests by department
*/
public function getTestsByDepartment($departmentID, $siteID = null) {
$builder = $this->select('testdeftech.*, testdefsite.TestSiteCode, testdefsite.TestSiteName')
->join('testdefsite', 'testdefsite.TestSiteID=testdeftech.TestSiteID', 'left')
->where('testdeftech.DepartmentID', $departmentID)
->where('testdeftech.EndDate IS NULL');
if ($siteID) {
$builder->where('testdefsite.SiteID', $siteID);
}
return $builder->findAll();
}
}

View File

@ -7,8 +7,22 @@ use App\Models\BaseModel;
class TestMapModel extends BaseModel { class TestMapModel extends BaseModel {
protected $table = 'testmap'; protected $table = 'testmap';
protected $primaryKey = 'TestMapID'; protected $primaryKey = 'TestMapID';
protected $allowedFields = ['HostType', 'HostID', 'HostDataSource', 'HostTestCode', 'HostTestName', protected $allowedFields = [
'ClientType', 'ClientID', 'ClientTestCode', 'ClientTestName', 'CreateDate', 'EndDate' ]; 'TestSiteID',
'HostType',
'HostID',
'HostDataSource',
'HostTestCode',
'HostTestName',
'ClientType',
'ClientID',
'ClientDataSource',
'ConDefID',
'ClientTestCode',
'ClientTestName',
'CreateDate',
'EndDate'
];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'CreateDate'; protected $createdField = 'CreateDate';
@ -16,4 +30,67 @@ class TestMapModel extends BaseModel {
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
protected $deletedField = "EndDate"; protected $deletedField = "EndDate";
} /**
* Get test mappings by test site
*/
public function getMappingsByTestSite($testSiteID) {
return $this->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->findAll();
}
/**
* Get test mappings by client (equipment/workstation)
*/
public function getMappingsByClient($clientType, $clientID) {
return $this->where('ClientType', $clientType)
->where('ClientID', $clientID)
->where('EndDate IS NULL')
->findAll();
}
/**
* Get test mappings by host (site/HIS)
*/
public function getMappingsByHost($hostType, $hostID) {
return $this->where('HostType', $hostType)
->where('HostID', $hostID)
->where('EndDate IS NULL')
->findAll();
}
/**
* Get test mapping by client test code and container
*/
public function getMappingByClientCode($clientTestCode, $conDefID = null) {
$builder = $this->where('ClientTestCode', $clientTestCode)
->where('EndDate IS NULL');
if ($conDefID) {
$builder->where('ConDefID', $conDefID);
}
return $builder->findAll();
}
/**
* Get test mapping by host test code
*/
public function getMappingByHostCode($hostTestCode) {
return $this->where('HostTestCode', $hostTestCode)
->where('EndDate IS NULL')
->findAll();
}
/**
* Check if mapping exists for client and host
*/
public function mappingExists($testSiteID, $clientType, $clientID, $clientTestCode) {
return $this->where('TestSiteID', $testSiteID)
->where('ClientType', $clientType)
->where('ClientID', $clientID)
->where('ClientTestCode', $clientTestCode)
->where('EndDate IS NULL')
->countAllResults() > 0;
}
}

View File

@ -16,13 +16,25 @@ class ValueSetDefModel extends BaseModel {
protected $deletedField = 'EndDate'; protected $deletedField = 'EndDate';
public function getValueSetDefs($param = null) { public function getValueSetDefs($param = null) {
if ($param !== null) { // Get item counts subquery
$rows = $this->like('VSName', $param, 'both') $itemCounts = $this->db->table('valueset')
->orlike('VSDesc', $param, 'both') ->select('VSetID, COUNT(*) as ItemCount')
->findAll(); ->where('EndDate IS NULL')
} else { ->groupBy('VSetID');
$rows = $this->findAll();
$builder = $this->db->table('valuesetdef vd');
$builder->select('vd.*, COALESCE(ic.ItemCount, 0) as ItemCount');
$builder->join("({$itemCounts->getCompiledSelect()}) ic", 'vd.VSetID = ic.VSetID', 'LEFT');
$builder->where('vd.EndDate IS NULL');
if ($param !== null) {
$builder->groupStart()
->like('vd.VSName', $param, 'both')
->orLike('vd.VSDesc', $param, 'both')
->groupEnd();
} }
$rows = $builder->get()->getResultArray();
return $rows; return $rows;
} }

View File

@ -15,18 +15,30 @@ class ValueSetModel extends BaseModel {
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
protected $deletedField = 'EndDate'; protected $deletedField = 'EndDate';
public function getValueSets($param = null) { public function getValueSets($param = null, $page = null, $limit = 50, $VSetID = null) {
$this->select("valueset.*, v1.VDesc as VCategoryName") $this->select("valueset.*, valuesetdef.VSName as VCategoryName")
->join('valueset v1', 'valueset.VCategory = v1.VID', 'LEFT'); ->join('valuesetdef', 'valueset.VSetID = valuesetdef.VSetID', 'LEFT');
if ($VSetID !== null) {
$this->where('valueset.VSetID', $VSetID);
}
if ($param !== null) { if ($param !== null) {
$this $this->groupStart()
->groupStart() ->like('valueset.VValue', $param, 'both')
->like('VValue', $param, 'both') ->orLike('valueset.VDesc', $param, 'both')
->orlike('VDesc', $param, 'both') ->orLike('valuesetdef.VSName', $param, 'both')
->groupEnd(); ->groupEnd();
} }
$rows = $this->findAll();
return $rows; if ($page !== null) {
return [
'data' => $this->paginate($limit, 'default', $page),
'pager' => $this->pager
];
}
return $this->findAll();
} }
public function getValueSet($VID) { public function getValueSet($VID) {

View File

@ -1,86 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<!-- SEO Meta -->
<title><?= $title ?? 'CLQMS' ?> - Clinical Laboratory QMS</title>
<meta name="description" content="<?= $description ?? 'CLQMS - Modern Clinical Laboratory Quality Management System' ?>">
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Google Fonts - Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<!-- App Styles -->
<link rel="stylesheet" href="/assets/css/app.css">
<!-- Page-specific styles -->
<?= $this->renderSection('styles') ?>
</head>
<body class="bg-pattern" x-data>
<!-- Floating Decorative Shapes -->
<div class="floating-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<!-- Main Content -->
<?= $this->renderSection('content') ?>
<!-- Toast Notifications Container -->
<div
x-data
class="toast-container"
style="position: fixed; top: 1rem; right: 1rem; z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem;"
>
<template x-for="toast in $store.toast.messages" :key="toast.id">
<div
x-show="true"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-x-8"
x-transition:enter-end="opacity-100 transform translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
:class="{
'alert': true,
'alert-success': toast.type === 'success',
'alert-error': toast.type === 'error',
'alert-info': toast.type === 'info'
}"
style="min-width: 280px; cursor: pointer;"
@click="$store.toast.dismiss(toast.id)"
>
<span x-text="toast.message"></span>
</div>
</template>
</div>
<!-- Alpine.js 3.x -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- App Scripts (loaded before Alpine) -->
<script src="/assets/js/app.js"></script>
<!-- Initialize Lucide Icons -->
<script>
document.addEventListener('DOMContentLoaded', () => {
lucide.createIcons();
});
</script>
<!-- Page-specific scripts -->
<?= $this->renderSection('scripts') ?>
</body>
</html>

View File

@ -1,40 +0,0 @@
<?= $this->extend('layouts/main') ?>
<?= $this->section('content') ?>
<div style="min-height: 100vh; padding: 2rem;">
<div class="card card-glass fade-in" style="max-width: 600px; margin: 2rem auto; text-align: center;">
<div class="login-logo" style="margin-bottom: 1.5rem;">
<i data-lucide="layout-dashboard"></i>
</div>
<h1 style="margin-bottom: 0.5rem;">🎉 Welcome to Dashboard!</h1>
<p class="text-muted" style="margin-bottom: 2rem;">
You're successfully logged in. This is a placeholder page.
</p>
<?php if (isset($user)): ?>
<div class="alert alert-success" style="text-align: left;">
<i data-lucide="check-circle" style="width: 18px; height: 18px;"></i>
<span>Logged in as: <strong><?= esc($user->username ?? 'User') ?></strong></span>
</div>
<?php endif; ?>
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
<a href="/login" class="btn btn-secondary">
<i data-lucide="arrow-left" style="width: 18px; height: 18px;"></i>
Back to Login
</a>
<form action="/logout" method="get" style="margin: 0;">
<button type="submit" class="btn btn-primary">
<i data-lucide="log-out" style="width: 18px; height: 18px;"></i>
Logout
</button>
</form>
</div>
</div>
</div>
<?= $this->endSection() ?>

View File

@ -1,130 +0,0 @@
<?= $this->extend('layouts/main') ?>
<?= $this->section('content') ?>
<div class="login-container">
<div class="login-card card card-glass fade-in" x-data="loginForm" x-ref="loginCard">
<!-- Header -->
<div class="login-header">
<div class="login-logo">
<i data-lucide="flask-conical"></i>
</div>
<h1 class="login-title">Welcome Back!</h1>
<p class="login-subtitle">Sign in to your CLQMS account</p>
</div>
<!-- Error Alert -->
<template x-if="error">
<div class="alert alert-error" x-transition>
<i data-lucide="alert-circle" style="width: 18px; height: 18px;"></i>
<span x-text="error"></span>
</div>
</template>
<!-- Login Form -->
<form @submit.prevent="submitLogin">
<!-- Username Field -->
<div class="form-group">
<label class="form-label" for="username">Username</label>
<div class="form-input-icon">
<i data-lucide="user" class="icon" style="width: 18px; height: 18px;"></i>
<input
type="text"
id="username"
class="form-input"
placeholder="Enter your username"
x-model="username"
:disabled="isLoading"
autocomplete="username"
autofocus
>
</div>
</div>
<!-- Password Field -->
<div class="form-group">
<label class="form-label" for="password">Password</label>
<div class="form-input-icon">
<i data-lucide="lock" class="icon" style="width: 18px; height: 18px;"></i>
<input
:type="showPassword ? 'text' : 'password'"
id="password"
class="form-input"
placeholder="Enter your password"
x-model="password"
:disabled="isLoading"
autocomplete="current-password"
style="padding-right: 3rem;"
>
<button
type="button"
class="password-toggle"
@click="togglePassword"
:title="showPassword ? 'Hide password' : 'Show password'"
>
<i :data-lucide="showPassword ? 'eye-off' : 'eye'" style="width: 18px; height: 18px;"></i>
</button>
</div>
</div>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between mb-4">
<label class="checkbox-wrapper">
<input
type="checkbox"
class="checkbox-input"
x-model="rememberMe"
:disabled="isLoading"
>
<span class="checkbox-label">Remember me</span>
</label>
<a href="#" class="text-sm text-primary">Forgot password?</a>
</div>
<!-- Submit Button -->
<button
type="submit"
class="btn btn-primary btn-lg btn-block"
:disabled="isLoading"
>
<template x-if="isLoading">
<div class="spinner"></div>
</template>
<template x-if="!isLoading">
<i data-lucide="log-in" style="width: 20px; height: 20px;"></i>
</template>
<span x-text="isLoading ? 'Signing in...' : 'Sign In'"></span>
</button>
</form>
<!-- Footer -->
<div class="login-footer">
<p class="text-muted">
&copy; <?= date('Y') ?> CLQMS • Clinical Laboratory QMS
</p>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
// Re-initialize Lucide icons after Alpine updates the DOM
document.addEventListener('alpine:initialized', () => {
// Watch for DOM changes and re-create icons
const observer = new MutationObserver(() => {
lucide.createIcons();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
</script>
<?= $this->endSection() ?>

214
app/Views/v2/auth/login.php Normal file
View File

@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - CLQMS</title>
<!-- Google Fonts - Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- TailwindCSS 4 CDN -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- Custom Styles -->
<link rel="stylesheet" href="<?= base_url('css/v2/styles.css') ?>">
<!-- FontAwesome -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/js/all.min.js"></script>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
/* Animated gradient background */
.gradient-bg {
background: linear-gradient(-45deg, #1e3a8a, #1e40af, #2563eb, #3b82f6);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
}
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Floating animation for logo */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.float-animation {
animation: float 3s ease-in-out infinite;
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center gradient-bg" x-data="loginApp()">
<!-- Login Card -->
<div class="w-full max-w-md p-4">
<div class="card-glass p-8 animate-fadeIn">
<!-- Logo & Title -->
<div class="text-center mb-8">
<div class="w-20 h-20 mx-auto mb-4 rounded-3xl bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-xl float-animation">
<i class="fa-solid fa-flask text-white text-4xl"></i>
</div>
<h1 class="text-3xl font-bold mb-2 text-gradient">CLQMS</h1>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Clinical Laboratory Quality Management System</p>
</div>
<!-- Alert Messages -->
<div x-show="errorMessage" x-cloak class="alert alert-error mb-4 animate-slideInUp">
<i class="fa-solid fa-exclamation-circle"></i>
<span x-text="errorMessage"></span>
</div>
<div x-show="successMessage" x-cloak class="alert alert-success mb-4 animate-slideInUp">
<i class="fa-solid fa-check-circle"></i>
<span x-text="successMessage"></span>
</div>
<!-- Login Form -->
<form @submit.prevent="login" class="space-y-4">
<!-- Username -->
<div>
<label class="label">
<span class="label-text font-medium">Username</span>
</label>
<div class="relative">
<input
type="text"
placeholder="Enter your username"
class="input !pl-10"
x-model="form.username"
required
:disabled="loading"
/>
<span class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-user"></i>
</span>
</div>
</div>
<!-- Password -->
<div>
<label class="label">
<span class="label-text font-medium">Password</span>
</label>
<div class="relative">
<input
:type="showPassword ? 'text' : 'password'"
placeholder="Enter your password"
class="input !pl-10 !pr-10"
x-model="form.password"
required
:disabled="loading"
/>
<span class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-lock"></i>
</span>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 flex items-center pr-3 z-10"
style="color: rgb(var(--color-text-muted));"
tabindex="-1"
>
<i :class="showPassword ? 'fa-solid fa-eye-slash' : 'fa-solid fa-eye'"></i>
</button>
</div>
</div>
<!-- Remember Me -->
<div class="flex items-center gap-2">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.remember" id="remember" />
<label for="remember" class="label-text cursor-pointer">Remember me</label>
</div>
<!-- Submit Button -->
<button
type="submit"
class="btn btn-primary w-full !rounded-full"
:disabled="loading"
>
<span x-show="loading" class="spinner spinner-sm"></span>
<span x-show="!loading">
<i class="fa-solid fa-sign-in-alt mr-2"></i>
Login
</span>
</button>
</form>
</div>
<!-- Copyright -->
<div class="text-center mt-6 text-white/90">
<p class="text-sm drop-shadow-lg">© 2025 5Panda. All rights reserved.</p>
</div>
</div>
<!-- Scripts -->
<script>
window.BASEURL = "<?= base_url() ?>";
function loginApp() {
return {
loading: false,
showPassword: false,
errorMessage: '',
successMessage: '',
form: {
username: '',
password: '',
remember: false
},
async login() {
this.errorMessage = '';
this.successMessage = '';
this.loading = true;
try {
const formData = new URLSearchParams({
username: this.form.username,
password: this.form.password,
remember: this.form.remember ? '1' : '0'
});
const res = await fetch(`${BASEURL}v2/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData,
credentials: 'include'
});
const data = await res.json();
if (res.ok && data.status === 'success') {
this.successMessage = 'Login successful! Redirecting...';
setTimeout(() => {
window.location.href = `${BASEURL}v2/`;
}, 1000);
} else {
this.errorMessage = data.message || 'Login failed. Please try again.';
}
} catch (err) {
console.error(err);
this.errorMessage = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,153 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div class="w-full space-y-6">
<!-- Welcome Section -->
<div class="card-glass p-8 animate-fadeIn">
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-chart-line text-3xl text-white"></i>
</div>
<div>
<h2 class="text-3xl font-bold mb-2" style="color: rgb(var(--color-text));">Welcome to CLQMS</h2>
<p class="text-lg" style="color: rgb(var(--color-text-muted));">Clinical Laboratory Quality Management System</p>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Total Patients -->
<div class="card group hover:shadow-xl transition-all duration-300">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Total Patients</p>
<p class="text-3xl font-bold" style="color: rgb(var(--color-text));">1,247</p>
</div>
<div class="w-14 h-14 rounded-2xl bg-blue-500/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<i class="fa-solid fa-users text-blue-500 text-2xl"></i>
</div>
</div>
</div>
</div>
<!-- Today's Visits -->
<div class="card group hover:shadow-xl transition-all duration-300">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Today's Visits</p>
<p class="text-3xl font-bold text-emerald-500">89</p>
</div>
<div class="w-14 h-14 rounded-2xl bg-emerald-500/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<i class="fa-solid fa-calendar-check text-emerald-500 text-2xl"></i>
</div>
</div>
</div>
</div>
<!-- Pending Tests -->
<div class="card group hover:shadow-xl transition-all duration-300">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Pending Tests</p>
<p class="text-3xl font-bold text-amber-500">34</p>
</div>
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<i class="fa-solid fa-flask text-amber-500 text-2xl"></i>
</div>
</div>
</div>
</div>
<!-- Completed Today -->
<div class="card group hover:shadow-xl transition-all duration-300">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Completed</p>
<p class="text-3xl font-bold text-sky-500">156</p>
</div>
<div class="w-14 h-14 rounded-2xl bg-sky-500/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<i class="fa-solid fa-check-circle text-sky-500 text-2xl"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Recent Activity -->
<div class="card">
<div class="p-6">
<h3 class="text-lg font-bold mb-4 flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-clock-rotate-left" style="color: rgb(var(--color-primary));"></i>
Recent Activity
</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 p-3 rounded-lg hover:bg-opacity-50 transition-colors" style="background: rgb(var(--color-bg) / 0.5);">
<div class="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-user-plus text-emerald-500"></i>
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm" style="color: rgb(var(--color-text));">New patient registered</p>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">John Doe - 5 minutes ago</p>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg hover:bg-opacity-50 transition-colors" style="background: rgb(var(--color-bg) / 0.5);">
<div class="w-10 h-10 rounded-full bg-sky-500/20 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-vial text-sky-500"></i>
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm" style="color: rgb(var(--color-text));">Test completed</p>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">Sample #12345 - 12 minutes ago</p>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg hover:bg-opacity-50 transition-colors" style="background: rgb(var(--color-bg) / 0.5);">
<div class="w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-exclamation-triangle text-amber-500"></i>
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm" style="color: rgb(var(--color-text));">Pending approval</p>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">Request #789 - 25 minutes ago</p>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Links -->
<div class="card">
<div class="p-6">
<h3 class="text-lg font-bold mb-4 flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-bolt" style="color: rgb(var(--color-primary));"></i>
Quick Actions
</h3>
<div class="grid grid-cols-2 gap-3">
<a href="<?= base_url('/v2/patients') ?>" class="btn btn-outline group">
<i class="fa-solid fa-users mr-2 group-hover:scale-110 transition-transform"></i>
Patients
</a>
<a href="<?= base_url('/v2/requests') ?>" class="btn btn-outline-secondary group">
<i class="fa-solid fa-flask mr-2 group-hover:scale-110 transition-transform"></i>
Lab Requests
</a>
<button class="btn btn-outline-accent group">
<i class="fa-solid fa-vial mr-2 group-hover:scale-110 transition-transform"></i>
Specimens
</button>
<button class="btn btn-outline-info group">
<i class="fa-solid fa-chart-bar mr-2 group-hover:scale-110 transition-transform"></i>
Reports
</button>
</div>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>

View File

@ -0,0 +1,389 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= esc($pageTitle ?? 'CLQMS') ?> - CLQMS</title>
<!-- Google Fonts - Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- TailwindCSS 4 CDN -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- Custom Styles -->
<link rel="stylesheet" href="<?= base_url('css/v2/styles.css') ?>">
<!-- FontAwesome -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/js/all.min.js"></script>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="min-h-screen flex" style="background: rgb(var(--color-bg));" x-data="layout()">
<!-- Sidebar -->
<aside
class="sidebar sticky top-0 z-40 h-screen flex flex-col shadow-2xl"
:class="sidebarOpen ? 'w-64' : 'w-0 lg:w-20'"
style="transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1);"
>
<!-- Sidebar Header -->
<div class="h-16 flex items-center justify-between px-4 border-b border-white/10" x-show="sidebarOpen" x-cloak>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-flask text-white text-lg"></i>
</div>
<span class="text-xl font-bold text-white">CLQMS</span>
</div>
</div>
<!-- Collapsed Logo -->
<div class="h-16 flex items-center justify-center border-b border-white/10" x-show="!sidebarOpen" x-cloak>
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-flask text-white text-lg"></i>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 py-6 overflow-y-auto" :class="sidebarOpen ? 'px-4' : 'px-2'">
<ul class="menu">
<!-- Dashboard -->
<li>
<a href="<?= base_url('/v2/') ?>"
:class="isActive('v2') ? 'active' : ''"
class="group">
<i class="fa-solid fa-th-large w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen" x-cloak>Dashboard</span>
</a>
</li>
<!-- Patients -->
<li>
<a href="<?= base_url('/v2/patients') ?>"
:class="isActive('patients') ? 'active' : ''"
class="group">
<i class="fa-solid fa-users w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen" x-cloak>Patients</span>
</a>
</li>
<!-- Lab Requests -->
<li>
<a href="<?= base_url('/v2/requests') ?>"
:class="isActive('requests') ? 'active' : ''"
class="group">
<i class="fa-solid fa-flask w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen" x-cloak>Lab Requests</span>
</a>
</li>
<!-- Master Data Sections -->
<template x-if="sidebarOpen">
<li class="px-3 py-2 mt-4 mb-1">
<span class="text-xs font-semibold uppercase tracking-wider opacity-60">Master Data</span>
</li>
</template>
<!-- Organization (Nested Group) -->
<li>
<div x-data="{
isOpen: orgOpen,
toggle() { this.isOpen = !this.isOpen; $root.layout().orgOpen = this.isOpen }
}" x-init="$watch('orgOpen', v => isOpen = v)">
<button @click="isOpen = !isOpen"
class="group w-full flex items-center justify-between"
:class="isParentActive('organization') ? 'text-primary font-medium' : ''">
<div class="flex items-center gap-3">
<i class="fa-solid fa-building w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen">Organization</span>
</div>
<i x-show="sidebarOpen" class="fa-solid fa-chevron-down text-xs transition-transform" :class="isOpen && 'rotate-180'"></i>
</button>
<ul x-show="isOpen && sidebarOpen" x-collapse class="ml-8 mt-2 space-y-1">
<li>
<a href="<?= base_url('/v2/master/organization/accounts') ?>"
:class="isActive('organization/accounts') ? 'active' : ''"
class="text-sm">Accounts</a>
</li>
<li>
<a href="<?= base_url('/v2/master/organization/sites') ?>"
:class="isActive('organization/sites') ? 'active' : ''"
class="text-sm">Sites</a>
</li>
<li>
<a href="<?= base_url('/v2/master/organization/disciplines') ?>"
:class="isActive('organization/disciplines') ? 'active' : ''"
class="text-sm">Disciplines</a>
</li>
<li>
<a href="<?= base_url('/v2/master/organization/departments') ?>"
:class="isActive('organization/departments') ? 'active' : ''"
class="text-sm">Departments</a>
</li>
<li>
<a href="<?= base_url('/v2/master/organization/workstations') ?>"
:class="isActive('organization/workstations') ? 'active' : ''"
class="text-sm">Workstations</a>
</li>
</ul>
</div>
</li>
<!-- Specimen (Nested Group) -->
<li>
<div x-data="{
isOpen: specimenOpen,
toggle() { this.isOpen = !this.isOpen; $root.layout().specimenOpen = this.isOpen }
}" x-init="$watch('specimenOpen', v => isOpen = v)">
<button @click="isOpen = !isOpen"
class="group w-full flex items-center justify-between"
:class="isParentActive('specimen') ? 'text-primary font-medium' : ''">
<div class="flex items-center gap-3">
<i class="fa-solid fa-vial w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen">Specimen</span>
</div>
<i x-show="sidebarOpen" class="fa-solid fa-chevron-down text-xs transition-transform" :class="isOpen && 'rotate-180'"></i>
</button>
<ul x-show="isOpen && sidebarOpen" x-collapse class="ml-8 mt-2 space-y-1">
<li>
<a href="<?= base_url('/v2/master/specimen/containers') ?>"
:class="isActive('specimen/containers') ? 'active' : ''"
class="text-sm">Container Defs</a>
</li>
<li>
<a href="<?= base_url('/v2/master/specimen/preparations') ?>"
:class="isActive('specimen/preparations') ? 'active' : ''"
class="text-sm">Preparations</a>
</li>
</ul>
</div>
</li>
<!-- Lab Tests -->
<li>
<a href="<?= base_url('/v2/master/tests') ?>"
:class="isActive('master/tests') ? 'active' : ''"
class="group">
<i class="fa-solid fa-microscope w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen">Lab Tests</span>
</a>
</li>
<!-- Value Sets -->
<li>
<a href="<?= base_url('/v2/master/valuesets') ?>"
:class="isActive('master/valuesets') ? 'active' : ''"
class="group">
<i class="fa-solid fa-list-check w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen">Value Sets</span>
</a>
</li>
<!-- Settings -->
<li class="mt-4">
<a href="<?= base_url('/v2/settings') ?>"
:class="isActive('settings') ? 'active' : ''"
class="group">
<i class="fa-solid fa-cog w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen" x-cloak>Settings</span>
</a>
</li>
</ul>
</nav>
</aside>
<!-- Overlay for mobile -->
<div
x-show="sidebarOpen"
@click="sidebarOpen = false"
class="fixed inset-0 bg-black/50 z-30 lg:hidden backdrop-blur-sm"
x-cloak
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>
<!-- Main Content Wrapper -->
<div class="flex-1 flex flex-col min-h-screen">
<!-- Top Navbar -->
<nav class="h-16 flex items-center justify-between px-4 lg:px-6 sticky top-0 z-20 glass shadow-sm">
<!-- Left: Burger Menu & Title -->
<div class="flex items-center gap-4">
<button @click="sidebarOpen = !sidebarOpen" class="btn btn-ghost btn-square">
<i class="fa-solid fa-bars text-lg"></i>
</button>
<h1 class="text-lg font-semibold" style="color: rgb(var(--color-text));"><?= esc($pageTitle ?? 'Dashboard') ?></h1>
</div>
<!-- Right: Theme Toggle & User -->
<div class="flex items-center gap-2">
<!-- Theme Toggle -->
<button @click="toggleTheme()" class="btn btn-ghost btn-square group" title="Toggle theme">
<i x-show="lightMode" class="fa-solid fa-moon text-lg transition-transform group-hover:rotate-12"></i>
<i x-show="!lightMode" class="fa-solid fa-sun text-lg transition-transform group-hover:rotate-45"></i>
</button>
<!-- User Dropdown -->
<div class="dropdown dropdown-end" x-data="{ open: false }">
<button @click="open = !open" class="btn btn-ghost gap-2 px-3">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-md">
<span class="text-xs font-semibold text-white">U</span>
</div>
<span class="hidden sm:inline text-sm font-medium">User</span>
<i class="fa-solid fa-chevron-down text-xs opacity-60 transition-transform" :class="open && 'rotate-180'"></i>
</button>
<!-- Dropdown Content -->
<div
x-show="open"
@click.away="open = false"
x-cloak
class="dropdown-content mt-2 w-72 shadow-2xl"
x-transition:enter="transition ease-out duration-150"
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-100"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- User Info Header -->
<div class="px-4 py-4" style="border-bottom: 1px solid rgb(var(--color-border));">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-lg">
<span class="text-sm font-semibold text-white">U</span>
</div>
<div>
<p class="font-semibold text-sm" style="color: rgb(var(--color-text));">User Name</p>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">user@example.com</p>
</div>
</div>
</div>
<!-- Menu Items -->
<ul class="menu menu-sm p-2">
<li>
<a href="#" class="flex items-center gap-3 py-2">
<i class="fa-solid fa-user w-4 text-center"></i>
<span>Profile</span>
</a>
</li>
<li>
<a href="#" class="flex items-center gap-3 py-2">
<i class="fa-solid fa-cog w-4 text-center"></i>
<span>Settings</span>
</a>
</li>
</ul>
<!-- Logout -->
<div style="border-top: 1px solid rgb(var(--color-border));" class="p-2">
<button @click="logout()" class="btn btn-ghost btn-sm w-full justify-start gap-3 hover:bg-red-50" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-sign-out-alt w-4 text-center"></i>
<span>Logout</span>
</button>
</div>
</div>
</div>
</div>
</nav>
<!-- Page Content -->
<main class="flex-1 p-4 lg:p-6 overflow-auto">
<?= $this->renderSection('content') ?>
</main>
<!-- Footer -->
<footer class="glass border-t py-4 px-6" style="border-color: rgb(var(--color-border));">
<div class="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm" style="color: rgb(var(--color-text-muted));">
<span>© 2025 5Panda. All rights reserved.</span>
<span>CLQMS v1.0.0</span>
</div>
</footer>
</div>
<!-- Global Scripts -->
<script>
window.BASEURL = "<?= base_url() ?>".replace(/\/$/, "") + "/";
function layout() {
return {
sidebarOpen: localStorage.getItem('sidebarOpen') !== 'false',
lightMode: localStorage.getItem('theme') !== 'dark',
orgOpen: false,
specimenOpen: false,
currentPath: window.location.pathname,
init() {
// Apply saved theme (default to light theme)
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
this.lightMode = savedTheme === 'light';
// Detect sidebar open/closed for mobile
if (window.innerWidth < 1024) this.sidebarOpen = false;
// Auto-expand menus based on active path
this.orgOpen = this.currentPath.includes('organization');
this.specimenOpen = this.currentPath.includes('specimen');
// Watch sidebar state to persist
this.$watch('sidebarOpen', val => localStorage.setItem('sidebarOpen', val));
},
isActive(path) {
// Get the current path without query strings or hash
const current = window.location.pathname;
// Handle dashboard as root - exact match only
if (path === 'v2') {
return current === '/v2' || current === '/v2/' || current === '/clqms-be/v2' || current === '/clqms-be/v2/';
}
// For other paths, check if current path contains the expected path segment
// Use exact match with /v2/ prefix
const checkPath = '/v2/' + path;
return current.includes(checkPath);
},
isParentActive(parent) {
return this.currentPath.includes(parent);
},
toggleTheme() {
this.lightMode = !this.lightMode;
const theme = this.lightMode ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
},
async logout() {
try {
const res = await fetch(`${BASEURL}v2/auth/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
window.location.href = `${BASEURL}v2/login`;
}
} catch (err) {
console.error('Logout error:', err);
window.location.href = `${BASEURL}v2/login`;
}
}
}
}
</script>
<?= $this->renderSection('script') ?>
</body>
</html>

View File

@ -0,0 +1,189 @@
<!-- Account Form Modal -->
<div
x-show="showModal"
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-6">
<h3 class="font-bold text-xl flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-building-circle-plus" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Account' : 'New Account'"></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="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Basic Info Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Basic Information</h4>
<div>
<label class="label">
<span class="label-text font-medium">Account Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.AccountName && 'input-error'"
x-model="form.AccountName"
placeholder="Main Laboratory Inc."
/>
<label class="label" x-show="errors.AccountName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.AccountName"></span>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Initial / Code</span>
</label>
<input
type="text"
class="input text-center font-mono"
x-model="form.Initial"
placeholder="MLAB"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Parent Account</span>
</label>
<select class="select" x-model="form.Parent">
<option value="">None (Top Level)</option>
<template x-for="acc in list" :key="acc.AccountID">
<option :value="acc.AccountID" x-text="acc.AccountName"></option>
</template>
</select>
</div>
</div>
<div class="divider">Contact Info</div>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Email Address</span>
</label>
<input
type="email"
class="input"
x-model="form.EmailAddress1"
placeholder="contact@lab.com"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Phone Number</span>
</label>
<input
type="tel"
class="input"
x-model="form.Phone"
placeholder="+62 21..."
/>
</div>
</div>
</div>
<!-- Address Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Location & Address</h4>
<div>
<label class="label">
<span class="label-text font-medium">Street Address</span>
</label>
<textarea
class="input h-24 pt-2"
x-model="form.Street_1"
placeholder="Jalan Sudirman No. 123..."
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">City</span>
</label>
<input
type="text"
class="input"
x-model="form.City"
placeholder="Jakarta"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Province</span>
</label>
<input
type="text"
class="input"
x-model="form.Province"
placeholder="DKI Jakarta"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">ZIP Code</span>
</label>
<input
type="text"
class="input"
x-model="form.ZIP"
placeholder="12345"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Country</span>
</label>
<input
type="text"
class="input"
x-model="form.Country"
placeholder="Indonesia"
/>
</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-primary 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 Account'"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,329 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="accounts()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-600 to-purple-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-building text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Organization Accounts</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage organization accounts and entities</p>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search accounts..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Account
</button>
</div>
</div>
</div>
<!-- Content Card -->
<div class="card overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading accounts...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Account Name</th>
<th>Code</th>
<th>Parent</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="5" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
<p class="text-lg">No accounts found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Account
</button>
</div>
</td>
</tr>
</template>
<!-- Account Rows -->
<template x-for="account in list" :key="account.AccountID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="account.AccountID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="account.AccountName || '-'"></div>
</td>
<td x-text="account.Initial || '-'"></td>
<td>
<span class="text-xs" x-text="account.ParentName || '-'"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editAccount(account.AccountID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(account)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="p-4 flex items-center justify-between" style="border-top: 1px solid rgb(var(--color-border));" x-show="list && list.length > 0">
<span class="text-sm" style="color: rgb(var(--color-text-muted));" x-text="'Showing ' + list.length + ' accounts'"></span>
</div>
</div>
<!-- Include Form Dialog -->
<?= $this->include('v2/master/organization/account_dialog') ?>
<!-- Delete Confirmation Modal -->
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete account <strong x-text="deleteTarget?.AccountName"></strong>?
This action cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteAccount()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function accounts() {
return {
// State
loading: false,
list: [],
keyword: "",
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
AccountID: null,
Parent: "",
AccountName: "",
Initial: "",
Street_1: "",
City: "",
Province: "",
ZIP: "",
Country: "",
EmailAddress1: "",
Phone: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchList();
},
// Fetch account list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('AccountName', this.keyword);
const res = await fetch(`${BASEURL}api/organization/account?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Show form for new account
showForm() {
this.isEditing = false;
this.form = {
AccountID: null,
Parent: "",
AccountName: "",
Initial: "",
Street_1: "",
City: "",
Province: "",
ZIP: "",
Country: "",
EmailAddress1: "",
Phone: ""
};
this.errors = {};
this.showModal = true;
},
// Edit account
async editAccount(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/organization/account/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load account data');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.AccountName?.trim()) e.AccountName = "Account name is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save account
async save() {
if (!this.validate()) return;
this.saving = true;
try {
let res;
const method = this.isEditing ? 'PATCH' : 'POST';
res = await fetch(`${BASEURL}api/organization/account`, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save account");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(account) {
this.deleteTarget = account;
this.showDeleteModal = true;
},
// Delete account
async deleteAccount() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/organization/account`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ AccountID: this.deleteTarget.AccountID }),
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
alert("Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete account");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,107 @@
<!-- Department Form Modal -->
<div
x-show="showModal"
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-xl 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-sitemap" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Department' : 'New Department'"></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>
<label class="label">
<span class="label-text font-medium">Department Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.DepartmentName && 'input-error'"
x-model="form.DepartmentName"
placeholder="Clinical Chemistry"
/>
<label class="label" x-show="errors.DepartmentName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.DepartmentName"></span>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Department Code <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input font-mono"
:class="errors.DepartmentCode && 'input-error'"
x-model="form.DepartmentCode"
placeholder="CHEM"
/>
<label class="label" x-show="errors.DepartmentCode">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.DepartmentCode"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Site</span>
</label>
<select class="select" x-model="form.SiteID">
<option value="">Select Site</option>
<template x-for="s in sitesList" :key="s.SiteID">
<option :value="s.SiteID" x-text="s.SiteName"></option>
</template>
</select>
</div>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Discipline</span>
</label>
<select class="select" x-model="form.DisciplineID">
<option value="">Select Discipline</option>
<template x-for="d in disciplinesList" :key="d.DisciplineID">
<option :value="d.DisciplineID" x-text="d.DisciplineName"></option>
</template>
</select>
</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-primary 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 Department'"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,351 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="departments()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-teal-600 to-teal-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-sitemap text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Organization Departments</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage lab departments and functional units</p>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search departments..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Department
</button>
</div>
</div>
</div>
<!-- Content Card -->
<div class="card overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading departments...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Department Name</th>
<th>Code</th>
<th>Discipline</th>
<th>Site</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
<p class="text-lg">No departments found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Department
</button>
</div>
</td>
</tr>
</template>
<!-- Department Rows -->
<template x-for="dept in list" :key="dept.DepartmentID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="dept.DepartmentID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="dept.DepartmentName || '-'"></div>
</td>
<td class="font-mono text-sm" x-text="dept.DepartmentCode || '-'"></td>
<td x-text="dept.DisciplineName || '-'"></td>
<td x-text="dept.SiteName || '-'"></td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editDepartment(dept.DepartmentID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(dept)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Include Form Dialog -->
<?= $this->include('v2/master/organization/department_dialog') ?>
<!-- Delete Confirmation Modal -->
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete department <strong x-text="deleteTarget?.DepartmentName"></strong>?
This action cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteDepartment()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function departments() {
return {
// State
loading: false,
list: [],
sitesList: [],
disciplinesList: [],
keyword: "",
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
DepartmentID: null,
DepartmentCode: "",
DepartmentName: "",
SiteID: "",
DisciplineID: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchList();
await this.fetchSites();
await this.fetchDisciplines();
},
// Fetch department list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('DepartmentName', this.keyword);
const res = await fetch(`${BASEURL}api/organization/department?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Fetch site list for dropdown
async fetchSites() {
try {
const res = await fetch(`${BASEURL}api/organization/site`, {
credentials: 'include'
});
const data = await res.json();
this.sitesList = data.data || [];
} catch (err) {
console.error('Failed to fetch sites:', err);
}
},
// Fetch discipline list for dropdown
async fetchDisciplines() {
try {
const res = await fetch(`${BASEURL}api/organization/discipline`, {
credentials: 'include'
});
const data = await res.json();
// Since discipline API returns nested structure, we need to flatten it for the dropdown
const flat = [];
if (data.data) {
data.data.forEach(p => {
flat.push({ DisciplineID: p.DisciplineID, DisciplineName: p.DisciplineName });
if (p.children) {
p.children.forEach(c => {
flat.push({ DisciplineID: c.DisciplineID, DisciplineName: `— ${c.DisciplineName}` });
});
}
});
}
this.disciplinesList = flat;
} catch (err) {
console.error('Failed to fetch disciplines:', err);
}
},
// Show form for new department
showForm() {
this.isEditing = false;
this.form = {
DepartmentID: null,
DepartmentCode: "",
DepartmentName: "",
SiteID: "",
DisciplineID: ""
};
this.errors = {};
this.showModal = true;
},
// Edit department
async editDepartment(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/organization/department/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load department data');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.DepartmentName?.trim()) e.DepartmentName = "Department name is required";
if (!this.form.DepartmentCode?.trim()) e.DepartmentCode = "Department code is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save department
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PATCH' : 'POST';
const res = await fetch(`${BASEURL}api/organization/department`, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save department");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(dept) {
this.deleteTarget = dept;
this.showDeleteModal = true;
},
// Delete department
async deleteDepartment() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/organization/department`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ DepartmentID: this.deleteTarget.DepartmentID }),
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
alert("Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete department");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,107 @@
<!-- Discipline Form Modal -->
<div
x-show="showModal"
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-xl 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-layer-group" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Discipline' : 'New Discipline'"></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>
<label class="label">
<span class="label-text font-medium">Discipline Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.DisciplineName && 'input-error'"
x-model="form.DisciplineName"
placeholder="Hematology"
/>
<label class="label" x-show="errors.DisciplineName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.DisciplineName"></span>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Discipline Code <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input font-mono"
:class="errors.DisciplineCode && 'input-error'"
x-model="form.DisciplineCode"
placeholder="HEM"
/>
<label class="label" x-show="errors.DisciplineCode">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.DisciplineCode"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Site</span>
</label>
<select class="select" x-model="form.SiteID">
<option value="">Select Site</option>
<template x-for="s in sitesList" :key="s.SiteID">
<option :value="s.SiteID" x-text="s.SiteName"></option>
</template>
</select>
</div>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Parent Discipline</span>
</label>
<select class="select" x-model="form.Parent">
<option value="">None</option>
<template x-for="d in flatList" :key="d.DisciplineID">
<option :value="d.DisciplineID" x-text="d.DisciplineName" :disabled="d.DisciplineID == form.DisciplineID"></option>
</template>
</select>
</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-primary 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 Discipline'"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,352 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="disciplines()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-600 to-indigo-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-layer-group text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Lab Disciplines</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage laboratory disciplines and specialties</p>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search disciplines..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Discipline
</button>
</div>
</div>
</div>
<!-- Content Card -->
<div class="card overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading disciplines...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table table-compact w-full">
<thead>
<tr>
<th width="80">ID</th>
<th>Discipline Name</th>
<th>Code</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="4" class="text-center py-4">
<div class="flex flex-col items-center gap-2 py-2" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-3xl opacity-40"></i>
<p class="text-sm">No disciplines found</p>
<button class="btn btn-primary btn-xs mt-1" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Discipline
</button>
</div>
</td>
</tr>
</template>
<!-- Discipline Rows -->
<template x-for="item in flatListWithLevels" :key="item.DisciplineID">
<tr :class="item.level === 0 ? 'bg-slate-50/50' : 'hover:bg-opacity-50'">
<td :class="item.level === 1 ? 'pl-8' : ''">
<span class="badge badge-ghost font-mono text-xs" :class="{'opacity-60': item.level === 1}" x-text="item.DisciplineID"></span>
</td>
<td :class="item.level === 1 ? 'pl-12' : ''">
<div class="flex items-center gap-2" :class="item.level === 0 ? 'font-bold' : ''" style="color: rgb(var(--color-text));">
<i :class="item.level === 0 ? 'fa-solid fa-folder-open text-amber-500' : 'fa-solid fa-chevron-right text-xs opacity-30'"></i>
<span x-text="item.DisciplineName"></span>
</div>
</td>
<td class="font-mono text-sm" :class="{'opacity-70': item.level === 1}" x-text="item.DisciplineCode"></td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editDiscipline(item.DisciplineID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(item)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Include Form Dialog -->
<?= $this->include('v2/master/organization/discipline_dialog') ?>
<!-- Delete Confirmation Modal -->
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete discipline <strong x-text="deleteTarget?.DisciplineName"></strong>?
This action cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteDiscipline()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function disciplines() {
return {
// State
loading: false,
list: [],
flatList: [],
sitesList: [],
keyword: "",
// Get flattened list with level indicators for table rendering
get flatListWithLevels() {
const flat = [];
this.list.forEach(parent => {
flat.push({ ...parent, level: 0 });
if (parent.children && parent.children.length > 0) {
parent.children.forEach(child => {
flat.push({ ...child, level: 1, ParentID: parent.DisciplineID });
});
}
});
return flat;
},
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
DisciplineID: null,
DisciplineCode: "",
DisciplineName: "",
SiteID: "",
Parent: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchList();
await this.fetchSites();
},
// Fetch discipline list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('DisciplineName', this.keyword);
const res = await fetch(`${BASEURL}api/organization/discipline?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
// Build flat list for parent selection dropdown
const flat = [];
this.list.forEach(p => {
flat.push({ DisciplineID: p.DisciplineID, DisciplineName: p.DisciplineName });
if (p.children && p.children.length > 0) {
p.children.forEach(c => {
flat.push({ DisciplineID: c.DisciplineID, DisciplineName: `— ${c.DisciplineName}` });
});
}
});
this.flatList = flat;
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Fetch site list for dropdown
async fetchSites() {
try {
const res = await fetch(`${BASEURL}api/organization/site`, {
credentials: 'include'
});
const data = await res.json();
this.sitesList = data.data || [];
} catch (err) {
console.error('Failed to fetch sites:', err);
}
},
// Show form for new discipline
showForm() {
this.isEditing = false;
this.form = {
DisciplineID: null,
DisciplineCode: "",
DisciplineName: "",
SiteID: "",
Parent: ""
};
this.errors = {};
this.showModal = true;
},
// Edit discipline
async editDiscipline(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/organization/discipline/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load discipline data');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.DisciplineName?.trim()) e.DisciplineName = "Discipline name is required";
if (!this.form.DisciplineCode?.trim()) e.DisciplineCode = "Discipline code is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save discipline
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PATCH' : 'POST';
const res = await fetch(`${BASEURL}api/organization/discipline`, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save discipline");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(discipline) {
this.deleteTarget = discipline;
this.showDeleteModal = true;
},
// Delete discipline
async deleteDiscipline() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/organization/discipline`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ DisciplineID: this.deleteTarget.DisciplineID }),
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
alert("Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete discipline");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,119 @@
<!-- Site Form Modal -->
<div
x-show="showModal"
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"
>
<!-- 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-hospital-user" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Site' : 'New Site'"></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>
<label class="label">
<span class="label-text font-medium">Site Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.SiteName && 'input-error'"
x-model="form.SiteName"
placeholder="Main Hospital Site"
/>
<label class="label" x-show="errors.SiteName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.SiteName"></span>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Site Code <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input font-mono"
:class="errors.SiteCode && 'input-error'"
x-model="form.SiteCode"
placeholder="SITE-01"
/>
<label class="label" x-show="errors.SiteCode">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.SiteCode"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Account</span>
</label>
<select class="select" x-model="form.AccountID">
<option value="">Select Account</option>
<template x-for="acc in accountsList" :key="acc.AccountID">
<option :value="acc.AccountID" x-text="acc.AccountName"></option>
</template>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Parent Site</span>
</label>
<select class="select" x-model="form.Parent">
<option value="">None</option>
<template x-for="s in list" :key="s.SiteID">
<option :value="s.SiteID" x-text="s.SiteName" :disabled="s.SiteID == form.SiteID"></option>
</template>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium">ME (Medical Examiner?)</span>
</label>
<input
type="text"
class="input"
x-model="form.ME"
/>
</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-primary 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 Site'"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,332 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="sites()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-hospital text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Organization Sites</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage physical sites and locations</p>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search sites..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Site
</button>
</div>
</div>
</div>
<!-- Content Card -->
<div class="card overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading sites...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Site Name</th>
<th>Code</th>
<th>Account</th>
<th>Parent Site</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
<p class="text-lg">No sites found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Site
</button>
</div>
</td>
</tr>
</template>
<!-- Site Rows -->
<template x-for="site in list" :key="site.SiteID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="site.SiteID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="site.SiteName || '-'"></div>
</td>
<td x-text="site.SiteCode || '-'"></td>
<td x-text="site.AccountName || '-'"></td>
<td x-text="site.ParentName || '-'"></td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editSite(site.SiteID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(site)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Summary -->
<div class="p-4 flex items-center justify-between" style="border-top: 1px solid rgb(var(--color-border));" x-show="list && list.length > 0">
<span class="text-sm" style="color: rgb(var(--color-text-muted));" x-text="'Showing ' + list.length + ' sites'"></span>
</div>
</div>
<!-- Include Form Dialog -->
<?= $this->include('v2/master/organization/site_dialog') ?>
<!-- Delete Confirmation Modal -->
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete site <strong x-text="deleteTarget?.SiteName"></strong>?
This action cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteSite()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function sites() {
return {
// State
loading: false,
list: [],
accountsList: [],
keyword: "",
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
SiteID: null,
SiteCode: "",
SiteName: "",
AccountID: "",
Parent: "",
ME: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchList();
await this.fetchAccounts();
},
// Fetch site list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('SiteName', this.keyword);
const res = await fetch(`${BASEURL}api/organization/site?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Fetch account list for dropdown
async fetchAccounts() {
try {
const res = await fetch(`${BASEURL}api/organization/account`, {
credentials: 'include'
});
const data = await res.json();
this.accountsList = data.data || [];
} catch (err) {
console.error('Failed to fetch accounts:', err);
}
},
// Show form for new site
showForm() {
this.isEditing = false;
this.form = {
SiteID: null,
SiteCode: "",
SiteName: "",
AccountID: "",
Parent: "",
ME: ""
};
this.errors = {};
this.showModal = true;
},
// Edit site
async editSite(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/organization/site/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load site data');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.SiteName?.trim()) e.SiteName = "Site name is required";
if (!this.form.SiteCode?.trim()) e.SiteCode = "Site code is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save site
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PATCH' : 'POST';
const res = await fetch(`${BASEURL}api/organization/site`, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save site");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(site) {
this.deleteTarget = site;
this.showDeleteModal = true;
},
// Delete site
async deleteSite() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/organization/site`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ SiteID: this.deleteTarget.SiteID }),
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
alert("Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete site");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,129 @@
<!-- Workstation Form Modal -->
<div
x-show="showModal"
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-xl 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-desktop" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Workstation' : 'New Workstation'"></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>
<label class="label">
<span class="label-text font-medium">Workstation Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.WorkstationName && 'input-error'"
x-model="form.WorkstationName"
placeholder="Chemistry Analyzer 1"
/>
<label class="label" x-show="errors.WorkstationName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.WorkstationName"></span>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Workstation Code <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input font-mono"
:class="errors.WorkstationCode && 'input-error'"
x-model="form.WorkstationCode"
placeholder="WS-CH-01"
/>
<label class="label" x-show="errors.WorkstationCode">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.WorkstationCode"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Department</span>
</label>
<select class="select" x-model="form.DepartmentID">
<option value="">Select Department</option>
<template x-for="d in departmentsList" :key="d.DepartmentID">
<option :value="d.DepartmentID" x-text="d.DepartmentName"></option>
</template>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Type</span>
</label>
<select class="select" x-model="form.Type">
<option value="">Select Type</option>
<option value="1">Manual</option>
<option value="2">Automated</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Status</span>
</label>
<select class="select" x-model="form.Enable">
<option value="1">Enabled</option>
<option value="0">Disabled</option>
</select>
</div>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Link To Workstation</span>
</label>
<select class="select" x-model="form.LinkTo">
<option value="">None</option>
<template x-for="w in list" :key="w.WorkstationID">
<option :value="w.WorkstationID" x-text="w.WorkstationName" :disabled="w.WorkstationID == form.WorkstationID"></option>
</template>
</select>
</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-primary 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 Workstation'"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,330 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="workstations()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-600 to-purple-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-desktop text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Organization Workstations</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage lab workstations and equipment units</p>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search workstations..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Workstation
</button>
</div>
</div>
</div>
<!-- Content Card -->
<div class="card overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading workstations...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Workstation Name</th>
<th>Code</th>
<th>Department</th>
<th>Status</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
<p class="text-lg">No workstations found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Workstation
</button>
</div>
</td>
</tr>
</template>
<!-- Workstation Rows -->
<template x-for="ws in list" :key="ws.WorkstationID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="ws.WorkstationID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="ws.WorkstationName || '-'"></div>
</td>
<td class="font-mono text-sm" x-text="ws.WorkstationCode || '-'"></td>
<td x-text="ws.DepartmentName || '-'"></td>
<td>
<span class="badge badge-sm" :class="ws.Enable == 1 ? 'badge-success' : 'badge-ghost'" x-text="ws.Enable == 1 ? 'Active' : 'Disabled'"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editWorkstation(ws.WorkstationID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(ws)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Include Form Dialog -->
<?= $this->include('v2/master/organization/workstation_dialog') ?>
<!-- Delete Confirmation Modal -->
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete workstation <strong x-text="deleteTarget?.WorkstationName"></strong>?
This action cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteWorkstation()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function workstations() {
return {
// State
loading: false,
list: [],
departmentsList: [],
keyword: "",
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
WorkstationID: null,
WorkstationCode: "",
WorkstationName: "",
DepartmentID: "",
Type: "",
Enable: 1,
LinkTo: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchList();
await this.fetchDepartments();
},
// Fetch workstation list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('WorkstationName', this.keyword);
const res = await fetch(`${BASEURL}api/organization/workstation?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Fetch department list for dropdown
async fetchDepartments() {
try {
const res = await fetch(`${BASEURL}api/organization/department`, {
credentials: 'include'
});
const data = await res.json();
this.departmentsList = data.data || [];
} catch (err) {
console.error('Failed to fetch departments:', err);
}
},
// Show form for new workstation
showForm() {
this.isEditing = false;
this.form = {
WorkstationID: null,
WorkstationCode: "",
WorkstationName: "",
DepartmentID: "",
Type: "",
Enable: 1,
LinkTo: ""
};
this.errors = {};
this.showModal = true;
},
// Edit workstation
async editWorkstation(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/organization/workstation/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load workstation data');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.WorkstationName?.trim()) e.WorkstationName = "Workstation name is required";
if (!this.form.WorkstationCode?.trim()) e.WorkstationCode = "Workstation code is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save workstation
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PATCH' : 'POST';
const res = await fetch(`${BASEURL}api/organization/workstation`, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save workstation");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(ws) {
this.deleteTarget = ws;
this.showDeleteModal = true;
},
// Delete workstation
async deleteWorkstation() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/organization/workstation`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ WorkstationID: this.deleteTarget.WorkstationID }),
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
alert("Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete workstation");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,143 @@
<!-- Container Form Modal -->
<div
x-show="showModal"
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-xl 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-vial" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Container' : 'New Container'"></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>
<label class="label">
<span class="label-text font-medium">Container Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.ConName && 'input-error'"
x-model="form.ConName"
placeholder="Gold Top Tube"
/>
<label class="label" x-show="errors.ConName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.ConName"></span>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Container Code <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input font-mono"
:class="errors.ConCode && 'input-error'"
x-model="form.ConCode"
placeholder="GTT-10"
/>
<label class="label" x-show="errors.ConCode">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.ConCode"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Cap Color</span>
</label>
<input
type="text"
class="input"
x-model="form.Color"
placeholder="e.g. Gold, Red, Lavender"
/>
</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.ConDesc"
placeholder="Tube description and usage notes..."
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Additive</span>
</label>
<input
type="text"
class="input"
x-model="form.Additive"
placeholder="SST / EDTA / Heparin"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Class</span>
</label>
<input
type="text"
class="input"
x-model="form.ConClass"
placeholder="Tube / Swab"
/>
</div>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Site</span>
</label>
<select class="select" x-model="form.SiteID">
<option value="">Select Site</option>
<template x-for="s in sitesList" :key="s.SiteID">
<option :value="s.SiteID" x-text="s.SiteName"></option>
</template>
</select>
</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-primary 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 Container'"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,335 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="containers()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-pink-600 to-pink-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-flask-vial text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Container Definitions</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage specimen collection containers and tubes</p>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search containers..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Container
</button>
</div>
</div>
</div>
<!-- Content Card -->
<div class="card overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading containers...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Container Name</th>
<th>Code</th>
<th>Color</th>
<th>Additive</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
<p class="text-lg">No containers found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Container
</button>
</div>
</td>
</tr>
</template>
<!-- Container Rows -->
<template x-for="con in list" :key="con.ConDefID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="con.ConDefID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="con.ConName || '-'"></div>
</td>
<td class="font-mono text-sm" x-text="con.ConCode || '-'"></td>
<td>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full border border-slate-300" :style="`background-color: ${con.Color || 'transparent'}`"></div>
<span x-text="con.ColorTxt || con.Color || '-'"></span>
</div>
</td>
<td x-text="con.AdditiveTxt || con.Additive || '-'"></td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editContainer(con.ConDefID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(con)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Include Form Dialog -->
<?= $this->include('v2/master/specimen/container_dialog') ?>
<!-- Delete Confirmation Modal -->
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete container <strong x-text="deleteTarget?.ConName"></strong>?
This action cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteContainer()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function containers() {
return {
// State
loading: false,
list: [],
sitesList: [],
keyword: "",
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
ConDefID: null,
ConCode: "",
ConName: "",
ConDesc: "",
Additive: "",
ConClass: "",
Color: "",
SiteID: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchList();
await this.fetchSites();
},
// Fetch container list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('ConName', this.keyword);
const res = await fetch(`${BASEURL}api/specimen/container?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Fetch site list for dropdown
async fetchSites() {
try {
const res = await fetch(`${BASEURL}api/organization/site`, {
credentials: 'include'
});
const data = await res.json();
this.sitesList = data.data || [];
} catch (err) {
console.error('Failed to fetch sites:', err);
}
},
// Show form for new container
showForm() {
this.isEditing = false;
this.form = {
ConDefID: null,
ConCode: "",
ConName: "",
ConDesc: "",
Additive: "",
ConClass: "",
Color: "",
SiteID: ""
};
this.errors = {};
this.showModal = true;
},
// Edit container
async editContainer(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/specimen/container/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load container data');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.ConName?.trim()) e.ConName = "Container name is required";
if (!this.form.ConCode?.trim()) e.ConCode = "Container code is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save container
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PATCH' : 'POST';
const res = await fetch(`${BASEURL}api/specimen/container`, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save container");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(con) {
this.deleteTarget = con;
this.showDeleteModal = true;
},
// Delete container
async deleteContainer() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/specimen/container`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ConDefID: this.deleteTarget.ConDefID }),
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
alert("Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete container");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,138 @@
<!-- Specimen Prep Form Modal -->
<div
x-show="showModal"
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-xl 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-mortar-pestle" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Preparation' : 'New Preparation'"></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>
<label class="label">
<span class="label-text font-medium">Description <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.Description && 'input-error'"
x-model="form.Description"
placeholder="Centrifugation 3000rpm"
/>
<label class="label" x-show="errors.Description">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.Description"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Method</span>
</label>
<input
type="text"
class="input"
x-model="form.Method"
placeholder="Centrifuge / Aliqout / Heat"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Additive</span>
</label>
<input
type="text"
class="input"
x-model="form.Additive"
placeholder="None"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="label">
<span class="label-text font-medium">Qty</span>
</label>
<input
type="number"
class="input text-center"
x-model="form.AddQty"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Unit</span>
</label>
<input
type="text"
class="input text-center"
x-model="form.AddUnit"
placeholder="ml"
/>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Preparation Start</span>
</label>
<input
type="datetime-local"
class="input"
x-model="form.PrepStart"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Preparation End</span>
</label>
<input
type="datetime-local"
class="input"
x-model="form.PrepEnd"
/>
</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-primary 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 Preparation'"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,317 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="preparations()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-orange-600 to-orange-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-mortar-pestle text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Specimen Preparations</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage specimen processing and preparation methods</p>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search preparations..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Preparation
</button>
</div>
</div>
</div>
<!-- Content Card -->
<div class="card overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading preparations...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Description</th>
<th>Method</th>
<th>Additive</th>
<th>Qty/Unit</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
<p class="text-lg">No preparations found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Preparation
</button>
</div>
</td>
</tr>
</template>
<!-- Prep Rows -->
<template x-for="prep in list" :key="prep.SpcPrpID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="prep.SpcPrpID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="prep.Description || '-'"></div>
</td>
<td x-text="prep.Method || '-'"></td>
<td x-text="prep.Additive || '-'"></td>
<td>
<span x-text="prep.AddQty || '-'"></span>
<span class="text-xs opacity-60" x-text="prep.AddUnit"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editPrep(prep.SpcPrpID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(prep)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Include Form Dialog -->
<?= $this->include('v2/master/specimen/preparation_dialog') ?>
<!-- Delete Confirmation Modal -->
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete preparation <strong x-text="deleteTarget?.Description"></strong>?
This action cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deletePrep()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function preparations() {
return {
// State
loading: false,
list: [],
keyword: "",
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
SpcPrpID: null,
Description: "",
Method: "",
Additive: "",
AddQty: "",
AddUnit: "",
PrepStart: "",
PrepEnd: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchList();
},
// Fetch prep list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('Description', this.keyword);
const res = await fetch(`${BASEURL}api/specimen/preparation?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Show form for new prep
showForm() {
this.isEditing = false;
this.form = {
SpcPrpID: null,
Description: "",
Method: "",
Additive: "",
AddQty: "",
AddUnit: "",
PrepStart: "",
PrepEnd: ""
};
this.errors = {};
this.showModal = true;
},
// Edit prep
async editPrep(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/specimen/preparation/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load prep data');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.Description?.trim()) e.Description = "Description is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save prep
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PATCH' : 'POST';
const res = await fetch(`${BASEURL}api/specimen/preparation`, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save preparation");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(prep) {
this.deleteTarget = prep;
this.showDeleteModal = true;
},
// Delete prep
async deletePrep() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/specimen/preparation`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ SpcPrpID: this.deleteTarget.SpcPrpID }),
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
alert("Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete preparation");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,364 @@
<!-- 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-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-cog mr-1"></i> Config
</button>
</div>
<!-- Form -->
<div class="space-y-4">
<!-- 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">
<!-- 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 text-sm">Description</span>
</label>
<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>
<label class="label">
<span class="label-text font-medium text-sm">Options</span>
</label>
<div class="flex flex-wrap gap-3 mt-1">
<label class="label cursor-pointer justify-start gap-1 px-0">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.CountStat" :true-value="1"
:false-value="0" />
<span class="label-text text-xs">Count Stat</span>
</label>
<label class="label cursor-pointer justify-start gap-1 px-0">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleScr" :true-value="1"
:false-value="0" />
<span class="label-text text-xs">Screen</span>
</label>
<label class="label cursor-pointer justify-start gap-1 px-0">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleRpt" :true-value="1"
:false-value="0" />
<span class="label-text text-xs">Report</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 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 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>
<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>
<!-- 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>
<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>
<!-- 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>
<!-- Reference Range Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-ruler-combined text-amber-500"></i> Reference Range
</h4>
<div class="grid grid-cols-4 gap-3">
<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>
<!-- Numeric Reference Fields -->
<div class="grid grid-cols-4 gap-3 mt-3" x-show="form.RefType === 'NMRC' || form.RefType === 'AGE' || form.RefType === 'GENDER'">
<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>
<!-- Text Reference Field -->
<div class="mt-3" x-show="form.RefType === 'TEXT'">
<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., Negative, Positive, etc." />
</div>
</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-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...' : (isEditing ? 'Update Calculated Test' : 'Create Calculated Test')"></span>
</button>
</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

@ -0,0 +1,386 @@
<!-- 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-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-cog mr-1"></i> Config
</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">
<!-- 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>
<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>
<label class="label">
<span class="label-text font-medium text-sm">Options</span>
</label>
<div class="flex flex-wrap gap-3 mt-1">
<label class="label cursor-pointer justify-start gap-1 px-0">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.CountStat" :true-value="1"
:false-value="0" />
<span class="label-text text-xs">Count Stat</span>
</label>
<label class="label cursor-pointer justify-start gap-1 px-0">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleScr" :true-value="1"
:false-value="0" />
<span class="label-text text-xs">Screen</span>
</label>
<label class="label cursor-pointer justify-start gap-1 px-0">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleRpt" :true-value="1"
:false-value="0" />
<span class="label-text text-xs">Report</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 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">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>
<!-- Reference Range Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-ruler-combined text-emerald-500"></i> Reference Range
</h4>
<div class="grid grid-cols-4 gap-3">
<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>
<!-- Numeric Reference Fields -->
<div class="grid grid-cols-4 gap-3 mt-3" x-show="form.RefType === 'NMRC' || form.RefType === 'AGE' || form.RefType === 'GENDER'">
<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>
<!-- Text Reference Field -->
<div class="mt-3" x-show="form.RefType === 'TEXT'">
<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., Negative, Positive, etc." />
</div>
</div>
</div>
<!-- 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-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...' : (isEditing ? 'Update Parameter' : 'Create Parameter')"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,344 @@
<!-- 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-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>
<!-- 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="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-cog mr-1"></i> Config
</button>
</div>
<!-- Form -->
<div 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>
<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>
<label class="label">
<span class="label-text font-medium text-sm">Options</span>
</label>
<div class="flex flex-wrap gap-3 mt-1">
<label class="label cursor-pointer justify-start gap-1 px-0">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.CountStat" :true-value="1"
:false-value="0" />
<span class="label-text text-xs">Count Stat</span>
</label>
<label class="label cursor-pointer justify-start gap-1 px-0">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleScr" :true-value="1"
:false-value="0" />
<span class="label-text text-xs">Screen</span>
</label>
<label class="label cursor-pointer justify-start gap-1 px-0">
<input type="checkbox" class="checkbox checkbox-sm" x-model="form.VisibleRpt" :true-value="1"
:false-value="0" />
<span class="label-text text-xs">Report</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 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">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>
<!-- Reference Range Section -->
<div class="mb-4">
<h4 class="font-medium text-sm mb-3 flex items-center gap-2">
<i class="fa-solid fa-ruler-combined text-indigo-500"></i> Reference Range
</h4>
<div class="grid grid-cols-4 gap-3">
<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>
<!-- Numeric Reference Fields -->
<div class="grid grid-cols-4 gap-3 mt-3" x-show="form.RefType === 'NMRC' || form.RefType === 'AGE' || form.RefType === 'GENDER'">
<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>
<!-- Text Reference Field -->
<div class="mt-3" x-show="form.RefType === 'TEXT'">
<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., Negative, Positive, etc." />
</div>
</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 Test' : 'Create Test')"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,780 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="tests()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div
class="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-600 to-indigo-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-microscope text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Laboratory Tests</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage test definitions, parameters, and groups
</p>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input type="text" placeholder="Search tests..." class="input flex-1 sm:w-80" x-model="keyword"
@keyup.enter="fetchList()" />
<select class="select w-40" x-model="filterType" @change="fetchList()">
<option value="">All Types</option>
<template x-for="(type, index) in (testTypes || [])" :key="(type?.VID ?? index)">
<option :value="type?.VID" x-text="(type?.VValue || '') + ' - ' + (type?.VDesc || '')"></option>
</template>
</select>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Test
</button>
</div>
</div>
</div>
<!-- Content Card -->
<div class="card overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading tests...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Code</th>
<th>Test Name</th>
<th>Type</th>
<th>Seq</th>
<th>Visible</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="7" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
<p class="text-lg">No tests found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Test
</button>
</div>
</td>
</tr>
</template>
<!-- Test Rows -->
<template x-for="test in list" :key="test.TestSiteID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="test.TestSiteID || '-'"></span>
</td>
<td>
<code class="text-sm font-mono px-2 py-1 rounded"
style="background: rgb(var(--color-bg-secondary)); color: rgb(var(--color-primary));"
x-text="test.TestSiteCode || '-'"></code>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="test.TestSiteName || '-'"></div>
<div class="text-xs mt-1" style="color: rgb(var(--color-text-muted));" x-text="test.Description || ''">
</div>
</td>
<td>
<span class="badge" :class="{
'badge-info': test.TypeCode === 'TEST',
'badge-success': test.TypeCode === 'PARAM',
'badge-warning': test.TypeCode === 'CALC',
'badge-primary': test.TypeCode === 'GROUP',
'badge-secondary': test.TypeCode === 'TITLE'
}" x-text="test.TypeName || test.TypeCode || '-'"></span>
</td>
<td x-text="test.SeqScr || '-'"></td>
<td>
<div class="flex gap-1">
<span class="badge badge-ghost badge-xs" title="Screen" x-show="test.VisibleScr == 1">
<i class="fa-solid fa-desktop text-green-500"></i>
</span>
<span class="badge badge-ghost badge-xs" title="Report" x-show="test.VisibleRpt == 1">
<i class="fa-solid fa-file-alt text-blue-500"></i>
</span>
</div>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="viewTest(test.TestSiteID)"
title="View Details">
<i class="fa-solid fa-eye text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="editTest(test.TestSiteID)" title="Edit">
<i class="fa-solid fa-pen text-indigo-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(test)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Summary -->
<div class="p-4 flex items-center justify-between" style="border-top: 1px solid rgb(var(--color-border));"
x-show="list && list.length > 0">
<span class="text-sm" style="color: rgb(var(--color-text-muted));"
x-text="'Showing ' + list.length + ' tests'"></span>
</div>
</div>
<!-- Include Form Dialogs -->
<?= $this->include('v2/master/tests/test_dialog') ?>
<?= $this->include('v2/master/tests/param_dialog') ?>
<?= $this->include('v2/master/tests/calc_dialog') ?>
<?= $this->include('v2/master/tests/grp_dialog') ?>
<!-- View Details Modal -->
<div x-show="showViewModal" x-cloak class="modal-overlay" @click.self="showViewModal = false">
<div class="modal-content p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto"
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">
<!-- 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="viewData?.TestSiteName || 'Test Details'"></span>
</h3>
<button class="btn btn-ghost btn-sm btn-square" @click="showViewModal = false">
<i class="fa-solid fa-times"></i>
</button>
</div>
<!-- Details Content -->
<template x-if="viewData">
<div class="space-y-6">
<!-- Basic Info -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Code</div>
<div class="font-mono font-semibold" x-text="viewData.TestSiteCode || '-'"></div>
</div>
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Type</div>
<span class="badge" :class="{
'badge-info': viewData.TypeCode === 'TEST',
'badge-success': viewData.TypeCode === 'PARAM',
'badge-warning': viewData.TypeCode === 'CALC',
'badge-primary': viewData.TypeCode === 'GROUP',
'badge-secondary': viewData.TypeCode === 'TITLE'
}" x-text="viewData.TypeName || viewData.TypeCode || '-'"></span>
</div>
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Sequence (Screen)</div>
<div class="font-semibold" x-text="viewData.SeqScr || '-'"></div>
</div>
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Sequence (Report)</div>
<div class="font-semibold" x-text="viewData.SeqRpt || '-'"></div>
</div>
</div>
<!-- Type-specific Details -->
<template x-if="viewData.TypeCode === 'TEST' || viewData.TypeCode === 'PARAM'">
<div>
<h4 class="font-semibold mb-3 flex items-center gap-2">
<i class="fa-solid fa-flask"></i> Technical Details
</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Result Type</div>
<div x-text="viewData.testdeftech?.[0]?.ResultType || '-'"></div>
</div>
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Unit</div>
<div x-text="viewData.testdeftech?.[0]?.Unit1 || '-'"></div>
</div>
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Method</div>
<div x-text="viewData.testdeftech?.[0]?.Method || '-'"></div>
</div>
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Expected TAT</div>
<div x-text="(viewData.testdeftech?.[0]?.ExpectedTAT || '-') + ' min'"></div>
</div>
</div>
</div>
</template>
<template x-if="viewData.TypeCode === 'CALC'">
<div>
<h4 class="font-semibold mb-3 flex items-center gap-2">
<i class="fa-solid fa-calculator"></i> Calculation Details
</h4>
<div class="grid grid-cols-2 gap-4">
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Formula Input</div>
<div class="font-mono text-sm" x-text="viewData.testdefcal?.[0]?.FormulaInput || '-'"></div>
</div>
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Formula Code</div>
<div class="font-mono text-sm" x-text="viewData.testdefcal?.[0]?.FormulaCode || '-'"></div>
</div>
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Result Unit</div>
<div x-text="viewData.testdefcal?.[0]?.Unit1 || '-'"></div>
</div>
<div class="p-3 rounded-lg" style="background: rgb(var(--color-bg-secondary));">
<div class="text-xs" style="color: rgb(var(--color-text-muted));">Decimal</div>
<div x-text="viewData.testdefcal?.[0]?.Decimal || '-'"></div>
</div>
</div>
</div>
</template>
<template x-if="viewData.TypeCode === 'GROUP'">
<div>
<h4 class="font-semibold mb-3 flex items-center gap-2">
<i class="fa-solid fa-layer-group"></i> Group Members
</h4>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Type</th>
</tr>
</thead>
<tbody>
<template x-for="member in (viewData.testdefgrp || [])" :key="member.TestGrpID">
<tr>
<td><code 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"></span>
</td>
</tr>
</template>
<template x-if="!viewData.testdefgrp || viewData.testdefgrp.length === 0">
<tr>
<td colspan="3" class="text-center text-muted">No members</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<!-- Test Mappings -->
<template x-if="viewData.testmap && viewData.testmap.length > 0">
<div>
<h4 class="font-semibold mb-3 flex items-center gap-2">
<i class="fa-solid fa-link"></i> Test Mappings
</h4>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Host Type</th>
<th>Host Code</th>
<th>Client Type</th>
<th>Client Code</th>
</tr>
</thead>
<tbody>
<template x-for="map in viewData.testmap" :key="map.TestMapID">
<tr>
<td x-text="map.HostType || '-'"></td>
<td x-text="map.HostTestCode || '-'"></td>
<td x-text="map.ClientType || '-'"></td>
<td><code x-text="map.ClientTestCode || '-'"></code></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
<!-- 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="showViewModal = false">Close</button>
<button class="btn btn-primary flex-1" @click="editTest(viewData?.TestSiteID)">
<i class="fa-solid fa-edit mr-2"></i> Edit Test
</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="showDeleteModal" x-cloak class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete test <strong x-text="deleteTarget?.TestSiteName"></strong>?
This action cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteTest()"
:disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function tests() {
return {
// State
loading: false,
list: [],
testTypes: [],
keyword: "",
filterType: "",
// View Modal
showViewModal: false,
viewData: null,
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
activeTab: 'basic',
// Member Selector
showMemberSelector: false,
memberSearch: '',
availableTests: [],
form: {
TestSiteID: null,
SiteID: 1,
TestSiteCode: "",
TestSiteName: "",
TestType: "",
Description: "",
SeqScr: 0,
SeqRpt: 0,
IndentLeft: 0,
VisibleScr: 1,
VisibleRpt: 1,
CountStat: 1,
// Technical fields
DisciplineID: "",
DepartmentID: "",
ResultType: "",
RefType: "",
Unit1: "",
Unit2: "",
Decimal: 2,
Method: "",
SampleType: "",
ExpectedTAT: 60,
RefMin: null,
RefMax: null,
RefText: "",
CriticalLow: null,
CriticalHigh: null,
// Calculation fields
FormulaInput: "",
FormulaCode: "",
// Value Set fields
ValueSetID: "",
DefaultValue: "",
// Group members
groupMembers: []
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchTestTypes();
await this.fetchAvailableTests();
// Small delay to ensure Alpine has processed testTypes
setTimeout(() => {
this.fetchList();
}, 50);
},
// Fetch test types from valueset
async fetchTestTypes() {
try {
const res = await fetch(`${BASEURL}api/valuesetdef/27`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.testTypes = data.data || [];
} catch (err) {
console.error('Failed to fetch test types:', err);
// Fallback to hardcoded types
this.testTypes = [
{ VID: 1, VValue: 'TEST', VDesc: 'Test' },
{ VID: 2, VValue: 'PARAM', VDesc: 'Parameter' },
{ VID: 3, VValue: 'CALC', VDesc: 'Calculated' },
{ VID: 4, VValue: 'GROUP', VDesc: 'Group' },
{ VID: 5, VValue: 'TITLE', VDesc: 'Title' }
];
}
},
// Fetch available tests for group members
async fetchAvailableTests() {
try {
const res = await fetch(`${BASEURL}api/tests`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.availableTests = (data.data || []).filter(t =>
t.TypeCode === 'TEST' || t.TypeCode === 'PARAM'
);
} catch (err) {
console.error('Failed to fetch available tests:', err);
this.availableTests = [];
}
},
// Get type display name
getTypeName(vid) {
const code = this.getTypeCode(vid);
const typeMap = {
'TEST': 'Test',
'PARAM': 'Parameter',
'CALC': 'Calculated Test',
'GROUP': 'Group',
'TITLE': 'Title'
};
return typeMap[code] || 'Test';
},
// Fetch test list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('TestSiteName', this.keyword);
if (this.filterType) params.append('TestType', this.filterType);
const res = await fetch(`${BASEURL}api/tests?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// View test details
async viewTest(id) {
this.viewData = null;
try {
const res = await fetch(`${BASEURL}api/tests/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.viewData = data.data;
this.showViewModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load test details');
}
},
// Show form for new test
showForm() {
this.isEditing = false;
this.activeTab = 'basic';
this.form = {
TestSiteID: null,
SiteID: 1,
TestSiteCode: "",
TestSiteName: "",
TestType: "",
Description: "",
SeqScr: 0,
SeqRpt: 0,
IndentLeft: 0,
VisibleScr: 1,
VisibleRpt: 1,
CountStat: 1,
DisciplineID: "",
DepartmentID: "",
ResultType: "",
RefType: "",
Unit1: "",
Unit2: "",
Decimal: 2,
Method: "",
SampleType: "",
ExpectedTAT: 60,
RefMin: null,
RefMax: null,
RefText: "",
CriticalLow: null,
CriticalHigh: null,
FormulaInput: "",
FormulaCode: "",
ValueSetID: "",
DefaultValue: "",
groupMembers: []
};
this.errors = {};
this.showModal = true;
},
// Edit test
async editTest(id) {
this.isEditing = true;
this.errors = {};
this.activeTab = 'basic';
try {
const res = await fetch(`${BASEURL}api/tests/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
const testData = data.data;
// Map TypeCode to TestType (VID) for proper dialog display
const typeCode = testData.TypeCode || '';
let testTypeVid = testData.TestType || null;
// Convert TypeCode string to VID if needed
if (!testTypeVid && typeCode) {
const typeMap = { 'TEST': 1, 'PARAM': 2, 'CALC': 3, 'GROUP': 4, 'TITLE': 5 };
testTypeVid = typeMap[typeCode] || null;
}
this.form = {
...this.form,
...testData,
TestType: testTypeVid,
// Store TypeCode directly for dialog display
TypeCode: typeCode,
// Preserve group members if editing group
groupMembers: testData.testdefgrp || []
};
this.showModal = true;
this.showViewModal = false;
}
} catch (err) {
console.error(err);
alert('Failed to load test data');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.TestSiteName?.trim()) e.TestSiteName = "Test name is required";
if (!this.form.TestSiteCode?.trim()) e.TestSiteCode = "Test code is required";
if (!this.form.TestType) e.TestType = "Test type is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.showMemberSelector = false;
this.errors = {};
this.memberSearch = '';
},
// Toggle group member
toggleMember(test) {
if (!this.form.groupMembers) {
this.form.groupMembers = [];
}
const index = this.form.groupMembers.findIndex(m => m.TestSiteID === test.TestSiteID);
if (index > -1) {
this.form.groupMembers.splice(index, 1);
} else {
this.form.groupMembers.push({
TestSiteID: test.TestSiteID,
TestSiteCode: test.TestSiteCode,
TestSiteName: test.TestSiteName,
MemberTypeCode: test.TypeCode || 'TEST',
SeqScr: 0
});
}
this.$forceUpdate();
},
// Remove group member
removeMember(index) {
this.form.groupMembers.splice(index, 1);
this.$forceUpdate();
},
// Add common member quickly
addCommonMember(code, type) {
const test = this.availableTests.find(t => t.TestSiteCode === code);
if (test) {
if (!this.form.groupMembers) {
this.form.groupMembers = [];
}
const exists = this.form.groupMembers.some(m => m.TestSiteID === test.TestSiteID);
if (!exists) {
this.form.groupMembers.push({
TestSiteID: test.TestSiteID,
TestSiteCode: test.TestSiteCode,
TestSiteName: test.TestSiteName,
MemberTypeCode: type,
SeqScr: this.form.groupMembers.length + 1
});
this.$forceUpdate();
}
} else {
// Add as custom entry if not found
if (!this.form.groupMembers) {
this.form.groupMembers = [];
}
this.form.groupMembers.push({
TestSiteID: null,
TestSiteCode: code,
TestSiteName: code,
MemberTypeCode: type,
SeqScr: this.form.groupMembers.length + 1
});
this.$forceUpdate();
}
},
// Save test
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PATCH' : 'POST';
const payload = { ...this.form };
// Handle group members for GROUP type
if (this.getTypeCode(this.form.TestType) === 'GROUP' && this.form.groupMembers?.length > 0) {
payload.groupMembers = this.form.groupMembers;
}
const res = await fetch(`${BASEURL}api/tests`, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save test");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(test) {
this.deleteTarget = test;
this.showDeleteModal = true;
},
// Delete test
async deleteTest() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/tests`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ TestSiteID: this.deleteTarget.TestSiteID }),
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
const data = await res.json();
alert(data.message || "Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete test");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
},
// Get type code from VID (with fallback and null safety)
getTypeCode(vid) {
if (!vid) return '';
// First try to find in loaded testTypes
if (this.testTypes && Array.isArray(this.testTypes) && this.testTypes.length > 0) {
const type = this.testTypes.find(t => t && t.VID === vid);
if (type && type.VValue) return type.VValue;
}
// Fallback to hardcoded mapping
const typeMap = { 1: 'TEST', 2: 'PARAM', 3: 'CALC', 4: 'GROUP', 5: 'TITLE' };
return typeMap[vid] || '';
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,156 @@
<!-- Value Set Item Form Modal -->
<div
x-show="showModal"
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"
>
<!-- 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-list-plus" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Item' : 'New Item'"></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-6">
<!-- General Error -->
<div x-show="errors.general" class="p-4 rounded-lg bg-rose-50 border border-rose-200" style="display: none;">
<div class="flex items-center gap-2">
<i class="fa-solid fa-exclamation-triangle text-rose-500"></i>
<p class="text-sm font-medium text-rose-700" x-text="errors.general"></p>
</div>
</div>
<!-- Category Selection (only show if no selectedDef) -->
<div x-show="!selectedDef" class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Category Assignment</h4>
<div>
<label class="label">
<span class="label-text font-medium">Category <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<select class="select" x-model="form.VSetID" :class="errors.VSetID && 'input-error'">
<option value="">Select Category</option>
<template x-for="def in defsList" :key="def.VSetID">
<option :value="def.VSetID" x-text="def.VSName"></option>
</template>
</select>
<label class="label" x-show="errors.VSetID">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.VSetID"></span>
</label>
</div>
</div>
<!-- Basic Information Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Item Details</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Value / Key <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.VValue && 'input-error'"
x-model="form.VValue"
placeholder="e.g. M, F, Active"
/>
<label class="label" x-show="errors.VValue">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.VValue"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Display Order</span>
</label>
<input
type="number"
class="input text-center"
x-model="form.VOrder"
placeholder="0"
min="0"
/>
</div>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Semantic Description</span>
</label>
<textarea
class="input h-24 pt-2"
x-model="form.VDesc"
placeholder="Detailed description or definition of this value..."
></textarea>
</div>
</div>
<!-- System Information Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">System Information</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Item ID</span>
</label>
<input
type="text"
class="input text-center font-mono"
x-model="form.VID"
placeholder="Auto-generated"
readonly
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Site ID</span>
</label>
<input
type="number"
class="input text-center font-mono"
x-model="form.SiteID"
placeholder="1"
readonly
/>
</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-primary 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...' : (isEditing ? 'Update Item' : 'Create Item')"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,362 @@
<!-- Nested ValueSet CRUD Modal -->
<div
x-show="showValueSetModal"
x-cloak
class="modal-overlay"
style="z-index: 1000;"
@click.self="$root.closeValueSetModal()"
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-0 max-w-5xl w-full max-h-[90vh] overflow-hidden"
@click.stop
x-data="valueSetItems()"
x-init="selectedDef = $root.selectedDef; if(selectedDef) { fetchList(1); fetchDefsList(); }"
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="p-6 border-b flex items-center justify-between" style="background: rgb(var(--color-bg)); border-color: rgb(var(--color-border));">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-md" style="background: rgb(var(--color-primary));">
<i class="fa-solid fa-list-ul"></i>
</div>
<div>
<h3 class="font-bold text-lg" style="color: rgb(var(--color-text));" x-text="selectedDef?.VSName || 'Value Items'"></h3>
<p class="text-xs uppercase font-bold opacity-40" style="color: rgb(var(--color-text-muted));">Manage Category Items</p>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i> Add Item
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="$root.closeValueSetModal()">
<i class="fa-solid fa-times"></i>
</button>
</div>
</div>
<!-- Search Bar -->
<div class="p-4 border-b" style="border-color: rgb(var(--color-border));">
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-gray-400"></i>
<input
type="text"
placeholder="Filter items..."
class="input input-sm w-full pl-10"
x-model="keyword"
@keyup.enter="fetchList(1)"
/>
</div>
</div>
<!-- Content Area -->
<div class="overflow-y-auto" style="max-height: calc(90vh - 200px);">
<!-- Loading Overlay -->
<div x-show="loading" class="py-20 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading items...</p>
</div>
<!-- Table Section -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th class="w-20">ID</th>
<th>Value / Key</th>
<th>Definition</th>
<th class="text-center">Order</th>
<th class="text-center w-32">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="5" class="py-20 text-center">
<div class="flex flex-col items-center gap-2 opacity-30" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-4xl"></i>
<p class="font-bold italic">No items found in this category</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Item
</button>
</div>
</td>
</tr>
</template>
<!-- Data Rows -->
<template x-for="v in list" :key="v.VID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="v.VID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="v.VValue || '-'"></div>
</td>
<td>
<span class="text-sm opacity-70" x-text="v.VDesc || '-'"></span>
</td>
<td class="text-center">
<span class="font-mono text-sm" x-text="v.VOrder || 0"></span>
</td>
<td>
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editValue(v.VID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(v)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Stats Footer -->
<div class="p-4 flex items-center justify-between" style="border-top: 1px solid rgb(var(--color-border));" x-show="list && list.length > 0">
<span class="text-sm" style="color: rgb(var(--color-text-muted));" x-text="list.length + ' items'"></span>
</div>
</div>
<!-- Item Form Dialog -->
<?= $this->include('v2/master/valuesets/valueset_dialog') ?>
<!-- Delete Modal -->
<div x-show="showDeleteModal" x-cloak class="modal-overlay" style="z-index: 1100;">
<div
class="card p-8 max-w-md w-full shadow-2xl"
x-show="showDeleteModal"
x-transition
>
<div class="w-16 h-16 rounded-2xl bg-rose-500/10 flex items-center justify-center text-rose-500 mx-auto mb-6">
<i class="fa-solid fa-triangle-exclamation text-2xl"></i>
</div>
<h3 class="text-xl font-bold text-center mb-2" style="color: rgb(var(--color-text));">Confirm Removal</h3>
<p class="text-center text-sm mb-8" style="color: rgb(var(--color-text-muted));">
Are you sure you want to delete <span class="font-bold text-rose-500" x-text="deleteTarget?.VValue"></span>?
</p>
<div class="flex gap-3">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1 bg-rose-600 text-white hover:bg-rose-700 shadow-lg shadow-rose-600/20" @click="deleteValue()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm !border-white/20 !border-t-white"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function valueSetItems() {
return {
loading: false,
list: [],
selectedDef: null,
keyword: "",
totalItems: 0,
// For dropdown population
defsList: [],
loadingDefs: false,
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
},
showDeleteModal: false,
deleteTarget: null,
deleting: false,
async fetchList(page = 1) {
if (!this.selectedDef) return;
this.loading = true;
try {
const params = new URLSearchParams();
params.append('VSetID', this.selectedDef.VSetID);
if (this.keyword) params.append('param', this.keyword);
const res = await fetch(`${BASEURL}api/valueset?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
this.totalItems = this.list.length;
} catch (err) {
console.error(err);
this.list = [];
this.totalItems = 0;
this.showToast('Failed to load items', 'error');
} finally {
this.loading = false;
}
},
async fetchDefsList() {
this.loadingDefs = true;
try {
const res = await fetch(`${BASEURL}api/valuesetdef?limit=1000`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.defsList = data.data || [];
} catch (err) {
console.error('Failed to fetch defs list:', err);
this.defsList = [];
} finally {
this.loadingDefs = false;
}
},
showForm() {
this.isEditing = false;
this.form = {
VID: null,
VSetID: this.selectedDef?.VSetID || "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
};
this.errors = {};
// If no selectedDef, we need to load all defs for dropdown
if (!this.selectedDef && this.defsList.length === 0) {
this.fetchDefsList();
}
this.showModal = true;
},
async editValue(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/valueset/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load item data', 'error');
}
},
validate() {
const e = {};
if (!this.form.VValue?.trim()) e.VValue = "Value is required";
if (!this.form.VSetID) e.VSetID = "Category is required";
this.errors = e;
return Object.keys(e).length === 0;
},
closeModal() {
this.showModal = false;
this.errors = {};
},
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PATCH' : 'POST';
const url = this.isEditing ? `${BASEURL}api/valueset/${this.form.VID}` : `${BASEURL}api/valueset`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
if (res.ok) {
this.closeModal();
await this.fetchList(1);
this.showToast(this.isEditing ? 'Item updated successfully' : 'Item created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.errors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error('Save failed:', err);
this.errors = { general: err.message || 'An error occurred while saving' };
this.showToast('Failed to save item', 'error');
} finally {
this.saving = false;
}
},
confirmDelete(v) {
this.deleteTarget = v;
this.showDeleteModal = true;
},
async deleteValue() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/valueset/${this.deleteTarget.VID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList(1);
this.showToast('Item deleted successfully', 'success');
} else {
this.showToast('Failed to delete item', 'error');
}
} catch (err) {
console.error('Delete failed:', err);
this.showToast('Failed to delete item', 'error');
} finally {
this.deleting = false;
this.deleteTarget = null;
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>

View File

@ -0,0 +1,122 @@
<!-- Value Set Definition Form Modal -->
<div
x-show="showModal"
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"
>
<!-- 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-layer-group-plus" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Category' : 'New Category'"></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-6">
<!-- General Error -->
<div x-show="errors.general" class="p-4 rounded-lg bg-rose-50 border border-rose-200" style="display: none;">
<div class="flex items-center gap-2">
<i class="fa-solid fa-exclamation-triangle text-rose-500"></i>
<p class="text-sm font-medium text-rose-700" x-text="errors.general"></p>
</div>
</div>
<!-- Basic Information Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Basic Information</h4>
<div>
<label class="label">
<span class="label-text font-medium">Category Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.VSName && 'input-error'"
x-model="form.VSName"
placeholder="e.g. Gender, Country, Status"
/>
<label class="label" x-show="errors.VSName">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.VSName"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Description</span>
</label>
<textarea
class="input h-24 pt-2"
x-model="form.VSDesc"
placeholder="Detailed description of this category..."
></textarea>
</div>
</div>
<!-- Additional Info Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">System Information</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Site ID</span>
</label>
<input
type="number"
class="input text-center font-mono"
x-model="form.SiteID"
placeholder="1"
readonly
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Category ID</span>
</label>
<input
type="text"
class="input text-center font-mono"
x-model="form.VSetID"
placeholder="Auto-generated"
readonly
/>
</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-primary 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...' : (isEditing ? 'Update Category' : 'Create Category')"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,679 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="valueSetManager()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-800 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-layer-group text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Value Set Manager</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage value set categories and their items</p>
</div>
</div>
</div>
<!-- Two Column Layout with Independent Scrolling -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- LEFT PANEL: ValueSetDef List -->
<div class="card overflow-hidden flex flex-col" style="height: calc(100vh - 280px); min-height: 400px;">
<!-- Left Panel Header -->
<div class="p-4 border-b flex items-center justify-between" style="border-color: rgb(var(--color-border));">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: rgb(var(--color-primary));">
<i class="fa-solid fa-layer-group text-white"></i>
</div>
<div>
<h3 class="font-bold" style="color: rgb(var(--color-text));">Categories</h3>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">Value Set Definitions</p>
</div>
</div>
<button class="btn btn-primary btn-sm" @click="showDefForm()">
<i class="fa-solid fa-plus mr-1"></i> Add
</button>
</div>
<!-- Search Bar -->
<div class="p-3 border-b" style="border-color: rgb(var(--color-border));">
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-gray-400" style="z-index: 10;"></i>
<input
type="text"
placeholder="Search categories..."
class="input input-sm w-full input-with-icon"
x-model="defKeyword"
@keyup.enter="fetchDefs()"
/>
</div>
</div>
<!-- Loading State -->
<div x-show="defLoading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading categories...</p>
</div>
<!-- Def List Table -->
<div class="overflow-y-auto flex-1" x-show="!defLoading" x-cloak>
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Category Name</th>
<th class="w-20 text-center">Items</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="!defList || defList.length === 0">
<tr>
<td colspan="4" class="text-center py-12">
<div class="flex flex-col items-center gap-2" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-folder-open text-4xl opacity-40"></i>
<p class="text-sm">No categories found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showDefForm()">
<i class="fa-solid fa-plus mr-1"></i> Add Category
</button>
</div>
</td>
</tr>
</template>
<template x-for="def in defList" :key="def.VSetID">
<tr
class="hover:bg-opacity-50 cursor-pointer transition-colors"
:class="selectedDef?.VSetID === def.VSetID ? 'bg-primary/10' : ''"
@click="selectDef(def)"
>
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="def.VSetID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="def.VSName || '-'"></div>
<div class="text-xs opacity-50" x-text="def.VSDesc || ''"></div>
</td>
<td class="text-center">
<span class="badge badge-sm" x-text="(def.ItemCount || 0) + ' items'"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1" @click.stop>
<button class="btn btn-ghost btn-sm btn-square" @click="editDef(def.VSetID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDeleteDef(def)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Left Panel Footer -->
<div class="p-3 flex items-center justify-between text-xs" style="border-top: 1px solid rgb(var(--color-border));" x-show="defList && defList.length > 0">
<span style="color: rgb(var(--color-text-muted));" x-text="defList.length + ' categories'"></span>
</div>
</div>
<!-- RIGHT PANEL: ValueSet Items -->
<div class="card overflow-hidden flex flex-col" style="height: calc(100vh - 280px); min-height: 400px;">
<!-- Right Panel Header -->
<div class="p-4 border-b flex items-center justify-between" style="border-color: rgb(var(--color-border));">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: rgb(var(--color-secondary));">
<i class="fa-solid fa-list-ul text-white"></i>
</div>
<div>
<h3 class="font-bold" style="color: rgb(var(--color-text));">Items</h3>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">
<template x-if="selectedDef">
<span x-text="selectedDef.VSName + ' Items'"></span>
</template>
<template x-if="!selectedDef">
<span>Select a category to view items</span>
</template>
</p>
</div>
</div>
<button
class="btn btn-primary btn-sm"
@click="showValueForm()"
:disabled="!selectedDef"
>
<i class="fa-solid fa-plus mr-1"></i> Add Item
</button>
</div>
<!-- Search Bar (Right Panel) -->
<div class="p-3 border-b" style="border-color: rgb(var(--color-border));" x-show="selectedDef">
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-gray-400" style="z-index: 10;"></i>
<input
type="text"
placeholder="Filter items..."
class="input input-sm w-full input-with-icon"
x-model="valueKeyword"
@keyup.enter="fetchValues()"
/>
</div>
</div>
<!-- Empty State - No Selection -->
<div x-show="!selectedDef" class="p-16 text-center" x-cloak>
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-hand-pointer text-5xl opacity-30"></i>
<p class="text-lg font-medium">Select a category</p>
<p class="text-sm opacity-60">Click on a category from the left panel to view and manage its items</p>
</div>
</div>
<!-- Loading State -->
<div x-show="valueLoading && selectedDef" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading items...</p>
</div>
<!-- Value List Table -->
<div class="overflow-y-auto flex-1" x-show="!valueLoading && selectedDef" x-cloak>
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Value</th>
<th>Description</th>
<th class="w-16 text-center">Order</th>
<th class="w-20 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="!valueList || valueList.length === 0">
<tr>
<td colspan="5" class="text-center py-12">
<div class="flex flex-col items-center gap-2" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-4xl opacity-40"></i>
<p class="text-sm">No items found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showValueForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Item
</button>
</div>
</td>
</tr>
</template>
<template x-for="value in valueList" :key="value.VID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="value.VID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="value.VValue || '-'"></div>
</td>
<td>
<span class="text-sm opacity-70" x-text="value.VDesc || '-'"></span>
</td>
<td class="text-center">
<span class="font-mono text-sm" x-text="value.VOrder || 0"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editValue(value.VID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDeleteValue(value)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Right Panel Footer -->
<div class="p-3 flex items-center justify-between text-xs" style="border-top: 1px solid rgb(var(--color-border));" x-show="valueList && valueList.length > 0 && selectedDef">
<span style="color: rgb(var(--color-text-muted));" x-text="valueList.length + ' items'"></span>
</div>
</div>
</div>
<!-- Include Definition Form Dialog -->
<?= $this->include('v2/master/valuesets/valuesetdef_dialog') ?>
<!-- Include Value Form Dialog -->
<?= $this->include('v2/master/valuesets/valueset_dialog') ?>
<!-- Delete Category Confirmation Modal -->
<div
x-show="showDeleteDefModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteDefModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete category <strong x-text="deleteDefTarget?.VSName"></strong>?
This will also delete all items in this category and cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteDefModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteDef()" :disabled="deletingDef">
<span x-show="deletingDef" class="spinner spinner-sm"></span>
<span x-show="!deletingDef">Delete</span>
</button>
</div>
</div>
</div>
<!-- Delete Value Confirmation Modal -->
<div
x-show="showDeleteValueModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteValueModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete item <strong x-text="deleteValueTarget?.VValue"></strong>?
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteValueModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteValue()" :disabled="deletingValue">
<span x-show="deletingValue" class="spinner spinner-sm"></span>
<span x-show="!deletingValue">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function valueSetManager() {
return {
// State - Definitions
defLoading: false,
defList: [],
defKeyword: "",
// State - Values
valueLoading: false,
valueList: [],
valueKeyword: "",
selectedDef: null,
// Definition Form
showDefModal: false,
isEditingDef: false,
savingDef: false,
defErrors: {},
defForm: {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
},
// Value Form
showValueModal: false,
isEditingValue: false,
savingValue: false,
valueErrors: {},
valueForm: {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
},
// Delete Definition
showDeleteDefModal: false,
deleteDefTarget: null,
deletingDef: false,
// Delete Value
showDeleteValueModal: false,
deleteValueTarget: null,
deletingValue: false,
// Dropdown data
defsList: [],
// Lifecycle
async init() {
await this.fetchDefs();
},
// ==================== DEFINITION METHODS ====================
async fetchDefs() {
this.defLoading = true;
try {
const params = new URLSearchParams();
if (this.defKeyword) params.append('param', this.defKeyword);
const res = await fetch(`${BASEURL}api/valuesetdef?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.defList = data.data || [];
// Update selected def in list if exists
if (this.selectedDef) {
const updated = this.defList.find(d => d.VSetID === this.selectedDef.VSetID);
if (updated) {
this.selectedDef = updated;
}
}
} catch (err) {
console.error(err);
this.defList = [];
this.showToast('Failed to load categories', 'error');
} finally {
this.defLoading = false;
}
},
showDefForm() {
this.isEditingDef = false;
this.defForm = {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
};
this.defErrors = {};
this.showDefModal = true;
},
async editDef(id) {
this.isEditingDef = true;
this.defErrors = {};
try {
const res = await fetch(`${BASEURL}api/valuesetdef/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.defForm = { ...this.defForm, ...data.data };
this.showDefModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load category data', 'error');
}
},
validateDef() {
const e = {};
if (!this.defForm.VSName?.trim()) e.VSName = "Category name is required";
this.defErrors = e;
return Object.keys(e).length === 0;
},
closeDefModal() {
this.showDefModal = false;
this.defErrors = {};
},
async saveDef() {
if (!this.validateDef()) return;
this.savingDef = true;
try {
const method = this.isEditingDef ? 'PATCH' : 'POST';
const url = this.isEditingDef ? `${BASEURL}api/valuesetdef/${this.defForm.VSetID}` : `${BASEURL}api/valuesetdef`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.defForm),
credentials: 'include'
});
if (res.ok) {
this.closeDefModal();
await this.fetchDefs();
this.showToast(this.isEditingDef ? 'Category updated successfully' : 'Category created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.defErrors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error(err);
this.defErrors = { general: 'Failed to save category' };
this.showToast('Failed to save category', 'error');
} finally {
this.savingDef = false;
}
},
confirmDeleteDef(def) {
this.deleteDefTarget = def;
this.showDeleteDefModal = true;
},
async deleteDef() {
if (!this.deleteDefTarget) return;
this.deletingDef = true;
try {
const res = await fetch(`${BASEURL}api/valuesetdef/${this.deleteDefTarget.VSetID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteDefModal = false;
if (this.selectedDef?.VSetID === this.deleteDefTarget.VSetID) {
this.selectedDef = null;
this.valueList = [];
}
await this.fetchDefs();
this.showToast('Category deleted successfully', 'success');
} else {
this.showToast('Failed to delete category', 'error');
}
} catch (err) {
console.error(err);
this.showToast('Failed to delete category', 'error');
} finally {
this.deletingDef = false;
this.deleteDefTarget = null;
}
},
// ==================== VALUE METHODS ====================
selectDef(def) {
this.selectedDef = def;
this.fetchValues();
},
async fetchValues() {
if (!this.selectedDef) return;
this.valueLoading = true;
try {
const params = new URLSearchParams();
params.append('VSetID', this.selectedDef.VSetID);
if (this.valueKeyword) params.append('param', this.valueKeyword);
const res = await fetch(`${BASEURL}api/valueset?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.valueList = data.data || [];
} catch (err) {
console.error(err);
this.valueList = [];
this.showToast('Failed to load items', 'error');
} finally {
this.valueLoading = false;
}
},
async fetchDefsList() {
try {
const res = await fetch(`${BASEURL}api/valuesetdef?limit=1000`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.defsList = data.data || [];
} catch (err) {
console.error('Failed to fetch defs list:', err);
this.defsList = [];
}
},
showValueForm() {
if (!this.selectedDef) {
this.showToast('Please select a category first', 'warning');
return;
}
this.isEditingValue = false;
this.valueForm = {
VID: null,
VSetID: this.selectedDef.VSetID,
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
};
this.valueErrors = {};
this.showValueModal = true;
},
async editValue(id) {
this.isEditingValue = true;
this.valueErrors = {};
try {
const res = await fetch(`${BASEURL}api/valueset/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.valueForm = { ...this.valueForm, ...data.data };
this.showValueModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load item data', 'error');
}
},
validateValue() {
const e = {};
if (!this.valueForm.VValue?.trim()) e.VValue = "Value is required";
if (!this.valueForm.VSetID) e.VSetID = "Category is required";
this.valueErrors = e;
return Object.keys(e).length === 0;
},
closeValueModal() {
this.showValueModal = false;
this.valueErrors = {};
},
async saveValue() {
if (!this.validateValue()) return;
this.savingValue = true;
try {
const method = this.isEditingValue ? 'PATCH' : 'POST';
const url = this.isEditingValue ? `${BASEURL}api/valueset/${this.valueForm.VID}` : `${BASEURL}api/valueset`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.valueForm),
credentials: 'include'
});
if (res.ok) {
this.closeValueModal();
await this.fetchValues();
await this.fetchDefs(); // Refresh item counts
this.showToast(this.isEditingValue ? 'Item updated successfully' : 'Item created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.valueErrors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error(err);
this.valueErrors = { general: 'Failed to save item' };
this.showToast('Failed to save item', 'error');
} finally {
this.savingValue = false;
}
},
confirmDeleteValue(value) {
this.deleteValueTarget = value;
this.showDeleteValueModal = true;
},
async deleteValue() {
if (!this.deleteValueTarget) return;
this.deletingValue = true;
try {
const res = await fetch(`${BASEURL}api/valueset/${this.deleteValueTarget.VID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteValueModal = false;
await this.fetchValues();
await this.fetchDefs(); // Refresh item counts
this.showToast('Item deleted successfully', 'success');
} else {
this.showToast('Failed to delete item', 'error');
}
} catch (err) {
console.error(err);
this.showToast('Failed to delete item', 'error');
} finally {
this.deletingValue = false;
this.deleteValueTarget = null;
}
},
// ==================== UTILITIES ====================
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,207 @@
<!-- Patient Form Modal -->
<div
x-show="showModal"
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-6">
<h3 class="font-bold text-xl flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-user-plus" style="color: rgb(var(--color-primary));"></i>
<span x-text="isEditing ? 'Edit Patient' : 'New Patient'"></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-5">
<!-- Patient ID -->
<div>
<label class="label">
<span class="label-text font-medium">Patient ID (MRN)</span>
</label>
<input
type="text"
class="input"
placeholder="Auto-generated if empty"
x-model="form.PatientID"
/>
</div>
<!-- Name Row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">First Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.NameFirst && 'input-error'"
x-model="form.NameFirst"
/>
<label class="label" x-show="errors.NameFirst">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.NameFirst"></span>
</label>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Middle Name</span>
</label>
<input
type="text"
class="input"
x-model="form.NameMiddle"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Last Name <span style="color: rgb(var(--color-error));">*</span></span>
</label>
<input
type="text"
class="input"
:class="errors.NameLast && 'input-error'"
x-model="form.NameLast"
/>
<label class="label" x-show="errors.NameLast">
<span class="label-text-alt" style="color: rgb(var(--color-error));" x-text="errors.NameLast"></span>
</label>
</div>
</div>
<!-- Gender & Birthdate -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Gender</span>
</label>
<select class="select" x-model="form.Gender">
<option value="1">Male</option>
<option value="2">Female</option>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Birth Date</span>
</label>
<input
type="date"
class="input"
x-model="form.Birthdate"
/>
</div>
</div>
<!-- Contact Info -->
<div class="divider">Contact Information</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Mobile Phone</span>
</label>
<input
type="tel"
class="input"
placeholder="+62..."
x-model="form.MobilePhone"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Email</span>
</label>
<input
type="email"
class="input"
placeholder="patient@email.com"
x-model="form.EmailAddress1"
/>
</div>
</div>
<!-- Address -->
<div class="divider">Address</div>
<div>
<label class="label">
<span class="label-text font-medium">Street Address</span>
</label>
<input
type="text"
class="input"
x-model="form.Street_1"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">City</span>
</label>
<input
type="text"
class="input"
x-model="form.City"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Province</span>
</label>
<input
type="text"
class="input"
x-model="form.Province"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">ZIP Code</span>
</label>
<input
type="text"
class="input"
x-model="form.ZIP"
/>
</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-primary 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 Patient'"></span>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,424 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="patients()" x-init="init()">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<!-- Total Patients -->
<div class="card group hover:shadow-xl transition-all">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Total Patients</p>
<p class="text-3xl font-bold" style="color: rgb(var(--color-text));" x-text="stats.total">0</p>
</div>
<div class="w-14 h-14 rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform" style="background: rgba(var(--color-primary), 0.15);">
<i class="fa-solid fa-users text-2xl" style="color: rgb(var(--color-primary));"></i>
</div>
</div>
</div>
</div>
<!-- New Today -->
<div class="card group hover:shadow-xl transition-all">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">New Today</p>
<p class="text-3xl font-bold text-emerald-500" x-text="stats.newToday">0</p>
</div>
<div class="w-14 h-14 rounded-2xl bg-emerald-500/15 flex items-center justify-center group-hover:scale-110 transition-transform">
<i class="fa-solid fa-user-plus text-emerald-500 text-2xl"></i>
</div>
</div>
</div>
</div>
<!-- Pending -->
<div class="card group hover:shadow-xl transition-all">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Pending Visits</p>
<p class="text-3xl font-bold text-amber-500" x-text="stats.pending">0</p>
</div>
<div class="w-14 h-14 rounded-2xl bg-amber-500/15 flex items-center justify-center group-hover:scale-110 transition-transform">
<i class="fa-solid fa-clock text-amber-500 text-2xl"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<!-- Search -->
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search by name, ID, phone..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<!-- Actions -->
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i> New Patient
</button>
</div>
</div>
</div>
<!-- Patient List Table -->
<div class="card overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading patients...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th>Patient ID</th>
<th>Name</th>
<th>Gender</th>
<th>Birth Date</th>
<th>Phone</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
<p class="text-lg">No patients found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Patient
</button>
</div>
</td>
</tr>
</template>
<!-- Patient Rows -->
<template x-for="patient in list" :key="patient.InternalPID">
<tr class="cursor-pointer hover:bg-opacity-50" @click="viewPatient(patient.InternalPID)">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="patient.PatientID || '-'"></span>
</td>
<td>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold bg-gradient-to-br from-blue-600 to-blue-900">
<span class="text-sm" x-text="(patient.NameFirst || '?')[0].toUpperCase()"></span>
</div>
<div>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="(patient.NameFirst || '') + ' ' + (patient.NameLast || '')"></div>
<div class="text-xs" style="color: rgb(var(--color-text-muted));" x-text="patient.EmailAddress1 || ''"></div>
</div>
</div>
</td>
<td>
<span
class="badge badge-sm"
:class="patient.Gender == 1 ? 'badge-info' : 'badge-secondary'"
x-text="patient.Gender == 1 ? 'Male' : patient.Gender == 2 ? 'Female' : '-'"
></span>
</td>
<td x-text="formatDate(patient.Birthdate)"></td>
<td x-text="patient.MobilePhone || patient.Phone || '-'"></td>
<td class="text-center" @click.stop>
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editPatient(patient.InternalPID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(patient)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="p-4 flex items-center justify-between" style="border-top: 1px solid rgb(var(--color-border));" x-show="list && list.length > 0">
<span class="text-sm" style="color: rgb(var(--color-text-muted));" x-text="'Showing ' + list.length + ' patients'"></span>
<div class="flex gap-1">
<button class="btn btn-ghost btn-sm">«</button>
<button class="btn btn-primary btn-sm">1</button>
<button class="btn btn-ghost btn-sm">2</button>
<button class="btn btn-ghost btn-sm">3</button>
<button class="btn btn-ghost btn-sm">»</button>
</div>
</div>
</div>
<!-- Include Form Dialog -->
<?= $this->include('v2/patients/dialog_form') ?>
<!-- Delete Confirmation Dialog -->
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete patient <strong x-text="deleteTarget?.PatientID"></strong>?
This action cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deletePatient()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function patients() {
return {
// State
loading: false,
list: [],
keyword: "",
// Stats
stats: {
total: 0,
newToday: 0,
pending: 0
},
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
InternalPID: null,
PatientID: "",
NameFirst: "",
NameMiddle: "",
NameLast: "",
Gender: 1,
Birthdate: "",
MobilePhone: "",
EmailAddress1: "",
Street_1: "",
City: "",
Province: "",
ZIP: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchList();
},
// Fetch patient list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/patient?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
this.stats.total = this.list.length;
// Calculate new today (simplified - you may want server-side)
const today = new Date().toISOString().split('T')[0];
this.stats.newToday = this.list.filter(p => p.CreateDate && p.CreateDate.startsWith(today)).length;
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Format date
formatDate(dateStr) {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
} catch {
return dateStr;
}
},
// View patient details
viewPatient(id) {
// Could navigate to detail page or open drawer
console.log('View patient:', id);
},
// Show form for new patient
showForm() {
this.isEditing = false;
this.form = {
InternalPID: null,
PatientID: "",
NameFirst: "",
NameMiddle: "",
NameLast: "",
Gender: 1,
Birthdate: "",
MobilePhone: "",
EmailAddress1: "",
Street_1: "",
City: "",
Province: "",
ZIP: ""
};
this.errors = {};
this.showModal = true;
},
// Edit patient
async editPatient(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/patient/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load patient');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.NameFirst?.trim()) e.NameFirst = "First name is required";
if (!this.form.NameLast?.trim()) e.NameLast = "Last name is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save patient
async save() {
if (!this.validate()) return;
this.saving = true;
try {
let res;
if (this.isEditing && this.form.InternalPID) {
res = await fetch(`${BASEURL}api/patient`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
} else {
res = await fetch(`${BASEURL}api/patient`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
}
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save patient");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(patient) {
this.deleteTarget = patient;
this.showDeleteModal = true;
},
// Delete patient
async deletePatient() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/patient`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InternalPID: this.deleteTarget.InternalPID }),
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
alert("Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete patient");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,130 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div class="w-full space-y-6">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-flask text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Lab Requests</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage laboratory test requests and orders</p>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="card group hover:shadow-xl transition-all duration-300">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Pending</p>
<p class="text-3xl font-bold text-amber-500">34</p>
</div>
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<i class="fa-solid fa-clock text-amber-500 text-2xl"></i>
</div>
</div>
</div>
</div>
<div class="card group hover:shadow-xl transition-all duration-300">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">In Progress</p>
<p class="text-3xl font-bold text-blue-500">18</p>
</div>
<div class="w-14 h-14 rounded-2xl bg-blue-500/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<i class="fa-solid fa-spinner text-blue-500 text-2xl"></i>
</div>
</div>
</div>
</div>
<div class="card group hover:shadow-xl transition-all duration-300">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Completed</p>
<p class="text-3xl font-bold text-emerald-500">156</p>
</div>
<div class="w-14 h-14 rounded-2xl bg-emerald-500/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<i class="fa-solid fa-check-circle text-emerald-500 text-2xl"></i>
</div>
</div>
</div>
</div>
<div class="card group hover:shadow-xl transition-all duration-300">
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: rgb(var(--color-text-muted));">Rejected</p>
<p class="text-3xl font-bold text-red-500">3</p>
</div>
<div class="w-14 h-14 rounded-2xl bg-red-500/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<i class="fa-solid fa-times-circle text-red-500 text-2xl"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Content Card -->
<div class="card">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<input
type="text"
placeholder="Search requests..."
class="input input-bordered w-64"
/>
<select class="select select-bordered">
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="in-progress">In Progress</option>
<option value="completed">Completed</option>
<option value="rejected">Rejected</option>
</select>
</div>
<button class="btn btn-primary">
<i class="fa-solid fa-plus mr-2"></i>
New Request
</button>
</div>
<!-- Table Placeholder -->
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>Request ID</th>
<th>Patient</th>
<th>Test Type</th>
<th>Priority</th>
<th>Status</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="7" class="text-center py-12" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-database text-4xl mb-3 opacity-30"></i>
<p>No data available. Connect to API to load lab requests.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>

Some files were not shown because too many files have changed in this diff Show More