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 |
| **Database** | MySQL (Optimized Schema Migration in progress) |
---
## 📂 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
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/
*/
public string $baseURL = 'http://localhost:8080/';
public string $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
* 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'
*/
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 = [
'before' => [
'forcehttps', // Force Global Secure Requests
// 'forcehttps', // Force Global Secure Requests - disabled for localhost
'pagecache', // Web Page Caching
],
'after' => [

View File

@ -5,167 +5,282 @@ use CodeIgniter\Router\RouteCollection;
/**
* @var RouteCollection $routes
*/
$routes->options('(:any)', function() { return ''; });
$routes->get('/', 'Home::index');
$routes->get('/', function () {
return redirect()->to('/v2');
});
// Frontend Pages
$routes->get('/login', 'Pages\AuthPage::login');
$routes->get('/logout', 'Pages\AuthPage::logout');
$routes->get('/dashboard', 'Pages\DashboardPage::index');
$routes->options('(:any)', function () {
return '';
});
$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
$routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1');
$routes->group('api', ['filter' => 'auth'], function($routes) {
$routes->get('dashboard', 'Dashboard::index');
$routes->get('result', 'Result::index');
$routes->get('sample', 'Sample::index');
$routes->group('api', function ($routes) {
// Auth
$routes->group('auth', function ($routes) {
$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
/*
$routes->get('/api/zones', 'Zones::index');
@ -173,3 +288,4 @@ $routes->get('/api/zones/synchronize', 'Zones::synchronize');
$routes->get('/api/zones/provinces', 'Zones::getProvinces');
$routes->get('/api/zones/cities', 'Zones::getCities');
*/

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\AreaGeoModel;
class AreaGeo extends BaseController {
class AreaGeoController extends BaseController {
use ResponseTrait;
protected $model;

View File

@ -12,16 +12,19 @@ use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use CodeIgniter\Cookie\Cookie;
class Auth extends Controller {
class AuthController extends Controller
{
use ResponseTrait;
// ok
public function __construct() {
public function __construct()
{
$this->db = \Config\Database::connect();
}
// ok
public function checkAuth() {
public function checkAuth()
{
$token = $this->request->getCookie('token');
$key = getenv('JWT_SECRET');
@ -74,7 +77,65 @@ class Auth extends Controller {
}
// 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
$username = $this->request->getVar('username');
@ -89,7 +150,9 @@ class Auth extends Controller {
$query = $this->db->query($sql);
$row = $query->getResultArray();
if (!$row) { return $this->fail('User not found.', 401); }
if (!$row) {
return $this->fail('User not found.', 401);
}
$row = $row[0];
if (!password_verify($password, $row['password'])) {
return $this->fail('Invalid password.', 401);
@ -131,7 +194,26 @@ class Auth extends Controller {
}
// 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
return $this->response->setCookie([
'name' => 'token',
@ -150,7 +232,8 @@ class Auth extends Controller {
}
// ok
public function register() {
public function register()
{
$username = strtolower($this->request->getJsonVar('username'));
$password = $this->request->getJsonVar('password');
@ -167,7 +250,7 @@ class Auth extends Controller {
// Cek Duplikasi Username
$exists = $this->db->query("SELECT id FROM users WHERE username = ?", [$username])->getRow();
if ($exists) {
return $this->respond(['status' => 'failed', 'code'=>409,'message' => 'Username already exists'], 409);
return $this->respond(['status' => 'failed', 'code' => 409, 'message' => 'Username already exists'], 409);
}
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
@ -219,7 +302,8 @@ class Auth extends Controller {
// return $this->respond($response);
// }
public function coba() {
public function coba()
{
$token = $this->request->getCookie('token');
$key = getenv('JWT_SECRET');

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;
class Contact extends BaseController {
class ContactController extends BaseController {
use ResponseTrait;
protected $db;
@ -33,13 +33,13 @@ class Contact extends BaseController {
public function show($ContactID = null) {
$model = new ContactModel();
$rows = $model->getContactWithDetail($ContactID);
$row = $model->getContactWithDetail($ContactID);
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'=> "fetch success", 'data' => $rows ], 200);
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
}
public function delete() {

View File

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

View File

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

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\CounterModel;
class Counter extends BaseController {
class CounterController extends BaseController {
use ResponseTrait;
protected $model;
@ -24,13 +24,13 @@ class Counter extends BaseController {
}
public function show($CounterID = null) {
$rows = $this->model->find($CounterID);
$row = $this->model->find($CounterID);
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() {

View File

@ -12,10 +12,12 @@ use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use CodeIgniter\Cookie\Cookie;
class Sample extends Controller {
class DashboardController extends Controller
{
use ResponseTrait;
public function index() {
public function index()
{
$token = $this->request->getCookie('token');
$key = getenv('JWT_SECRET');

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 CodeIgniter\Cookie\Cookie;
class Home extends Controller {
class HomeController extends Controller {
use ResponseTrait;
public function index() {

View File

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

View File

@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
use CodeIgniter\Database\RawSql;
class OrderTest extends Controller {
class OrderTestController extends Controller {
use ResponseTrait;
public function __construct() {
@ -34,13 +34,13 @@ class OrderTest extends Controller {
}
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)) {
return $this->respond([
'status' => 'success',
'message' => "Data not found.",
'data' => [],
'data' => null,
], 200);
}

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\AccountModel;
class Account extends BaseController {
class AccountController extends BaseController {
use ResponseTrait;
protected $db;
@ -34,13 +34,13 @@ class Account extends BaseController {
public function show($AccountID = null) {
//$rows = $this->model->where('AccountID', $AccountID)->findAll();
$rows = $this->model->getAccount($AccountID);
$row = $this->model->getAccount($AccountID);
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'=> "fetch success", 'data' => $rows ], 200);
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
}
public function delete() {

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\DepartmentModel;
class Department extends BaseController {
class DepartmentController extends BaseController {
use ResponseTrait;
protected $db;
@ -32,13 +32,12 @@ class Department extends BaseController {
}
public function show($DepartmentID = null) {
//$rows = $this->model->where('DepartmentID', $DepartmentID)->findAll();
$rows = $this->model->getDepartment($DepartmentID);
if (empty($rows)) {
return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200);
$row = $this->model->getDepartment($DepartmentID);
if (empty($row)) {
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() {

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\DisciplineModel;
class Discipline extends BaseController {
class DisciplineController extends BaseController {
use ResponseTrait;
protected $db;
@ -32,13 +32,13 @@ class Discipline extends BaseController {
}
public function show($DisciplineID = null) {
$rows = $this->model->where('DisciplineID', $DisciplineID)->findAll();
$row = $this->model->where('DisciplineID', $DisciplineID)->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'=> "fetch success", 'data' => $rows ], 200);
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
}
public function delete() {

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\SiteModel;
class Site extends BaseController {
class SiteController extends BaseController {
use ResponseTrait;
protected $db;
@ -33,13 +33,13 @@ class Site extends BaseController {
public function show($SiteID = null) {
//$rows = $this->model->where('SiteID', $SiteID)->findAll();
$rows = $this->model->getSite($SiteID);
$row = $this->model->getSite($SiteID);
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'=> "fetch success", 'data' => $rows ], 200);
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
}
public function delete() {

View File

@ -6,7 +6,7 @@ use App\Controllers\BaseController;
use App\Models\Organization\WorkstationModel;
class Workstation extends BaseController {
class WorkstationController extends BaseController {
use ResponseTrait;
protected $db;
@ -32,13 +32,13 @@ class Workstation extends BaseController {
}
public function show($WorkstationID = null) {
$rows = $this->model->getWorkstation($WorkstationID);
$row = $this->model->getWorkstation($WorkstationID);
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'=> "fetch success", 'data' => $rows ], 200);
return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200);
}
public function delete() {

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,8 +30,11 @@ class ContainerDef extends BaseController {
public function show($id) {
try {
$rows = $this->model->where('SpcStaID', $id)->findAll();
return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200);
$row = $this->model->where('SpcStaID', $id)->first();
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) {
return $this->failServerError('Exception : '.$e->getMessage());
}

View File

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

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\Models\ValueSet\ValueSetModel;
class ValueSet extends BaseController {
class ValueSetController extends BaseController {
use ResponseTrait;
protected $db;
@ -23,17 +23,31 @@ class ValueSet extends BaseController {
public function index() {
$param = $this->request->getVar('param');
$rows = $this->model->getValueSets($param);
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);
$VSetID = $this->request->getVar('VSetID');
$page = $this->request->getVar('page') ?? 1;
$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) {
$rows = $this->model->getValueSet($VID);
if (empty($rows)) {
return $this->respond([ 'status' => 'success', 'message' => "ValueSet with ID $VID not found.", 'data' => [] ], 200);
$row = $this->model->getValueSet($VID);
if (empty($row)) {
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) {

View File

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

View File

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

View File

@ -24,12 +24,14 @@ class CreateLocationTable extends Migration {
$this->forge->addField([
'LocationID' => ['type' => 'INT', 'unsigned' => 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],
'Province' => ['type' => 'int', 'null' => true],
'PostCode' => ['type' => 'varchar', 'constraint' => 255, 'null' => true],
'GeoLocationSystem' => ['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],
'EndDate' => ['type' => 'DATETIME', 'null' => true]
]);

View File

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

View File

@ -11,67 +11,43 @@ class CreateRefRangesTable extends Migration {
'RefNumID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'SiteID' => ['type' => 'INT', 'null' => true],
'TestSiteID' => ['type' => 'INT', 'null' => false],
'SpcType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false],
'SpcType' => ['type' => 'INT', 'null' => true],
'Sex' => ['type' => 'INT', 'null' => true],
'Criteria' => ['type' => 'varchar', 'constraint'=>100, 'null' => true],
'AgeStart' => ['type' => 'INT', 'null' => true],
'AgeEnd' => ['type' => 'int', 'null' => true],
'CriticalLow' => ['type' => 'int', 'null' => true],
'Low' => ['type' => 'int', 'null' => true],
'High' => ['type' => 'int', 'null' => true],
'CriticalHigh' => ['type' => 'int', 'null' => true],
'NumRefType' => ['type' => 'INT', 'null' => true],
'RangeType' => ['type' => 'INT', 'null' => true],
'LowSign' => ['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],
'StartDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true],
]);
$this->forge->addKey('RefNumID', true);
$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([
'RefTxtID' => ['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],
'Criteria' => ['type' => 'varchar', 'constraint'=>100, 'null' => true],
'AgeStart' => ['type' => 'INT', 'null' => true],
'AgeEnd' => ['type' => 'int', 'null' => true],
'TxtRefType' => ['type' => 'INT', '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],
'StartDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true],
]);
$this->forge->addKey('RefTxtID', true);
@ -80,8 +56,6 @@ class CreateRefRangesTable extends Migration {
public function down() {
$this->forge->dropTable('refnum');
$this->forge->dropTable('refthold');
$this->forge->dropTable('refvset');
$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');
// users
// Password: 'password' for all users (bcrypt hash)
$passwordHash = password_hash('password', PASSWORD_BCRYPT);
$data = [
['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'],
['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => '$2y$12$KwPedIPb7K/0IR/8/FcwdOMG4eBNNAXSjXnbkB26SwjH4Nf7PaYBe'],
['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'],
['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => $passwordHash],
['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => $passwordHash],
['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => $passwordHash],
];
$this->db->table('users')->insertBatch($data);

View File

@ -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"];
$this->db->table('testdefsite')->insert($data);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
// 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"];
$this->db->table('testdefsite')->insert($data);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
// ========================================
@ -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"];
$this->db->table('testdefsite')->insert($data);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
// ========================================
@ -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"];
$this->db->table('testdefsite')->insert($data);
$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);
$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);
$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);
$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);
$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);
// ========================================
@ -186,67 +186,67 @@ class TestSeeder extends Seeder {
$this->db->table('testdefsite')->insert($data);
$tIDs['CBC'] = $this->db->insertID();
$this->db->table('testdefgrp')->insertBatch([
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HB'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HCT'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['RBC'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['WBC'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['PLT'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCV'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCH'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCHC'], 'CreateDate' => "$now"]
['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HB'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HCT'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['RBC'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['WBC'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['PLT'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCV'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCH'], '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"];
$this->db->table('testdefsite')->insert($data);
$tIDs['LIPID'] = $this->db->insertID();
$this->db->table('testdefgrp')->insertBatch([
['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['CHOL'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['TG'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['HDL'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDL'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDLCALC'], 'CreateDate' => "$now"]
['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['CHOL'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['TG'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['HDL'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDL'], '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"];
$this->db->table('testdefsite')->insert($data);
$tIDs['LFT'] = $this->db->insertID();
$this->db->table('testdefgrp')->insertBatch([
['SiteID' => '1', 'TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGOT'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGPT'], 'CreateDate' => "$now"]
['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGOT'], '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"];
$this->db->table('testdefsite')->insert($data);
$tIDs['RFT'] = $this->db->insertID();
$this->db->table('testdefgrp')->insertBatch([
['SiteID' => '1', 'TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['UREA'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['CREA'], 'CreateDate' => "$now"],
['SiteID' => '1', 'TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['EGFR'], 'CreateDate' => "$now"]
['TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['UREA'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['CREA'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['EGFR'], 'CreateDate' => "$now"]
]);
// 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"];
$this->db->table('testdefsite')->insert($data);
$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);
$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);
$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);
$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);
$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);
$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);
$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);
}

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' => 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' => 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' => 7, 'VValue' =>'BBL', 'VDesc' => "Blood bag", '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' => 3, 'VValue' =>'TEXT', 'VDesc' => "Text", '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' => 2, 'VValue' =>'THOLD', 'VDesc' => "Threshold", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 44,'VOrder' => 3, 'VValue' =>'VSET', 'VDesc' => "Value Set", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 44,'VOrder' => 4, 'VValue' =>'TEXT', 'VDesc' => "Text.", 'VCategory' => 1, 'CreateDate' => "$now"]
['VSetID' => 44,'VOrder' => 1, 'VValue' =>'NMRC', 'VDesc' => "Numeric", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 44,'VOrder' => 2, 'VValue' =>'TEXT', 'VDesc' => "Text", 'VCategory' => 1, 'CreateDate' => "$now"],
['VSetID' => 45,'VOrder' => 1, 'VValue' =>'REF', 'VDesc' => "Reference Range", '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);
$data = [
['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' => 'Gender','VSDesc' =>'patient.Gender', 'VSetID' => '3', '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, refnum.Sex', 'VSetID' => '3', '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' => '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' => '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' => 'Requested Entity','VSDesc' =>'order.ReqEntity', 'VSetID' => '9', 'CreateDate' => "$now"],
['VSName' => 'Order Priority','VSDesc' =>'order.Priority', 'VSetID' => '10', 'CreateDate' => "$now"],
['VSName' => 'Order Status','VSDesc' =>'orderststatus.OrderStatus', 'VSetID' => '11', 'CreateDate' => "$now"],
['VSName' => 'Location Type','VSDesc' =>'location.LocationType', 'VSetID' => '12', 'CreateDate' => "$now"],
['VSName' => 'Additive','VSDesc' =>'containertype.Additive specimenprep.Additive', 'VSetID' => '13', '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' => 'Container Class','VSDesc' =>'containertype.ConClass', 'VSetID' => '14', 'CreateDate' => "$now"],
['VSName' => 'Specimen Type','VSDesc' =>'testdeftech.SpcType refnum. SpcType refthold.SpcType', 'VSetID' => '15', 'CreateDate' => "$now"],
['VSName' => 'Unit','VSDesc' =>'spcdef.Unit specimens.Unit specimenstatus.Unit specimenprep.AddUnit', 'VSetID' => '16', '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' => 'GenerateBy','VSDesc' =>'specimens. GenerateBy', 'VSetID' => '17', '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 Condition','VSDesc' =>'specimenstatus.SpcCon', 'VSetID' => '21', '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' => 'Container Size','VSDesc' =>'specimencollection.CntSize', 'VSetID' => '25', 'CreateDate' => "$now"],
['VSName' => 'Fasting Status','VSDesc' =>'specimencollection.Fasting', 'VSetID' => '26', 'CreateDate' => "$now"],
['VSName' => 'Test Type','VSDesc' =>'testdefsite.Type', 'VSetID' => '27', 'CreateDate' => "$now"],
['VSName' => 'Result Unit','VSDesc' =>'testdefsite.Unit1 testdefsite.Unit2', 'VSetID' => '28', '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' => 'Formula Languange','VSDesc' =>'testdefcal.FormulaLang', 'VSetID' => '29', 'CreateDate' => "$now"],
['VSName' => 'Race','VSDesc' =>'patient.Race', 'VSetID' => '30', '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' => 'Site Type','VSDesc' =>'Site.SiteType', 'VSetID' => '37', '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' => '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' => 'Result Type','VSDesc' =>'testdeftech.ResultType', 'VSetID' => '43', '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);
}

View File

@ -16,8 +16,13 @@ class AuthFilter implements FilterInterface
$key = getenv('JWT_SECRET');
$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
if (!$token) {
if ($isApiRequest) {
return Services::response()
->setStatusCode(401)
->setJSON([
@ -25,6 +30,9 @@ class AuthFilter implements FilterInterface
'message' => 'Unauthorized: Token not found'
]);
}
// Redirect to login for page requests
return redirect()->to('/v2/login');
}
try {
// Decode JWT : jika error maka akan mentrigger catch
@ -36,6 +44,7 @@ class AuthFilter implements FilterInterface
// $request->userData = $decoded;
} catch (\Exception $e) {
if ($isApiRequest) {
return Services::response()
->setStatusCode(401)
->setJSON([
@ -43,6 +52,9 @@ class AuthFilter implements FilterInterface
'message' => 'Unauthorized: ' . $e->getMessage()
]);
}
// Redirect to login for page requests
return redirect()->to('/v2/login');
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)

View File

@ -10,6 +10,7 @@ class Cors implements FilterInterface
{
protected $allowedOrigins = [
'http://localhost:5173',
'http://localhost',
'https://clqms01.services-summit.my.id',
];
@ -19,6 +20,11 @@ class Cors implements FilterInterface
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$response = service('response');
// Allow same-origin requests (when no Origin header is present)
if (empty($origin)) {
return null;
}
if (in_array($origin, $this->allowedOrigins)) {
$response->setHeader('Access-Control-Allow-Origin', $origin);
$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 {
protected $table = 'locationaddress';
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 $createdField = 'CreateDate';

View File

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

View File

@ -31,7 +31,7 @@ class AccountModel extends BaseModel {
}
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,
country.VValue as CountryName, country.VID as country')
->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('valueset country', 'country.VID=account.Country', 'left')
->where('account.AccountID', $AccountID)
->findAll();
return $rows;
->first();
return $row;
}
}

View File

@ -30,11 +30,11 @@ class DepartmentModel extends BaseModel {
}
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('site', 'site.SiteID=department.SiteID', 'left')
->where('department.DepartmentID', $DepartmentID)
->findAll();
return $rows;
->first();
return $row;
}
}

View File

@ -32,13 +32,13 @@ class SiteModel extends BaseModel {
}
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('site s1', 's1.SiteID=site.Parent', 'left')
->join('valueset sitetype', 'site.SiteTypeID=sitetype.VID', 'left')
->join('valueset siteclass', 'site.SiteClassID=siteclass.VID', 'left')
->where('site.SiteID', $SiteID)
->findAll();
return $rows;
->first();
return $row;
}
}

View File

@ -30,13 +30,13 @@ class WorkstationModel extends BaseModel {
}
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('department', 'department.DepartmentID=workstation.DepartmentID', 'left')
->join('valueset wstype', 'wstype.VID=workstation.Type', 'left')
->join('valueset enable', 'enable.VID=workstation.Enable', 'left')
->where('workstation.WorkstationID', $WorkstationID)
->findAll();
return $rows;
->first();
return $row;
}
}

View File

@ -20,11 +20,11 @@ class PatVisitModel extends BaseModel {
protected $visnum_prefix = "DV";
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('patvisitadt', 'patvisitadt.InternalPVID=patvisit.InternalPVID', 'left')
->where('patvisit.PVID',$PVID)->findAll();
return $rows;
->where('patvisit.PVID',$PVID)->first();
return $row;
}
public function showByPatient($InternalPID) {

View File

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

View File

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

View File

@ -33,11 +33,11 @@ class ContainerDefModel extends BaseModel {
}
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 vscla', 'vscla.VID=containerdef.ConClass', 'left')
->join('valueset vsadd', 'vsadd.VID=containerdef.Additive', 'left')
->where('ConDefID', $ConDefID)->findAll();
return $rows;
->where('ConDefID', $ConDefID)->first();
return $row;
}
}

View File

@ -7,8 +7,21 @@ use App\Models\BaseModel;
class TestDefCalModel extends BaseModel {
protected $table = 'testdefcal';
protected $primaryKey = 'TestCalID';
protected $allowedFields = ['SiteID', 'TestSiteID', 'DisciplineID', 'DepartmentID','FormulaCode', 'FormulaInput',
'Unit1', 'Factor', 'Unit2', 'Decimal' ,'CreateDate', 'EndDate'];
protected $allowedFields = [
'TestSiteID',
'DisciplineID',
'DepartmentID',
'FormulaInput',
'FormulaCode',
'RefType',
'Unit1',
'Factor',
'Unit2',
'Decimal',
'Method',
'CreateDate',
'EndDate'
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
@ -16,4 +29,50 @@ class TestDefCalModel extends BaseModel {
protected $useSoftDeletes = true;
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,7 +7,12 @@ use App\Models\BaseModel;
class TestDefGrpModel extends BaseModel {
protected $table = 'testdefgrp';
protected $primaryKey = 'TestGrpID';
protected $allowedFields = ['SiteID', 'TestSiteID', 'Member', 'CreateDate', 'EndDate'];
protected $allowedFields = [
'TestSiteID',
'Member',
'CreateDate',
'EndDate'
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
@ -15,4 +20,30 @@ class TestDefGrpModel extends BaseModel {
protected $useSoftDeletes = true;
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 {
protected $table = 'testdefsite';
protected $primaryKey = 'TestSiteID';
protected $allowedFields = ['SiteID', 'TestSiteCode', 'TestSiteName', 'TestType', 'Description', 'SeqScr', 'SeqRpt', 'IndentLeft',
'VisibleScr', 'VisibleRpt', 'CountStat', 'CreateDate', 'EndDate'];
protected $allowedFields = [
'SiteID',
'TestSiteCode',
'TestSiteName',
'TestType',
'Description',
'SeqScr',
'SeqRpt',
'IndentLeft',
'FontStyle',
'VisibleScr',
'VisibleRpt',
'CountStat',
'CreateDate',
'StartDate',
'EndDate'
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = '';
protected $updatedField = 'StartDate';
protected $useSoftDeletes = true;
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")
->findAll();
return $rows;
->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);
}
return $builder->orderBy('testdefsite.SeqScr', 'ASC')->findAll();
}
/**
* Get single test with all related details based on TestType
*/
public function getTest($TestSiteID) {
$db = \Config\Database::connect();
$row = $this->select("testdefsite.*, valueset.VValue as TypeCode, valueset.VDesc as TypeName")
->join("valueset", "valueset.VID=testdefsite.TestType", "left")
->where("testdefsite.TestSiteID", $TestSiteID)
@ -31,12 +78,56 @@ class TestDefSiteModel extends BaseModel {
if (!$row) return null;
if ($row['TypeCode'] == 'Calculated') {
$row['testdefcal'] = $this->db->query("select * from testdefcal where TestSiteID='$TestSiteID'")->getResultArray();
} elseif ($row['TypeCode'] == 'GROUP') {
$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();
} else {
$row['testdeftech'] = $this->db->query("select * from testdeftech where TestSiteID='$TestSiteID'")->getResultArray();
$typeCode = $row['TypeCode'] ?? '';
// Load related details based on TestType
if ($typeCode === 'CALC') {
// Load calculation details with joined discipline and department
$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;

View File

@ -7,8 +7,25 @@ use App\Models\BaseModel;
class TestDefTechModel extends BaseModel {
protected $table = 'testdeftech';
protected $primaryKey = 'TestTechID';
protected $allowedFields = ['SiteID', 'TestSiteID', 'DisciplineID', 'DepartmentID', 'WorkstationID', 'EquipmentID', 'VSet', 'SpcType',
'ReqQty', 'ReqQtyUnit', 'Unit1', 'Factor', 'Unit2', 'Decimal', 'CollReq', 'Method', 'ExpectedTAT', 'CreateDate', 'EndDate'];
protected $allowedFields = [
'TestSiteID',
'DisciplineID',
'DepartmentID',
'ResultType',
'RefType',
'VSet',
'ReqQty',
'ReqQtyUnit',
'Unit1',
'Factor',
'Unit2',
'Decimal',
'CollReq',
'Method',
'ExpectedTAT',
'CreateDate',
'EndDate'
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
@ -16,4 +33,50 @@ class TestDefTechModel extends BaseModel {
protected $useSoftDeletes = true;
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 {
protected $table = 'testmap';
protected $primaryKey = 'TestMapID';
protected $allowedFields = ['HostType', 'HostID', 'HostDataSource', 'HostTestCode', 'HostTestName',
'ClientType', 'ClientID', 'ClientTestCode', 'ClientTestName', 'CreateDate', 'EndDate' ];
protected $allowedFields = [
'TestSiteID',
'HostType',
'HostID',
'HostDataSource',
'HostTestCode',
'HostTestName',
'ClientType',
'ClientID',
'ClientDataSource',
'ConDefID',
'ClientTestCode',
'ClientTestName',
'CreateDate',
'EndDate'
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
@ -16,4 +30,67 @@ class TestMapModel extends BaseModel {
protected $useSoftDeletes = true;
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';
public function getValueSetDefs($param = null) {
// Get item counts subquery
$itemCounts = $this->db->table('valueset')
->select('VSetID, COUNT(*) as ItemCount')
->where('EndDate IS NULL')
->groupBy('VSetID');
$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) {
$rows = $this->like('VSName', $param, 'both')
->orlike('VSDesc', $param, 'both')
->findAll();
} else {
$rows = $this->findAll();
$builder->groupStart()
->like('vd.VSName', $param, 'both')
->orLike('vd.VSDesc', $param, 'both')
->groupEnd();
}
$rows = $builder->get()->getResultArray();
return $rows;
}

View File

@ -15,18 +15,30 @@ class ValueSetModel extends BaseModel {
protected $useSoftDeletes = true;
protected $deletedField = 'EndDate';
public function getValueSets($param = null) {
$this->select("valueset.*, v1.VDesc as VCategoryName")
->join('valueset v1', 'valueset.VCategory = v1.VID', 'LEFT');
public function getValueSets($param = null, $page = null, $limit = 50, $VSetID = null) {
$this->select("valueset.*, valuesetdef.VSName as VCategoryName")
->join('valuesetdef', 'valueset.VSetID = valuesetdef.VSetID', 'LEFT');
if ($VSetID !== null) {
$this->where('valueset.VSetID', $VSetID);
}
if ($param !== null) {
$this
->groupStart()
->like('VValue', $param, 'both')
->orlike('VDesc', $param, 'both')
$this->groupStart()
->like('valueset.VValue', $param, 'both')
->orLike('valueset.VDesc', $param, 'both')
->orLike('valuesetdef.VSName', $param, 'both')
->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) {

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