feat(api): transition to headless architecture and enhance order management

This commit marks a significant architectural shift, transitioning the CLQMS backend to a fully headless REST API. All view-related components have been removed to focus solely on providing a robust, stateless API for clinical laboratory workflows.

### Architectural Changes

- **Headless API Transition:**
    - Removed all view files (`app/Views/v2`), associated page controllers (`PagesController`), and routes (`Routes.php`). The application no longer serves a front-end UI.
    - The root endpoint (`/`) now returns a simple "Backend Running" status message.

- **Developer Tooling & Guidance:**
    - Replaced `CLAUDE.md` with `GEMINI.md` to provide updated context and instructional guidelines for Gemini agents.
    - Updated `.serena/project.yml` with project configuration.

### Feature Enhancements

- **Advanced Order Management (`OrderTestModel`):**
    - **Test Expansion:** The `createOrder` process now automatically expands `GROUP` (panel) tests into their individual components and recursively includes all parameter dependencies for `CALC` (calculated) tests.
    - **Order Comments:** Added support for attaching comments to an order via the `ordercom` table.
    - **Status Tracking:** Order status updates are now correctly recorded in the `orderstatus` table.
    - **Schema Alignment:** Switched from `OrderID` to `InternalOID` as the primary key for internal operations.

- **Reference Range Refactor (`TestsController`):**
    - Simplified reference range logic by consolidating `refthold` and `refvset` into the main `refnum` and `reftxt` tables.
    - Standardized `RefType` handling to support `NMRC`, `TEXT`, `THOLD`, and `VSET` codes from the `reference_type` ValueSet.

### Other Changes

- **Documentation:**
    - `PRD.md`, `README.md`, and `TODO.md` were updated to reflect the headless architecture, refined scope, and current project priorities.
- **Database:**
    - Removed obsolete `RefTHoldID` and `RefVSetID` columns from the `patres` table migration.
- **Testing:**
    - Added new feature tests for `ContactController`, `OrganizationController`, and `TestsController`.
This commit is contained in:
mahdahar 2026-01-31 09:27:32 +07:00
parent fcdbc3f20a
commit 40ecb4e6e8
55 changed files with 704 additions and 10029 deletions

View File

@ -84,6 +84,27 @@ excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project # initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand). # (contrary to the memories, which are loaded on demand).
initial_prompt: "" initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "clqms01" project_name: "clqms01"
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: [] included_optional_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []

200
CLAUDE.md
View File

@ -1,200 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
---
## Tool Preference: Use Serena MCP
**Always prioritize Serena MCP tools for code operations to minimize tool calls:**
- **Use `find_symbol` / `get_symbols_overview`** instead of `Read` for exploring code structure
- **Use `replace_symbol_body`** instead of `Edit` for modifying functions, methods, classes
- **Use `insert_before_symbol` / `insert_after_symbol`** for adding new code symbols
- **Use `search_for_pattern`** instead of `Grep` for searching code patterns
- **Use `list_dir` / `find_file`** instead of `Glob` for file discovery
This leverages semantic code understanding for this PHP codebase.
---
## Common Commands
### Testing
```bash
# Run all tests
vendor/bin/phpunit
# Run specific test file
vendor/bin/phpunit tests/feature/UniformShowTest.php
# Run tests in a directory
vendor/bin/phpunit app/Models
# Generate code coverage report
vendor/bin/phpunit --colors --coverage-text=tests/coverage.txt --coverage-html=tests/coverage/ -d memory_limit=1024m
```
### Database
```bash
# Run migrations
spark migrate
# Rollback migrations
spark migrate:rollback
# Seed database
spark db:seed DBSeeder
# Refresh all migrations and seed
spark migrate:refresh --seed
```
### CodeIgniter CLI
```bash
# General help
spark help
# Generate code (scaffolding)
spark make:model ModelName
spark make:controller ControllerName
spark make:migration MigrationName
```
### API Documentation
```bash
# Keep api-docs.yaml updated whenever controllers change
# File: public/api-docs.yaml
# After modifying controllers, update the OpenAPI schema definitions
# to reflect new field names, types, and response formats
```
---
## Architecture Overview
### Core Pattern: MVC + Libraries
CLQMS follows CodeIgniter 4 conventions with these key architectural patterns:
**Controllers** (`app/Controllers/`)
- Organized by domain: `Patient/`, `Organization/`, `Specimen/`, `Test/`, `Contact/`
- Root-level controllers for cross-domain concerns: `AuthController`, `EdgeController`, `ValueSetController`
- All controllers extend `BaseController` which provides request/response helpers
**Models** (`app/Models/`)
- Organized by domain matching controllers: `Patient/`, `Organization/`, `Specimen/`, `Test/`, etc.
- All models extend `BaseModel` which provides automatic UTC date normalization
- Model callbacks: `beforeInsert/Update` normalize dates to UTC, `afterFind/Insert/Update` convert to UTC ISO format
**Libraries** (`app/Libraries/`)
- `ValueSet` - Static lookup system using JSON files (see "Lookup System" below)
### Lookup System: ValueSet Library
The system uses a **JSON file-based lookup system** instead of database queries for static values.
**Location:** `app/Libraries/Data/*.json` (44+ JSON files)
**Key Files:**
- `_meta.json` - Metadata about lookup definitions
- `sex.json`, `order_status.json`, `specimen_type.json`, `test_type.json`, etc.
**Usage:**
```php
use App\Libraries\ValueSet;
// Get dropdown-formatted options
$options = ValueSet::get('sex'); // [["value"=>"1","label"=>"Female"],...]
// Get raw data
$raw = ValueSet::getRaw('sex'); // [["key"=>"1","value"=>"Female"],...]
// Get label for a specific key
$label = ValueSet::getLabel('sex', '1'); // "Female"
// Transform database records with lookup text labels
$data = ValueSet::transformLabels($patients, ['Sex' => 'sex']);
// Clear cache after modifying JSON files
ValueSet::clearCache();
```
**When to use ValueSet vs API:**
- **ValueSet library** - Static values that rarely change (fast, cached)
- **API `/api/valueset*`** - Dynamic values managed by admins at runtime
### Database Migrations Structure
Migrations are numbered sequentially starting with `2026-01-01-`:
| Migration | Tables Created |
|-----------|----------------|
| 000001 | valueset, counter, containerdef, occupation, specialty |
| 000002 | account, site, location, discipline, department |
| 000003 | patient, patidentifier, pataddress, patcontact |
| 000004 | contact, contactdetail, userdevices, loginattempts |
| 000005 | patvisit, patinsurance |
| 000006 | porder, orderitem |
| 000007 | specimen, specmenactivity, containerdef |
| 000008 | testdefinition, testactivity, refnum, reftxt |
| 000009 | patresult, patresultdetail, patresultcomment |
| 000010 | edgeres, edgestatus, edgeack, workstation |
### Authentication & Authorization
- **JWT-based authentication** using `firebase/php-jwt`
- **AuthFilter** - Route filter protecting API endpoints
- **AuthController** - Login/logout endpoints (`POST /api/login`, `POST /api/logout`)
- **AuthV2Controller** - V2 authentication endpoints
### Edge API (Instrument Integration)
The `EdgeController` provides endpoints for laboratory instrument integration via `tiny-edge` middleware:
- `POST /api/edge/results` - Receive instrument results (stored in `edgeres` table)
- `GET /api/edge/orders` - Fetch pending orders for instruments
- `POST /api/edge/orders/:id/ack` - Acknowledge order delivery
- `POST /api/edge/status` - Log instrument status updates
**Workflow:** `Instrument → tiny-edge → edgeres table → [Processing] → patresult table`
### Test Types System
Tests in `testdefinition` table support multiple types via `TestType` field:
| Code | Type | Description |
|------|------|-------------|
| TEST | Technical | Individual lab test with specs |
| PARAM | Parameter | Non-lab measurement |
| CALC | Calculated | Test with formula |
| GROUP | Panel/Profile | Contains multiple tests |
| TITLE | Section | Report organization header |
### Reference Range Architecture
Reference ranges support multiple types for result validation:
| Type | Table | Purpose |
|------|-------|---------|
| Numeric | `refnum` | Ranges with age/sex criteria |
| Threshold | `refthold` | Critical values |
| Text | `reftxt` | Text-based references |
| Value Set | `refvset` | Coded references |
### Routes Organization
Routes are defined in `app/Config/Routes.php`:
- API routes: `/api/{resource}`
- Auth-protected routes use `AuthFilter`
---
## Project Structure Notes
- **Language:** PHP 8.1+ (PSR-compliant)
- **Framework:** CodeIgniter 4
- **Database:** MySQL with migration-based schema management
- **Testing:** PHPUnit 10.5+ (tests in `tests/` directory)
- **Entry point:** `public/index.php` (web), `spark` (CLI)
- **Environment config:** `.env` file (copy from `env` template)

115
GEMINI.md Normal file
View File

@ -0,0 +1,115 @@
# CLQMS (Clinical Laboratory Quality Management System) - Gemini Context
This file provides context and instructional guidelines for Gemini agents working on the CLQMS repository.
## 1. Project Overview
**CLQMS** is a **headless REST API backend** for a Clinical Laboratory Quality Management System. It manages the complete laboratory workflow: patient registration, ordering, specimen tracking, result entry/verification, and instrument integration.
* **Type:** API-only Backend (No View Layer).
* **Framework:** CodeIgniter 4 (PHP 8.1+).
* **Database:** MySQL.
* **Authentication:** JWT (Stateless).
* **Architecture:** Modular MVC with a file-based Lookup Library.
## 2. Technical Stack
* **Language:** PHP 8.1+ (PSR-compliant)
* **Framework:** CodeIgniter 4
* **Dependencies:** `firebase/php-jwt` (Auth), `phpunit/phpunit` (Testing)
* **Database:** MySQL (Managed via Migrations)
* **Entry Point:** `public/index.php` (Web), `spark` (CLI)
## 3. Development Workflow & Conventions
### Tool Usage Guidelines
**Critical:** Prioritize semantic code analysis tools over generic file reading to minimize context window usage and improve accuracy.
* **Explore Code:** Use `find_symbol` or `get_symbols_overview`.
* **Modify Code:** Use `replace_symbol_body` for functions/classes. Use `replace_content` (regex) for small tweaks.
* **Add Code:** Use `insert_before_symbol` / `insert_after_symbol`.
* **Search:** Use `search_for_pattern`.
* **File Discovery:** Use `list_dir` / `find_file`.
### Common Commands
**Testing:**
```bash
# Run all tests
vendor/bin/phpunit
# Run specific test file
vendor/bin/phpunit tests/feature/UniformShowTest.php
# Run tests in a directory
vendor/bin/phpunit app/Models
```
**Database & Migrations:**
```bash
# Run migrations
spark migrate
# Rollback migrations
spark migrate:rollback
# Seed database
spark db:seed DBSeeder
# Refresh all (Reset DB)
spark migrate:refresh --seed
```
**Code Generation:**
```bash
spark make:model ModelName
spark make:controller ControllerName
spark make:migration MigrationName
```
## 4. Key Architectural Patterns
### MVC Structure
* **Controllers (`app/Controllers/`)**: Organized by domain (Patient, Order, Test, etc.). All extend `BaseController`.
* **Models (`app/Models/`)**: Domain-specific data access. All extend `BaseModel`.
* **UTC Handling:** Models automatically normalize dates to UTC on save and format to UTC ISO on retrieve.
### Lookup System (`App\Libraries\ValueSet`)
* **Mechanism:** JSON file-based static data storage. **Do not use database queries for static lookups.**
* **Location:** `app/Libraries/Data/valuesets/*.json`.
* **Usage:**
```php
use App\Libraries\ValueSet;
$options = ValueSet::get('gender'); // Returns dropdown options
$label = ValueSet::getLabel('gender', '1'); // Returns 'Female'
```
* **Cache:** System caches these files. Run `ValueSet::clearCache()` if files are modified manually (rare).
### Edge API (Instrument Integration)
* **Purpose:** Middleware for laboratory analyzers.
* **Flow:** Instrument -> `tiny-edge` -> `POST /api/edge/results` -> `edgeres` table -> Processing -> `patresult` table.
* **Controllers:** `EdgeController`.
### Database Schema
* **Migrations:** Sequentially numbered (e.g., `2026-01-01-000001`).
* **Master Data:** `valueset`, `testdef*` (test definitions), `ref*` (reference ranges).
* **Transactions:** `patient`, `porder` (orders), `specimen`, `patresult`.
## 5. Documentation Map
* **`README.md`**: High-level API overview and endpoint list.
* **`PRD.md`**: Detailed Product Requirements Document. **Read this for business logic queries.**
* **`CLAUDE.md`**: Original developer guide (source of these conventions).
* **`TODO.md`**: Current project status and roadmap.
* **`app/Config/Routes.php`**: API Route definitions.
## 6. Testing Strategy
* **Framework:** PHPUnit 10.5+.
* **Location:** `tests/`.
* **Coverage:** Aim for high coverage on core logic (Models, Libraries).
* **Configuration:** `phpunit.xml.dist`.
## 7. Configuration
* **Environment:** managed via `.env` (template in `env`).
* **Database Config:** `app/Config/Database.php` (uses `.env` variables).

4
PRD.md
View File

@ -498,7 +498,7 @@ POST /api/edge/results
|-------|---------|------------| |-------|---------|------------|
| patient | Patient registry | InternalPID, PatientID, NameFirst, NameLast, Sex, Birthdate | | patient | Patient registry | InternalPID, PatientID, NameFirst, NameLast, Sex, Birthdate |
| porder | Laboratory orders | OrderID, InternalPID, OrderStatus, Priority | | porder | Laboratory orders | OrderID, InternalPID, OrderStatus, Priority |
| orderitem | Order tests | OrderID, TestID |
| specimen | Specimens | SID, SpecimenID, SpecimenStatus | | specimen | Specimens | SID, SpecimenID, SpecimenStatus |
| patresult | Patient results | ResultID, OrderID, TestID, ResultValue | | patresult | Patient results | ResultID, OrderID, TestID, ResultValue |
| patresultdetail | Result details | ResultID, ParameterID, Value | | patresultdetail | Result details | ResultID, ParameterID, Value |
@ -684,7 +684,7 @@ The MVP is considered complete when:
### 10.1 Open Questions ### 10.1 Open Questions
| Question | Impact | Target Date | | Question | Impact | Target Date |
|----------|--------|-------------| |----------|--------|-------------|
| Reference range types (refthold, refvset) - are they needed for MVP? | Medium | Phase 0 |
| Multi-site deployment requirements? | High | Phase 0 | | Multi-site deployment requirements? | High | Phase 0 |
| Specific instrument integrations needed? | High | Phase 2 | | Specific instrument integrations needed? | High | Phase 2 |
| Report format requirements (PDF/HTML)? | Medium | Phase 1 | | Report format requirements (PDF/HTML)? | Medium | Phase 1 |

View File

@ -423,9 +423,7 @@ Reference Ranges define normal and critical values for test results. The system
| Type | Table | Description | | Type | Table | Description |
|------|-------|-------------| |------|-------|-------------|
| Numeric | `refnum` | Numeric ranges with age/sex criteria | | Numeric | `refnum` | Numeric ranges with age/sex criteria |
| Threshold | `refthold` | Critical threshold values |
| Text | `reftxt` | Text-based reference values | | Text | `reftxt` | Text-based reference values |
| Value Set | `refvset` | Coded reference values |
#### Numeric Reference Range Structure #### Numeric Reference Range Structure
@ -513,7 +511,7 @@ valuesetdef (VSetDefID, VSName, VSDesc)
| Category | Tables | Purpose | | Category | Tables | Purpose |
|----------|--------|---------| |----------|--------|---------|
| Tests | `testdefsite`, `testdeftech`, `testdefcal`, `testdefgrp`, `testmap` | Test definitions | | Tests | `testdefsite`, `testdeftech`, `testdefcal`, `testdefgrp`, `testmap` | Test definitions |
| Reference Ranges | `refnum`, `refthold`, `reftxt`, `refvset` | Result validation | | Reference Ranges | `refnum`, `reftxt` | Result validation |
| Value Sets | `valuesetdef`, `valueset` | Configurable options | | Value Sets | `valuesetdef`, `valueset` | Configurable options |
--- ---

117
TODO.md
View File

@ -6,31 +6,16 @@
You **don't need** all master data finished to create an order. Here's what's actually required: You **don't need** all master data finished to create an order. Here's what's actually required:
### Minimum Required (4 Tables) ### Minimum Required (2 Tables)
These are the only tables that need database entries for a minimal setup. System lookups (status, priority, etc.) are handled by the `ValueSet` library using static JSON files in `app/Libraries/Data/`.
```sql ```sql
-- 1. Patient (already exists in codebase) -- 1. Patient (already exists in codebase)
-- Just need at least 1 patient -- Just need at least 1 patient
-- 2. Order Status Values (VSetID=11) -- 2. Counter for Order ID
INSERT INTO valueset (VID, VSetID, VValue, VDesc, VOrder) VALUES
(1, 11, 'ORD', 'Ordered', 1),
(2, 11, 'SCH', 'Scheduled', 2),
(3, 11, 'ANA', 'Analysis', 3),
(4, 11, 'VER', 'Verified', 4),
(5, 11, 'REV', 'Reviewed', 5),
(6, 11, 'REP', 'Reported', 6);
-- 3. Priority Values (VSetID=10)
INSERT INTO valueset (VID, VSetID, VValue, VDesc, VOrder) VALUES
(1, 10, 'S', 'Stat', 1),
(2, 10, 'R', 'Routine', 2),
(3, 10, 'A', 'ASAP', 3);
-- 4. Counter for Order ID
INSERT INTO counter (CounterName, CounterValue) VALUES ('ORDER', 1); INSERT INTO counter (CounterName, CounterValue) VALUES ('ORDER', 1);
-- Run seeder: php spark db:seed MinimalMasterDataSeeder
``` ```
### API Endpoints (No Auth Required for Testing) ### API Endpoints (No Auth Required for Testing)
@ -72,17 +57,20 @@ Order → Collection → Reception → Preparation → Analysis → Verification
--- ---
## Phase 1: Core Lab Workflow (Must Have) ## Phase 1: Order Management (Immediate Priority)
### 1.1 Order Management ### 1.1 Order Management
- [ ] Complete `OrderTestController` create/update/delete - [x] Complete `OrderTestController` create/update/delete
- [ ] Implement order ID generation (LLYYMMDDXXXXX format) - [x] Implement order ID generation (LLYYMMDDXXXXX format)
- [ ] Implement order attachment handling (ordercom, orderatt tables) - [x] Implement order comment handling (ordercom table)
- [ ] Add order status tracking (ORD→SCH→ANA→VER→REV→REP) - [ ] Implement order attachment handling (orderatt table)
- [ ] Create order test mapping (testmap table) - [x] Add order status tracking (ORD→SCH→ANA→VER→REV→REP)
- [ ] Add calculated test parameter auto-selection - [x] Create order test mapping (using patres table)
- [x] Add calculated test parameter auto-selection & Group expansion
### 1.2 Specimen Management ## Phase 2: Specimen & Result Management (Later)
### 2.1 Specimen Management
- [ ] Complete `SpecimenController` API - [ ] Complete `SpecimenController` API
- [ ] Implement specimen ID generation (OrderID + SSS + C) - [ ] Implement specimen ID generation (OrderID + SSS + C)
- [ ] Build specimen collection API (Collection status) - [ ] Build specimen collection API (Collection status)
@ -93,7 +81,7 @@ Order → Collection → Reception → Preparation → Analysis → Verification
- [ ] Build specimen dispatching API (Dispatch status) - [ ] Build specimen dispatching API (Dispatch status)
- [ ] Implement specimen condition tracking (HEM, ITC, LIP flags) - [ ] Implement specimen condition tracking (HEM, ITC, LIP flags)
### 1.3 Result Management ### 2.2 Result Management
- [ ] Complete `ResultController` with full CRUD - [ ] Complete `ResultController` with full CRUD
- [ ] Implement result entry API (numeric, text, valueset, range) - [ ] Implement result entry API (numeric, text, valueset, range)
- [ ] Implement result verification workflow (Technical + Clinical) - [ ] Implement result verification workflow (Technical + Clinical)
@ -102,17 +90,16 @@ Order → Collection → Reception → Preparation → Analysis → Verification
- [ ] Implement result rerun with AspCnt tracking - [ ] Implement result rerun with AspCnt tracking
- [ ] Add result report generation API - [ ] Add result report generation API
### 1.4 Patient Visit ### 2.3 Patient Visit
- [ ] Complete `PatVisitController` create/read - [ ] Complete `PatVisitController` create/read
- [ ] Implement patient visit to order linking - [ ] Implement patient visit to order linking
- [ ] Add admission/discharge/transfer (ADT) tracking - [ ] Add admission/discharge/transfer (ADT) tracking
- [ ] Add diagnosis linking (patdiag table) - [ ] Add diagnosis linking (patdiag table)
--- ---
## Phase 2: Instrument Integration (Must Have) ## Phase 3: Instrument Integration (Later)
### 2.1 Edge API ### 3.1 Edge API
- [ ] Complete `EdgeController` results endpoint - [ ] Complete `EdgeController` results endpoint
- [ ] Implement edgeres table data handling - [ ] Implement edgeres table data handling
- [ ] Implement edgestatus tracking - [ ] Implement edgestatus tracking
@ -121,14 +108,14 @@ Order → Collection → Reception → Preparation → Analysis → Verification
- [ ] Build order acknowledgment endpoint (/api/edge/orders/:id/ack) - [ ] Build order acknowledgment endpoint (/api/edge/orders/:id/ack)
- [ ] Build status logging endpoint (/api/edge/status) - [ ] Build status logging endpoint (/api/edge/status)
### 2.2 Test Mapping ### 3.2 Test Mapping
- [ ] Implement test mapping CRUD (TestMapModel) - [ ] Implement test mapping CRUD (TestMapModel)
- [ ] Build instrument code to LQMS test mapping - [ ] Build instrument code to LQMS test mapping
- [ ] Add many-to-one mapping support (e.g., glucose variations) - [ ] Add many-to-one mapping support (e.g., glucose variations)
--- ---
## Phase 3: Quality Management (Should Have) ## Phase 4: Quality Management (Should Have)
### 3.1 Quality Control ### 3.1 Quality Control
- [ ] Build QC result entry API - [ ] Build QC result entry API
@ -161,10 +148,10 @@ Order → Collection → Reception → Preparation → Analysis → Verification
- [ ] Test parameters - [ ] Test parameters
### 4.2 Reference Ranges ✅ Existing ### 4.2 Reference Ranges ✅ Existing
- [ ] Numeric ranges (refnum) - [x] Numeric ranges (refnum)
- [ ] Threshold ranges (refthold) - [x] Threshold ranges (refthold)
- [ ] Text ranges (reftxt) - [x] Text ranges (reftxt)
- [ ] Value set ranges (refvset) - [x] Value set ranges (refvset)
### 4.3 Organizations ✅ Existing ### 4.3 Organizations ✅ Existing
- [ ] Sites (SiteController) - [ ] Sites (SiteController)
@ -243,42 +230,42 @@ curl -X POST http://localhost:8080/api/ordertest/status \
## Success Criteria ## Success Criteria
### Functional ### Functional
- Patient registration works - [x] Patient registration works
- Test ordering generates valid OrderID and SID - [ ] Test ordering generates valid OrderID and SID
- Specimens track through collection → transport → reception → preparation → analysis - [ ] Specimens track through collection → transport → reception → preparation → analysis
- Results can be entered with reference range validation - [ ] Results can be entered with reference range validation
- Results verified through VER → REV → REP workflow - [ ] Results verified through VER → REV → REP workflow
- Instruments can send results via Edge API - [ ] Instruments can send results via Edge API
### Non-Functional ### Non-Functional
- JWT authentication required for all endpoints - [ ] JWT authentication required for all endpoints
- Soft delete (DelDate) on all transactions - [ ] Soft delete (DelDate) on all transactions
- UTC timezone for all datetime fields - [ ] UTC timezone for all datetime fields
- Audit logging for data changes - [ ] Audit logging for data changes
- < 2s response time for standard queries - [ ] < 2s response time for standard queries
--- ---
## Current Codebase Status ## Current Codebase Status
### Controllers (Need Work) ### Controllers (Need Work)
- OrderTestController - placeholder code, incomplete - [ ] OrderTestController - placeholder code, incomplete
- ResultController - only validates JWT - [ ] ResultController - only validates JWT
- PatientController - complete - [x] PatientController - complete
- TestsController - complete - [x] TestsController - complete
- PatVisitController - partial - [x] PatVisitController - partial
### Models (Good) ### Models (Good)
- PatientModel - complete - [x] PatientModel - complete
- TestDef* models - complete - [x] TestDef* models - complete
- Ref* models - complete - [x] Ref* models - complete
- ValueSet* models - complete - [x] ValueSet* models - complete
- SpecimenModel - exists, needs API - [x] SpecimenModel - exists, needs API
### Missing Controllers ### Missing Controllers
- SpecimenController - need full implementation - [ ] SpecimenController - need full implementation
- ResultController - need full implementation - [ ] ResultController - need full implementation
- QualityControlController - not exist - [ ] QualityControlController - not exist
- CalibrationController - not exist - [ ] CalibrationController - not exist
- AuditController - not exist - [ ] AuditController - not exist
- BillingController - not exist - [ ] BillingController - not exist

View File

@ -6,7 +6,7 @@ use CodeIgniter\Router\RouteCollection;
* @var RouteCollection $routes * @var RouteCollection $routes
*/ */
$routes->get('/', function () { $routes->get('/', function () {
return redirect()->to('/v2'); return "Backend Running";
}); });
$routes->options('(:any)', function () { $routes->options('(:any)', function () {
@ -19,8 +19,7 @@ $routes->group('api', ['filter' => 'auth'], function ($routes) {
$routes->get('sample', 'SampleController::index'); $routes->get('sample', 'SampleController::index');
}); });
// Public Routes (no auth required)
$routes->get('/v2/login', 'PagesController::login');
// Swagger API Documentation (public - no filters) // Swagger API Documentation (public - no filters)
$routes->add('swagger', 'PagesController::swagger'); $routes->add('swagger', 'PagesController::swagger');
@ -33,32 +32,7 @@ $routes->group('v2/auth', function ($routes) {
$routes->post('logout', 'AuthV2Controller::logout'); $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('valueset', 'PagesController::valueSetLibrary');
$routes->get('result/valueset', 'PagesController::resultValueSet');
$routes->get('result/valuesetdef', 'PagesController::resultValueSetDef');
});
// Faker // Faker
$routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1'); $routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1');

View File

@ -36,7 +36,7 @@ class OrderTestController extends Controller {
} else { } else {
$rows = $this->db->table('ordertest') $rows = $this->db->table('ordertest')
->where('DelDate', null) ->where('DelDate', null)
->orderBy('OrderDateTime', 'DESC') ->orderBy('TrnDate', 'DESC')
->get() ->get()
->getResultArray(); ->getResultArray();
} }
@ -134,7 +134,7 @@ class OrderTestController extends Controller {
if (isset($input['WorkstationID'])) $updateData['WorkstationID'] = $input['WorkstationID']; if (isset($input['WorkstationID'])) $updateData['WorkstationID'] = $input['WorkstationID'];
if (!empty($updateData)) { if (!empty($updateData)) {
$this->model->update($input['OrderID'], $updateData); $this->model->update($order['InternalOID'], $updateData);
} }
return $this->respond([ return $this->respond([

View File

@ -10,193 +10,7 @@ namespace App\Controllers;
*/ */
class PagesController extends BaseController 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'
]);
}
/**
* Value Set Library - Read-only
*/
public function valueSetLibrary()
{
return view('v2/valueset/valueset_index', [
'pageTitle' => 'Value Set Library',
'activePage' => 'valueset-library'
]);
}
/**
* Result Valueset - CRUD for valueset table
*/
public function resultValueSet()
{
return view('v2/result/valueset/resultvalueset_index', [
'pageTitle' => 'Result Valuesets',
'activePage' => 'result-valueset'
]);
}
/**
* Result Valueset Definition - CRUD for valuesetdef table
*/
public function resultValueSetDef()
{
return view('v2/result/valuesetdef/resultvaluesetdef_index', [
'pageTitle' => 'Valueset Definitions',
'activePage' => 'result-valuesetdef'
]);
}
/**
* Login page
*/
public function login()
{
return view('v2/auth/login', [
'pageTitle' => 'Login',
'activePage' => ''
]);
}
/** /**
* API Documentation / Swagger UI page * API Documentation / Swagger UI page

View File

@ -67,7 +67,7 @@ class ContainerDefController extends BaseController {
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try { try {
$ConDefID = $this->model->insert($input); $ConDefID = $this->model->insert($input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $ConDefID created successfully" ]); return $this->respondCreated([ 'status' => 'success', 'message' => "data $ConDefID created successfully", 'data' => $ConDefID ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }

View File

@ -44,6 +44,7 @@ class DemoOrderController extends Controller {
'Priority' => $input['Priority'] ?? 'R', 'Priority' => $input['Priority'] ?? 'R',
'OrderingProvider' => $input['OrderingProvider'] ?? 'Dr. Demo', 'OrderingProvider' => $input['OrderingProvider'] ?? 'Dr. Demo',
'DepartmentID' => $input['DepartmentID'] ?? 1, 'DepartmentID' => $input['DepartmentID'] ?? 1,
'Tests' => $input['Tests'] ?? []
]; ];
$orderID = $this->orderModel->createOrder($orderData); $orderID = $this->orderModel->createOrder($orderData);

View File

@ -148,7 +148,7 @@ class TestsController extends BaseController
$techData = $row['testdeftech'][0]; $techData = $row['testdeftech'][0];
$refType = $techData['RefType']; $refType = $techData['RefType'];
if ($refType === '1') { if ($refType === '1' || $refType === 'NMRC' || $refType === '3' || $refType === 'THOLD') {
$refnumData = $this->modelRefNum $refnumData = $this->modelRefNum
->where('TestSiteID', $id) ->where('TestSiteID', $id)
->where('EndDate IS NULL') ->where('EndDate IS NULL')
@ -159,24 +159,28 @@ class TestsController extends BaseController
return [ return [
'RefNumID' => $r['RefNumID'], 'RefNumID' => $r['RefNumID'],
'NumRefType' => $r['NumRefType'], 'NumRefType' => $r['NumRefType'],
'NumRefTypeLabel' => ValueSet::getLabel('numeric_ref_type', $r['NumRefType']), 'NumRefTypeLabel' => $r['NumRefType'] ? ValueSet::getLabel('numeric_ref_type', $r['NumRefType']) : '',
'RangeType' => $r['RangeType'], 'RangeType' => $r['RangeType'],
'RangeTypeLabel' => ValueSet::getLabel('range_type', $r['RangeType']), 'RangeTypeLabel' => $r['RangeType'] ? ValueSet::getLabel('range_type', $r['RangeType']) : '',
'Sex' => $r['Sex'], 'Sex' => $r['Sex'],
'SexLabel' => ValueSet::getLabel('gender', $r['Sex']), 'SexLabel' => $r['Sex'] ? ValueSet::getLabel('gender', $r['Sex']) : '',
'LowSign' => $r['LowSign'], 'LowSign' => $r['LowSign'],
'LowSignLabel' => ValueSet::getLabel('math_sign', $r['LowSign']), 'LowSignLabel' => $r['LowSign'] ? ValueSet::getLabel('math_sign', $r['LowSign']) : '',
'HighSign' => $r['HighSign'], 'HighSign' => $r['HighSign'],
'HighSignLabel' => ValueSet::getLabel('math_sign', $r['HighSign']), 'HighSignLabel' => $r['HighSign'] ? ValueSet::getLabel('math_sign', $r['HighSign']) : '',
'High' => $r['High'] !== null ? (int) $r['High'] : null, 'High' => $r['High'] !== null ? (float) $r['High'] : null,
'Flag' => $r['Flag'] 'Low' => $r['Low'] !== null ? (float) $r['Low'] : null,
'AgeStart' => (int) $r['AgeStart'],
'AgeEnd' => (int) $r['AgeEnd'],
'Flag' => $r['Flag'],
'Interpretation' => $r['Interpretation']
]; ];
}, $refnumData ?? []); }, $refnumData ?? []);
$row['rangeTypeOptions'] = ValueSet::getOptions('range_type'); $row['rangeTypeOptions'] = ValueSet::getOptions('range_type');
} }
if ($refType === '2') { if ($refType === '2' || $refType === 'TEXT' || $refType === '4' || $refType === 'VSET') {
$reftxtData = $this->modelRefTxt $reftxtData = $this->modelRefTxt
->where('TestSiteID', $id) ->where('TestSiteID', $id)
->where('EndDate IS NULL') ->where('EndDate IS NULL')
@ -187,17 +191,15 @@ class TestsController extends BaseController
return [ return [
'RefTxtID' => $r['RefTxtID'], 'RefTxtID' => $r['RefTxtID'],
'TxtRefType' => $r['TxtRefType'], 'TxtRefType' => $r['TxtRefType'],
'TxtRefTypeLabel' => ValueSet::getLabel('text_ref_type', $r['TxtRefType']), 'TxtRefTypeLabel' => $r['TxtRefType'] ? ValueSet::getLabel('text_ref_type', $r['TxtRefType']) : '',
'Sex' => $r['Sex'], 'Sex' => $r['Sex'],
'SexLabel' => ValueSet::getLabel('gender', $r['Sex']), 'SexLabel' => $r['Sex'] ? ValueSet::getLabel('gender', $r['Sex']) : '',
'AgeStart' => (int) $r['AgeStart'], 'AgeStart' => (int) $r['AgeStart'],
'AgeEnd' => (int) $r['AgeEnd'], 'AgeEnd' => (int) $r['AgeEnd'],
'RefTxt' => $r['RefTxt'], 'RefTxt' => $r['RefTxt'],
'Flag' => $r['Flag'] 'Flag' => $r['Flag']
]; ];
}, $reftxtData ?? []); }, $reftxtData ?? []);
// $row['txtRefTypeOptions'] = ValueSet::getOptions('text_ref_type');
} }
} }
} }
@ -431,13 +433,13 @@ class TestsController extends BaseController
$this->saveTechDetails($testSiteID, $details, $action, $typeCode); $this->saveTechDetails($testSiteID, $details, $action, $typeCode);
if (in_array($typeCode, ['TEST', 'PARAM']) && isset($details['RefType'])) { if (in_array($typeCode, ['TEST', 'PARAM']) && isset($details['RefType'])) {
$refType = $details['RefType']; $refType = (string) $details['RefType'];
if ($refType === '1' && isset($input['refnum']) && is_array($input['refnum'])) { if (($refType === '1' || $refType === 'NMRC' || $refType === '3' || $refType === 'THOLD') && isset($input['refnum']) && is_array($input['refnum'])) {
$this->saveRefNumRanges($testSiteID, $input['refnum'], $action, $input['SiteID'] ?? 1); $this->saveRefNumRanges($testSiteID, $input['refnum'], $action, $input['SiteID'] ?? 1);
} }
if ($refType === '2' && isset($input['reftxt']) && is_array($input['reftxt'])) { if (($refType === '2' || $refType === 'TEXT' || $refType === '4' || $refType === 'VSET') && isset($input['reftxt']) && is_array($input['reftxt'])) {
$this->saveRefTxtRanges($testSiteID, $input['reftxt'], $action, $input['SiteID'] ?? 1); $this->saveRefTxtRanges($testSiteID, $input['reftxt'], $action, $input['SiteID'] ?? 1);
} }
} }
@ -503,10 +505,11 @@ class TestsController extends BaseController
'AgeStart' => (int) ($range['AgeStart'] ?? 0), 'AgeStart' => (int) ($range['AgeStart'] ?? 0),
'AgeEnd' => (int) ($range['AgeEnd'] ?? 150), 'AgeEnd' => (int) ($range['AgeEnd'] ?? 150),
'LowSign' => !empty($range['LowSign']) ? $range['LowSign'] : null, 'LowSign' => !empty($range['LowSign']) ? $range['LowSign'] : null,
'Low' => !empty($range['Low']) ? (int) $range['Low'] : null, 'Low' => !empty($range['Low']) ? (float) $range['Low'] : null,
'HighSign' => !empty($range['HighSign']) ? $range['HighSign'] : null, 'HighSign' => !empty($range['HighSign']) ? $range['HighSign'] : null,
'High' => !empty($range['High']) ? (int) $range['High'] : null, 'High' => !empty($range['High']) ? (float) $range['High'] : null,
'Flag' => $range['Flag'] ?? null, 'Flag' => $range['Flag'] ?? null,
'Interpretation' => $range['Interpretation'] ?? null,
'Display' => $index, 'Display' => $index,
'CreateDate' => date('Y-m-d H:i:s') 'CreateDate' => date('Y-m-d H:i:s')
]); ]);

View File

@ -22,8 +22,6 @@ class CreateResults extends Migration {
'WorkstationID' => ['type' => 'INT', 'null' => true], 'WorkstationID' => ['type' => 'INT', 'null' => true],
'EquipmentID' => ['type' => 'INT', 'null' => true], 'EquipmentID' => ['type' => 'INT', 'null' => true],
'RefNumID' => ['type' => 'INT', 'null' => true], 'RefNumID' => ['type' => 'INT', 'null' => true],
'RefTHoldID' => ['type' => 'INT', 'null' => true],
'RefVSetID' => ['type' => 'INT', 'null' => true],
'RefTxtID' => ['type' => 'INT', 'null' => true], 'RefTxtID' => ['type' => 'INT', 'null' => true],
'CreateDate' => ['type' => 'DATETIME', 'null' => true], 'CreateDate' => ['type' => 'DATETIME', 'null' => true],
'EndDate' => ['type' => 'DATETIME', 'null' => true], 'EndDate' => ['type' => 'DATETIME', 'null' => true],

View File

@ -3,8 +3,8 @@
"VCategory": "System", "VCategory": "System",
"values": [ "values": [
{"key": "S", "value": "Stat"}, {"key": "S", "value": "Stat"},
{"key": "A", "value": "ASAP"},
{"key": "R", "value": "Routine"}, {"key": "R", "value": "Routine"},
{"key": "A", "value": "ASAP"},
{"key": "P", "value": "Preop"}, {"key": "P", "value": "Preop"},
{"key": "C", "value": "Callback"}, {"key": "C", "value": "Callback"},
{"key": "T", "value": "Timing critical"}, {"key": "T", "value": "Timing critical"},

View File

@ -2,6 +2,12 @@
"VSName": "Order Status", "VSName": "Order Status",
"VCategory": "System", "VCategory": "System",
"values": [ "values": [
{"key": "ORD", "value": "Ordered"},
{"key": "SCH", "value": "Scheduled"},
{"key": "ANA", "value": "Analysis"},
{"key": "VER", "value": "Verified"},
{"key": "REV", "value": "Reviewed"},
{"key": "REP", "value": "Reported"},
{"key": "A", "value": "Some, not all results available"}, {"key": "A", "value": "Some, not all results available"},
{"key": "CA", "value": "Order is cancelled"}, {"key": "CA", "value": "Order is cancelled"},
{"key": "CM", "value": "Order is completed"}, {"key": "CM", "value": "Order is completed"},

View File

@ -3,8 +3,8 @@
"VCategory": "System", "VCategory": "System",
"values": [ "values": [
{"key": "S", "value": "Stat"}, {"key": "S", "value": "Stat"},
{"key": "A", "value": "ASAP"},
{"key": "R", "value": "Routine"}, {"key": "R", "value": "Routine"},
{"key": "A", "value": "ASAP"},
{"key": "P", "value": "Preop"}, {"key": "P", "value": "Preop"},
{"key": "C", "value": "Callback"}, {"key": "C", "value": "Callback"},
{"key": "T", "value": "Timing critical"}, {"key": "T", "value": "Timing critical"},

View File

@ -3,6 +3,8 @@
"VCategory": "System", "VCategory": "System",
"values": [ "values": [
{"key": "NMRC", "value": "Numeric"}, {"key": "NMRC", "value": "Numeric"},
{"key": "TEXT", "value": "Text"} {"key": "TEXT", "value": "Text"},
{"key": "THOLD", "value": "Threshold"},
{"key": "VSET", "value": "Value Set"}
] ]
} }

View File

@ -5,21 +5,22 @@ use App\Models\BaseModel;
class OrderTestModel extends BaseModel { class OrderTestModel extends BaseModel {
protected $table = 'ordertest'; protected $table = 'ordertest';
protected $primaryKey = 'OrderID'; protected $primaryKey = 'InternalOID';
protected $useAutoIncrement = true;
protected $allowedFields = [ protected $allowedFields = [
'InternalOID',
'OrderID', 'OrderID',
'PlacerID',
'InternalPID', 'InternalPID',
'PatVisitID',
'OrderDateTime',
'Priority',
'OrderStatus',
'OrderedBy',
'OrderingProvider',
'SiteID', 'SiteID',
'SourceSiteID', 'PVADTID',
'DepartmentID', 'ReqApp',
'WorkstationID', 'Priority',
'BillingAccount', 'TrnDate',
'EffDate',
'CreateDate',
'EndDate',
'ArchiveDate',
'DelDate' 'DelDate'
]; ];
@ -52,29 +53,101 @@ class OrderTestModel extends BaseModel {
} }
public function createOrder(array $data): string { public function createOrder(array $data): string {
$orderID = $data['OrderID'] ?? $this->generateOrderID(); $orderID = $data['OrderID'] ?? $this->generateOrderID($data['SiteCode'] ?? '00');
$orderData = [ $orderData = [
'OrderID' => $orderID, 'OrderID' => $orderID,
'PlacerID' => $data['PlacerID'] ?? null,
'InternalPID' => $data['InternalPID'], 'InternalPID' => $data['InternalPID'],
'PatVisitID' => $data['PatVisitID'] ?? null, 'SiteID' => $data['SiteID'] ?? '1',
'OrderDateTime' => $data['OrderDateTime'] ?? date('Y-m-d H:i:s'), 'PVADTID' => $data['PatVisitID'] ?? $data['PVADTID'] ?? 0,
'ReqApp' => $data['ReqApp'] ?? null,
'Priority' => $data['Priority'] ?? 'R', 'Priority' => $data['Priority'] ?? 'R',
'OrderStatus' => $data['OrderStatus'] ?? 'ORD', 'TrnDate' => $data['OrderDateTime'] ?? $data['TrnDate'] ?? date('Y-m-d H:i:s'),
'OrderedBy' => $data['OrderedBy'] ?? null, 'EffDate' => $data['EffDate'] ?? date('Y-m-d H:i:s'),
'OrderingProvider' => $data['OrderingProvider'] ?? null,
'SiteID' => $data['SiteID'] ?? 1,
'SourceSiteID' => $data['SourceSiteID'] ?? 1,
'DepartmentID' => $data['DepartmentID'] ?? null,
'WorkstationID' => $data['WorkstationID'] ?? null,
'BillingAccount' => $data['BillingAccount'] ?? null,
'CreateDate' => date('Y-m-d H:i:s') 'CreateDate' => date('Y-m-d H:i:s')
]; ];
$this->insert($orderData); $internalOID = $this->insert($orderData);
// Handle Order Comments
if (!empty($data['Comment'])) {
$this->db->table('ordercom')->insert([
'InternalOID' => $internalOID,
'Comment' => $data['Comment'],
'CreateDate' => date('Y-m-d H:i:s')
]);
}
// Process Tests Expansion
if (isset($data['Tests']) && is_array($data['Tests'])) {
$testToOrder = [];
$testModel = new \App\Models\Test\TestDefSiteModel();
$grpModel = new \App\Models\Test\TestDefGrpModel();
$calModel = new \App\Models\Test\TestDefCalModel();
foreach ($data['Tests'] as $test) {
$testSiteID = $test['TestSiteID'] ?? $test['TestID'] ?? null;
if ($testSiteID) {
$this->expandTest($testSiteID, $testToOrder, $testModel, $grpModel, $calModel);
}
}
// Insert unique tests into patres
if (!empty($testToOrder)) {
$resModel = new \App\Models\PatResultModel();
foreach ($testToOrder as $tid => $tinfo) {
$resModel->insert([
'OrderID' => $internalOID,
'TestSiteID' => $tid,
'TestSiteCode' => $tinfo['TestSiteCode'],
'SID' => $orderID,
'SampleID' => $orderID,
'ResultDateTime' => $orderData['TrnDate'],
'CreateDate' => date('Y-m-d H:i:s')
]);
}
}
}
return $orderID; return $orderID;
} }
private function expandTest($testSiteID, &$testToOrder, $testModel, $grpModel, $calModel) {
if (isset($testToOrder[$testSiteID])) return;
$testInfo = $testModel->find($testSiteID);
if (!$testInfo) return;
$testToOrder[$testSiteID] = [
'TestSiteCode' => $testInfo['TestSiteCode'],
'TestType' => $testInfo['TestType']
];
// Handle Group Expansion
if ($testInfo['TestType'] === 'GROUP') {
$members = $grpModel->where('TestSiteID', $testSiteID)->findAll();
foreach ($members as $m) {
$this->expandTest($m['Member'], $testToOrder, $testModel, $grpModel, $calModel);
}
}
// Handle Calculated Test Dependencies
if ($testInfo['TestType'] === 'CALC') {
$calDetail = $calModel->where('TestSiteID', $testSiteID)->first();
if ($calDetail && !empty($calDetail['FormulaInput'])) {
$inputs = explode(',', $calDetail['FormulaInput']);
foreach ($inputs as $inputCode) {
$inputCode = trim($inputCode);
$inputTest = $testModel->where('TestSiteCode', $inputCode)->first();
if ($inputTest) {
$this->expandTest($inputTest['TestSiteID'], $testToOrder, $testModel, $grpModel, $calModel);
}
}
}
}
}
public function getOrder(string $orderID): ?array { public function getOrder(string $orderID): ?array {
return $this->select('*') return $this->select('*')
->where('OrderID', $orderID) ->where('OrderID', $orderID)
@ -87,16 +160,23 @@ class OrderTestModel extends BaseModel {
return $this->select('*') return $this->select('*')
->where('InternalPID', $internalPID) ->where('InternalPID', $internalPID)
->where('DelDate', null) ->where('DelDate', null)
->orderBy('OrderDateTime', 'DESC') ->orderBy('TrnDate', 'DESC')
->get() ->get()
->getResultArray(); ->getResultArray();
} }
public function updateStatus(string $orderID, string $status): bool { public function updateStatus(string $orderID, string $status): bool {
return $this->where('OrderID', $orderID)->update(['OrderStatus' => $status]); $order = $this->getOrder($orderID);
if (!$order) return false;
return (bool)$this->db->table('orderstatus')->insert([
'InternalOID' => $order['InternalOID'],
'OrderStatus' => $status,
'CreateDate' => date('Y-m-d H:i:s')
]);
} }
public function softDelete(string $orderID): bool { public function softDelete(string $orderID): bool {
return $this->where('OrderID', $orderID)->update(['DelDate' => date('Y-m-d H:i:s')]); return $this->where('OrderID', $orderID)->update(null, ['DelDate' => date('Y-m-d H:i:s')]);
} }
} }

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
class PatResultModel extends BaseModel {
protected $table = 'patres';
protected $primaryKey = 'ResultID';
protected $allowedFields = [
'SiteID',
'OrderID',
'InternalSID',
'SID',
'SampleID',
'TestSiteID',
'TestSiteCode',
'AspCnt',
'Result',
'SampleType',
'ResultDateTime',
'WorkstationID',
'EquipmentID',
'RefNumID',
'RefTxtID',
'CreateDate',
'EndDate',
'ArchiveDate',
'DelDate'
];
}

View File

@ -1,214 +0,0 @@
<!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

@ -1,153 +0,0 @@
<?= $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

@ -1,416 +0,0 @@
<!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 (Nested Group) -->
<li>
<div x-data="{
isOpen: valuesetOpen,
toggle() { this.isOpen = !this.isOpen; $root.layout().valuesetOpen = this.isOpen }
}" x-init="$watch('valuesetOpen', v => isOpen = v)">
<button @click="isOpen = !isOpen"
class="group w-full flex items-center justify-between"
:class="isParentActive('valueset') ? 'text-primary font-medium' : ''">
<div class="flex items-center gap-3">
<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>
</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/valueset') ?>"
:class="isActive('valueset') && !isParentActive('result/valueset') && !isParentActive('result/valuesetdef') ? 'active' : ''"
class="text-sm">Library Valuesets</a>
</li>
<li>
<a href="<?= base_url('/v2/result/valueset') ?>"
:class="isActive('result/valueset') ? 'active' : ''"
class="text-sm">Result Valuesets</a>
</li>
<li>
<a href="<?= base_url('/v2/result/valuesetdef') ?>"
:class="isActive('result/valuesetdef') ? 'active' : ''"
class="text-sm">Valueset Definitions</a>
</li>
</ul>
</div>
</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,
valuesetOpen: 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');
this.valuesetOpen = this.currentPath.includes('valueset');
// 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

@ -1,189 +0,0 @@
<!-- 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>
<select class="select" x-model="form.Country">
<option value="">Select Country</option>
<template x-for="opt in countryOptions" :key="opt.key">
<option :value="opt.key" x-text="opt.value"></option>
</template>
</select>
</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

@ -1,345 +0,0 @@
<?= $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: "",
countryOptions: [],
// 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();
await this.fetchCountryOptions();
},
// Fetch country options
async fetchCountryOptions() {
try {
const res = await fetch(`${BASEURL}api/valueset/account_Country`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.countryOptions = data.data || [];
} catch (err) {
console.error('Failed to fetch country options:', err);
}
},
// 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

@ -1,107 +0,0 @@
<!-- 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

@ -1,351 +0,0 @@
<?= $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

@ -1,107 +0,0 @@
<!-- 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

@ -1,352 +0,0 @@
<?= $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

@ -1,144 +0,0 @@
<!-- 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">Site Type</span>
</label>
<select class="select" x-model="form.SiteTypeID">
<option value="">Select Type</option>
<template x-for="opt in siteTypeOptions" :key="opt.key">
<option :value="opt.key" x-text="opt.value"></option>
</template>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">Site Class</span>
</label>
<select class="select" x-model="form.SiteClassID">
<option value="">Select Class</option>
<template x-for="opt in siteClassOptions" :key="opt.key">
<option :value="opt.key" x-text="opt.value"></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

@ -1,368 +0,0 @@
<?= $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>Type</th>
<th>Class</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.SiteTypeText || site.SiteTypeID || '-'"></td>
<td x-text="site.SiteClassText || site.SiteClassID || '-'"></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,
// Lookup Options
siteTypeOptions: [],
siteClassOptions: [],
// Lifecycle
async init() {
await this.fetchList();
await this.fetchAccounts();
await this.fetchSiteTypeOptions();
await this.fetchSiteClassOptions();
},
// Fetch site type options
async fetchSiteTypeOptions() {
try {
const res = await fetch(`${BASEURL}api/valueset/site_type`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.siteTypeOptions = data.data || [];
} catch (err) {
console.error('Failed to fetch site type options:', err);
}
},
// Fetch site class options
async fetchSiteClassOptions() {
try {
const res = await fetch(`${BASEURL}api/valueset/site_class`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.siteClassOptions = data.data || [];
} catch (err) {
console.error('Failed to fetch site class options:', err);
}
},
// 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

@ -1,131 +0,0 @@
<!-- 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>
<template x-for="opt in typeOptions" :key="opt.key">
<option :value="opt.key" x-text="opt.value"></option>
</template>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Status</span>
</label>
<select class="select" x-model="form.Enable">
<template x-for="opt in enableOptions" :key="opt.key">
<option :value="opt.key" x-text="opt.value"></option>
</template>
</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

@ -1,368 +0,0 @@
<?= $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.EnableText === 'Enabled' || ws.Enable === '1' ? 'badge-success' : 'badge-ghost'" x-text="ws.EnableText || (ws.Enable == 1 ? 'Enabled' : '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,
// Lookup Options
typeOptions: [],
enableOptions: [],
// Lifecycle
async init() {
await this.fetchList();
await this.fetchDepartments();
await this.fetchTypeOptions();
await this.fetchEnableOptions();
},
// Fetch workstation type options
async fetchTypeOptions() {
try {
const res = await fetch(`${BASEURL}api/valueset/ws_type`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.typeOptions = data.data || [];
} catch (err) {
console.error('Failed to fetch type options:', err);
}
},
// Fetch enable options
async fetchEnableOptions() {
try {
const res = await fetch(`${BASEURL}api/valueset/enable_disable`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.enableOptions = data.data || [];
} catch (err) {
console.error('Failed to fetch enable options:', err);
this.enableOptions = [
{ key: '1', value: 'Enabled' },
{ key: '0', value: 'Disabled' }
];
}
},
// 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

@ -1,143 +0,0 @@
<!-- 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>
<select class="select" x-model="form.Color">
<option value="">Select Color</option>
<template x-for="opt in colorOptions" :key="opt.key">
<option :value="opt.key" x-text="opt.value"></option>
</template>
</select>
</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>
<select class="select" x-model="form.Additive">
<option value="">Select Additive</option>
<template x-for="opt in additiveOptions" :key="opt.key">
<option :value="opt.key" x-text="opt.value"></option>
</template>
</select>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Class</span>
</label>
<select class="select" x-model="form.ConClass">
<option value="">Select Class</option>
<template x-for="opt in classOptions" :key="opt.key">
<option :value="opt.key" x-text="opt.value"></option>
</template>
</select>
</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

@ -1,385 +0,0 @@
<?= $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.ColorText || con.Color || '-'"></span>
</div>
</td>
<td x-text="con.AdditiveText || 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,
// Lookup Options
colorOptions: [],
additiveOptions: [],
classOptions: [],
// Lifecycle
async init() {
await this.fetchList();
await this.fetchSites();
await this.fetchColorOptions();
await this.fetchAdditiveOptions();
await this.fetchClassOptions();
},
// Fetch color options
async fetchColorOptions() {
try {
const res = await fetch(`${BASEURL}api/valueset/container_cap_color`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.colorOptions = data.data || [];
} catch (err) {
console.error('Failed to fetch color options:', err);
}
},
// Fetch additive options
async fetchAdditiveOptions() {
try {
const res = await fetch(`${BASEURL}api/valueset/additive`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.additiveOptions = data.data || [];
} catch (err) {
console.error('Failed to fetch additive options:', err);
}
},
// Fetch class options
async fetchClassOptions() {
try {
const res = await fetch(`${BASEURL}api/valueset/container_class`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.classOptions = data.data || [];
} catch (err) {
console.error('Failed to fetch class options:', err);
}
},
// 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

@ -1,138 +0,0 @@
<!-- 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

@ -1,317 +0,0 @@
<?= $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

@ -1,364 +0,0 @@
<!-- 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

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

View File

@ -1,386 +0,0 @@
<!-- 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

@ -1,344 +0,0 @@
<!-- 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

@ -1,758 +0,0 @@
<?= $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?.key ?? index)">
<option :value="type?.key" x-text="(type?.value || '')"></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/valueset/test_type`, {
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);
this.testTypes = [
{ key: 'TEST', value: 'Test' },
{ key: 'PARAM', value: 'Parameter' },
{ key: 'CALC', value: 'Calculated Test' },
{ key: 'GROUP', value: 'Group Test' },
{ key: 'TITLE', value: '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(value) {
const typeMap = {
'TEST': 'Test',
'PARAM': 'Parameter',
'CALC': 'Calculated Test',
'GROUP': 'Group',
'TITLE': 'Title'
};
return typeMap[value] || value || 'Test';
},
// Get type code from value
getTypeCode(value) {
return value || '';
},
// 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;
this.form = {
...this.form,
...testData,
TestType: testData.TestType || '',
TypeCode: testData.TestType || '',
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 };
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;
}
},
}
}
</script>
<?= $this->endSection() ?>

View File

@ -1,208 +0,0 @@
<!-- 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">Sex</span>
</label>
<select class="select" x-model="form.Sex">
<template x-for="opt in sexOptions" :key="opt.key">
<option :value="opt.key" x-text="opt.value"></option>
</template>
</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

@ -1,446 +0,0 @@
<?= $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>Sex</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.Sex === 'M' ? 'badge-info' : patient.Sex === 'F' ? 'badge-secondary' : 'badge-ghost'"
x-text="patient.SexText || patient.Sex || '-'"
></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: "",
Sex: "M",
Birthdate: "",
MobilePhone: "",
EmailAddress1: "",
Street_1: "",
City: "",
Province: "",
ZIP: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lookup Options
sexOptions: [],
// Lifecycle
async init() {
await this.fetchList();
await this.fetchSexOptions();
},
// Fetch sex options from valueset
async fetchSexOptions() {
try {
const res = await fetch(`${BASEURL}api/valueset/gender`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.sexOptions = data.data || [];
} catch (err) {
console.error('Failed to fetch sex options:', err);
this.sexOptions = [
{ key: 'M', value: 'Male' },
{ key: 'F', value: 'Female' }
];
}
},
// 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: "",
Sex: "M",
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

@ -1,130 +0,0 @@
<?= $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() ?>

View File

@ -1,149 +0,0 @@
<!-- Result 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"
>
<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>
<div class="space-y-6">
<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>
<div 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>
<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>
<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>
<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

@ -1,322 +0,0 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="resultValueSet()" x-init="init()">
<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-amber-600 to-orange-800 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-list-ul text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Result Valuesets</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage valueset items from database</p>
</div>
</div>
</div>
<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 valuesets..."
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 Item
</button>
</div>
</div>
</div>
<div class="card overflow-hidden">
<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 valuesets...</p>
</div>
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Category</th>
<th>Value</th>
<th>Description</th>
<th class="w-20 text-center">Order</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
<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 valuesets found</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>
<template x-for="item in list" :key="item.VID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="item.VID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="item.VCategoryName || '-'"></div>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="item.VValue || '-'"></div>
</td>
<td>
<span class="text-sm opacity-70" x-text="item.VDesc || '-'"></span>
</td>
<td class="text-center">
<span class="font-mono text-sm" x-text="item.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="editItem(item.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(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>
<?= $this->include('v2/result/valueset/resultvalueset_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 item <strong x-text="deleteTarget?.VValue"></strong>?
</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="deleteItem()" :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 resultValueSet() {
return {
loading: false,
list: [],
keyword: "",
defsList: [],
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
},
showDeleteModal: false,
deleteTarget: null,
deleting: false,
async init() {
await this.fetchList();
await this.fetchDefsList();
},
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/result/valueset?${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 = [];
this.showToast('Failed to load valuesets', 'error');
} finally {
this.loading = false;
}
},
async fetchDefsList() {
try {
const res = await fetch(`${BASEURL}api/result/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 = [];
}
},
showForm() {
this.isEditing = false;
this.form = {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
};
this.errors = {};
this.showModal = true;
},
async editItem(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/result/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 ? 'PUT' : 'POST';
const url = this.isEditing ? `${BASEURL}api/result/valueset/${this.form.VID}` : `${BASEURL}api/result/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();
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(err);
this.errors = { general: 'Failed to save item' };
this.showToast('Failed to save item', 'error');
} finally {
this.saving = false;
}
},
confirmDelete(item) {
this.deleteTarget = item;
this.showDeleteModal = true;
},
async deleteItem() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/result/valueset/${this.deleteTarget.VID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
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.deleting = false;
this.deleteTarget = null;
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -1,116 +0,0 @@
<!-- Result 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"
>
<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>
<div class="space-y-6">
<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>
<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>
<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>
<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

@ -1,298 +0,0 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="resultValueSetDef()" x-init="init()">
<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-rose-600 to-pink-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));">Valueset Definitions</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage valueset categories and definitions</p>
</div>
</div>
</div>
<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 definitions..."
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 Category
</button>
</div>
</div>
</div>
<div class="card overflow-hidden">
<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 definitions...</p>
</div>
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Category Name</th>
<th>Description</th>
<th class="w-20 text-center">Items</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
<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-folder-open text-5xl opacity-40"></i>
<p class="text-lg">No definitions found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Category
</button>
</div>
</td>
</tr>
</template>
<template x-for="def in list" :key="def.VSetID">
<tr class="hover:bg-opacity-50">
<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>
</td>
<td>
<span class="text-sm opacity-70" x-text="def.VSDesc || '-'"></span>
</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">
<button class="btn btn-ghost btn-sm btn-square" @click="editItem(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="confirmDelete(def)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<?= $this->include('v2/result/valuesetdef/resultvaluesetdef_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 category <strong x-text="deleteTarget?.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="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteItem()" :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 resultValueSetDef() {
return {
loading: false,
list: [],
keyword: "",
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
},
showDeleteModal: false,
deleteTarget: null,
deleting: false,
async init() {
await this.fetchList();
},
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/result/valuesetdef?${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 = [];
this.showToast('Failed to load definitions', 'error');
} finally {
this.loading = false;
}
},
showForm() {
this.isEditing = false;
this.form = {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
};
this.errors = {};
this.showModal = true;
},
async editItem(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/result/valuesetdef/${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 category data', 'error');
}
},
validate() {
const e = {};
if (!this.form.VSName?.trim()) e.VSName = "Category name 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 ? 'PUT' : 'POST';
const url = this.isEditing ? `${BASEURL}api/result/valuesetdef/${this.form.VSetID}` : `${BASEURL}api/result/valuesetdef`;
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();
this.showToast(this.isEditing ? 'Category updated successfully' : 'Category 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(err);
this.errors = { general: 'Failed to save category' };
this.showToast('Failed to save category', 'error');
} finally {
this.saving = false;
}
},
confirmDelete(def) {
this.deleteTarget = def;
this.showDeleteModal = true;
},
async deleteItem() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/result/valuesetdef/${this.deleteTarget.VSetID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
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.deleting = false;
this.deleteTarget = null;
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -1,131 +0,0 @@
<?= $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-slate-600 to-slate-900 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-cog text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Settings</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Configure system settings and preferences</p>
</div>
</div>
</div>
<!-- Settings Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- General Settings -->
<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-sliders" style="color: rgb(var(--color-primary));"></i>
General Settings
</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2" style="color: rgb(var(--color-text));">System Name</label>
<input type="text" value="CLQMS" class="input input-bordered w-full" />
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: rgb(var(--color-text));">Time Zone</label>
<select class="select select-bordered w-full">
<option>Asia/Jakarta (GMT+7)</option>
<option>Asia/Singapore (GMT+8)</option>
</select>
</div>
</div>
</div>
</div>
<!-- User Preferences -->
<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-user-cog" style="color: rgb(var(--color-primary));"></i>
User Preferences
</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2" style="color: rgb(var(--color-text));">Language</label>
<select class="select select-bordered w-full">
<option>English</option>
<option>Bahasa Indonesia</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: rgb(var(--color-text));">Date Format</label>
<select class="select select-bordered w-full">
<option>DD/MM/YYYY</option>
<option>MM/DD/YYYY</option>
<option>YYYY-MM-DD</option>
</select>
</div>
</div>
</div>
</div>
<!-- Notification Settings -->
<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-bell" style="color: rgb(var(--color-primary));"></i>
Notifications
</h3>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" class="checkbox checkbox-primary" checked />
<span class="text-sm" style="color: rgb(var(--color-text));">Email notifications</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" class="checkbox checkbox-primary" checked />
<span class="text-sm" style="color: rgb(var(--color-text));">Test result alerts</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" class="checkbox checkbox-primary" />
<span class="text-sm" style="color: rgb(var(--color-text));">System updates</span>
</label>
</div>
</div>
</div>
<!-- Security Settings -->
<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-shield-halved" style="color: rgb(var(--color-primary));"></i>
Security
</h3>
<div class="space-y-3">
<button class="btn btn-outline w-full justify-start">
<i class="fa-solid fa-key mr-2"></i>
Change Password
</button>
<button class="btn btn-outline w-full justify-start">
<i class="fa-solid fa-mobile-screen mr-2"></i>
Two-Factor Authentication
</button>
<button class="btn btn-outline w-full justify-start">
<i class="fa-solid fa-clock-rotate-left mr-2"></i>
Login History
</button>
</div>
</div>
</div>
</div>
<!-- Save Button -->
<div class="flex justify-end">
<button class="btn btn-primary">
<i class="fa-solid fa-save mr-2"></i>
Save Settings
</button>
</div>
</div>
<?= $this->endSection() ?>

View File

@ -1,371 +0,0 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="valueSetLibrary()" x-init="init()" class="relative">
<!-- Header & Stats -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-600 to-teal-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 Library</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Browse predefined value sets from library</p>
</div>
</div>
<div class="flex items-center gap-6">
<div class="text-center">
<p class="text-2xl font-bold" style="color: rgb(var(--color-primary));" x-text="Object.keys(list).length"></p>
<p class="text-xs uppercase tracking-wider opacity-60">Value Sets</p>
</div>
<div class="w-px h-8 bg-current opacity-10"></div>
<div class="text-center">
<p class="text-2xl font-bold" style="color: rgb(var(--color-secondary));" x-text="totalItems"></p>
<p class="text-xs uppercase tracking-wider opacity-60">Total Items</p>
</div>
</div>
</div>
</div>
<!-- 2-Column Layout: Left Sidebar (Categories) + Right Content (Values) -->
<div class="grid grid-cols-12 gap-4" style="height: calc(100vh - 200px);">
<!-- LEFT PANEL: Value Set Categories -->
<div class="col-span-4 xl:col-span-3 flex flex-col card-glass overflow-hidden">
<!-- Left Panel Header -->
<div class="p-4 border-b shrink-0" style="border-color: rgb(var(--color-border));">
<h3 class="font-semibold text-sm uppercase tracking-wider opacity-60 mb-3">Categories</h3>
<div class="flex items-center gap-2 bg-base-200 rounded-lg px-3 border border-dashed border-base-content/20">
<i class="fa-solid fa-search text-xs opacity-50"></i>
<input
type="text"
placeholder="Search categories..."
class="input input-sm bg-transparent border-0 p-2 flex-1 min-w-0 focus:outline-none"
x-model.debounce.300ms="keyword"
@input="fetchList()"
/>
<button
x-show="keyword"
@click="keyword = ''; fetchList()"
class="btn btn-ghost btn-xs btn-square"
x-cloak
>
<i class="fa-solid fa-times text-xs"></i>
</button>
</div>
</div>
<!-- Categories List -->
<div class="flex-1 overflow-y-auto">
<!-- Skeleton Loading -->
<div x-show="loading && !Object.keys(list).length" class="p-4 space-y-2" x-cloak>
<template x-for="i in 5">
<div class="p-3 animate-pulse rounded-lg bg-current opacity-5">
<div class="h-4 w-3/4 rounded bg-current opacity-10 mb-2"></div>
<div class="h-3 w-1/4 rounded bg-current opacity-10"></div>
</div>
</template>
</div>
<!-- Empty State -->
<div x-show="!loading && !Object.keys(list).length" class="p-8 text-center opacity-40" x-cloak>
<i class="fa-solid fa-folder-open text-3xl mb-2"></i>
<p class="text-sm">No categories found</p>
</div>
<!-- Category Items -->
<div x-show="!loading && Object.keys(list).length > 0" class="p-2" x-cloak>
<template x-for="(count, name) in filteredList" :key="name">
<div
class="p-3 rounded-lg cursor-pointer transition-all mb-1 group"
:class="selectedCategory === name ? 'bg-primary/10 border border-primary/30' : 'hover:bg-black/5 border border-transparent'"
@click="selectCategory(name)"
>
<div class="flex items-center justify-between">
<div class="truncate flex-1">
<div
class="font-medium text-sm transition-colors"
:class="selectedCategory === name ? 'text-primary' : 'opacity-80'"
x-text="formatName(name)"
></div>
<div class="text-xs opacity-40 font-mono truncate" x-text="name"></div>
</div>
<div class="flex items-center gap-2 ml-2">
<span
class="badge badge-sm"
:class="selectedCategory === name ? 'badge-primary' : 'badge-ghost'"
x-text="count"
></span>
<i
class="fa-solid fa-chevron-right text-xs transition-transform"
:class="selectedCategory === name ? 'opacity-100 rotate-0' : 'opacity-0 group-hover:opacity-50'"
></i>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- Left Panel Footer -->
<div class="p-3 border-t text-xs text-center opacity-40" style="border-color: rgb(var(--color-border));">
<span x-text="Object.keys(list).length"></span> categories
</div>
</div>
<!-- RIGHT PANEL: Value Set Values -->
<div class="col-span-8 xl:col-span-9 flex flex-col card-glass overflow-hidden">
<!-- Right Panel Header -->
<div class="p-4 border-b shrink-0" style="border-color: rgb(var(--color-border));">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-lg flex items-center justify-center transition-transform"
:class="selectedCategory ? 'bg-primary/10' : 'bg-black/5'"
>
<i
class="fa-solid text-lg"
:class="selectedCategory ? 'fa-table-list text-primary' : 'fa-list opacity-20'"
></i>
</div>
<div>
<h3 class="font-bold" style="color: rgb(var(--color-text));" x-text="selectedCategory ? formatName(selectedCategory) : 'Select a Category'"></h3>
<p x-show="selectedCategory" class="text-xs font-mono opacity-50" x-text="selectedCategory"></p>
</div>
</div>
</div>
<!-- Filter Input (when category selected) -->
<div x-show="selectedCategory" class="mt-3" x-transition>
<div class="flex items-center gap-2 bg-black/5 rounded-lg px-3 border border-dashed">
<i class="fa-solid fa-filter text-xs opacity-40"></i>
<input
type="text"
placeholder="Filter items..."
class="input input-sm bg-transparent border-0 p-2 flex-1 focus:outline-none"
x-model="itemFilter"
/>
</div>
</div>
</div>
<!-- Right Panel Content -->
<div class="flex-1 overflow-y-auto">
<!-- No Category Selected State -->
<div x-show="!selectedCategory" class="h-full flex flex-col items-center justify-center opacity-30" x-cloak>
<i class="fa-solid fa-arrow-left text-5xl mb-4"></i>
<p class="text-lg">Select a category from the left to view values</p>
</div>
<!-- Loading State -->
<div x-show="itemLoading" class="h-full flex flex-col items-center justify-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>
<!-- Values Table -->
<div x-show="!itemLoading && selectedCategory">
<template x-if="!items[selectedCategory]?.length">
<div class="h-full flex flex-col items-center justify-center opacity-30" x-cloak>
<i class="fa-solid fa-box-open text-5xl mb-4"></i>
<p>This category has no items</p>
</div>
</template>
<template x-if="items[selectedCategory]?.length">
<table class="table table-zebra w-full">
<thead class="sticky top-0 bg-inherit shadow-sm z-10">
<tr>
<th class="w-24">Key</th>
<th>Value / Label</th>
<th class="w-20 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-for="item in filteredItems" :key="item.value">
<tr class="group">
<td class="font-mono text-xs">
<span class="badge badge-ghost px-2 py-1" x-text="item.value || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="item.label || '-'"></div>
</td>
<td class="text-center">
<button
class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity"
@click="copyToClipboard(item.label)"
title="Copy label"
>
<i class="fa-solid fa-copy"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<!-- Filter Empty State -->
<template x-if="filteredItems.length === 0 && items[selectedCategory]?.length && itemFilter">
<div class="p-12 text-center opacity-40" x-cloak>
<i class="fa-solid fa-magnifying-glass text-4xl mb-3"></i>
<p>No items match your filter</p>
</div>
</template>
</div>
</div>
<!-- Right Panel Footer -->
<div x-show="selectedCategory" class="p-3 border-t text-xs text-center opacity-40" style="border-color: rgb(var(--color-border));" x-transition>
Showing <span x-text="filteredItems.length"></span> of <span x-text="items[selectedCategory]?.length || 0"></span> items
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function valueSetLibrary() {
return {
loading: false,
itemLoading: false,
list: {},
items: {},
keyword: "",
sortBy: 'name',
selectedCategory: null,
itemFilter: "",
get totalItems() {
return Object.values(this.list).reduce((acc, count) => acc + count, 0);
},
get filteredList() {
if (!this.keyword) return this.list;
const filter = this.keyword.toLowerCase();
return Object.fromEntries(
Object.entries(this.list).filter(([name]) =>
this.matchesPartial(name, filter)
)
);
},
matchesPartial(name, filter) {
const nameLower = name.toLowerCase().replace(/_/g, ' ');
let nameIndex = 0;
for (let i = 0; i < filter.length; i++) {
const char = filter[i];
const foundIndex = nameLower.indexOf(char, nameIndex);
if (foundIndex === -1) return false;
nameIndex = foundIndex + 1;
}
return true;
},
get filteredItems() {
if (!this.items[this.selectedCategory]) return [];
const filter = this.itemFilter.toLowerCase();
return this.items[this.selectedCategory].filter(item => {
const label = (item.label || "").toLowerCase();
const value = (item.value || "").toLowerCase();
return label.includes(filter) || value.includes(filter);
});
},
async init() {
await this.fetchList();
},
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', 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.sortList();
} catch (err) {
console.error(err);
this.list = {};
this.showToast('Failed to load value sets', 'error');
} finally {
this.loading = false;
}
},
sortList() {
const entries = Object.entries(this.list);
entries.sort((a, b) => {
if (this.sortBy === 'count') return b[1] - a[1];
return a[0].localeCompare(b[0]);
});
this.list = Object.fromEntries(entries);
},
async selectCategory(name) {
if (this.selectedCategory === name) {
return;
}
this.selectedCategory = name;
this.itemFilter = "";
if (!this.items[name]) {
await this.fetchItems(name);
}
},
async fetchItems(name) {
this.itemLoading = true;
try {
const res = await fetch(`${BASEURL}api/valueset/${name}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.items[name] = data.data || [];
} catch (err) {
console.error(err);
this.items[name] = [];
this.showToast('Failed to load items', 'error');
} finally {
this.itemLoading = false;
}
},
formatName(name) {
if (!name) return '';
return name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
},
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showToast('Copied to clipboard', 'success');
} catch (err) {
console.error(err);
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -1,932 +0,0 @@
/**
* CLQMS V2 - Custom Tailwind Design System
* Premium glassmorphism & modern aesthetics
*/
/* ============================================
CSS VARIABLES - DESIGN TOKENS
============================================ */
:root {
/* Primary Colors */
--color-primary: 30 64 175;
/* Blue 800 */
--color-primary-hover: 30 58 138;
/* Blue 900 */
--color-primary-light: 59 130 246;
/* Blue 500 */
/* Secondary Colors */
--color-secondary: 29 78 216;
/* Blue 700 */
--color-secondary-hover: 30 64 175;
/* Blue 800 */
/* Semantic Colors */
--color-success: 16 185 129;
/* Emerald 500 */
--color-warning: 245 158 11;
/* Amber 500 */
--color-error: 239 68 68;
/* Red 500 */
--color-info: 14 165 233;
/* Sky 500 */
/* Neutral Colors - Light Theme */
--color-text: 15 23 42;
/* Slate 900 */
--color-text-muted: 100 116 139;
/* Slate 500 */
--color-bg: 248 250 252;
/* Slate 50 */
--color-surface: 255 255 255;
/* White */
--color-border: 226 232 240;
/* Slate 200 */
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
/* Border Radius - Less rounded for modern aesthetic */
--radius-sm: 0.375rem;
--radius-md: 0.625rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Dark Theme Variables */
[data-theme="dark"] {
--color-text: 248 250 252;
/* Slate 50 */
--color-text-muted: 148 163 184;
/* Slate 400 */
--color-bg: 15 23 42;
/* Slate 900 */
--color-surface: 30 41 59;
/* Slate 800 */
--color-border: 51 65 85;
/* Slate 700 */
}
/* ============================================
BASE STYLES
============================================ */
* {
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: rgb(var(--color-bg));
color: rgb(var(--color-text));
transition: background-color var(--transition-base), color var(--transition-base);
}
/* Smooth transitions for theme switching */
* {
transition-property: background-color, border-color, color, fill, stroke;
transition-duration: var(--transition-base);
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Remove transitions for transforms and opacity (performance) */
*:where(:not(:has(> *))) {
transition-property: background-color, border-color, color, fill, stroke, opacity, transform;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgb(var(--color-bg));
}
::-webkit-scrollbar-thumb {
background: rgb(var(--color-border));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--color-text-muted));
}
/* ============================================
UTILITY CLASSES
============================================ */
/* Alpine.js cloak */
[x-cloak] {
display: none !important;
}
/* Glass Effect */
.glass {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
[data-theme="dark"] .glass {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* ============================================
BUTTONS
============================================ */
/* Base Button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25rem;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-base);
border: none;
outline: none;
white-space: nowrap;
user-select: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Primary Button */
.btn-primary {
background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary)));
color: white;
box-shadow: 0 4px 14px rgba(var(--color-primary), 0.4);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(var(--color-primary), 0.5);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
/* Secondary Button */
.btn-secondary {
background: rgb(var(--color-secondary));
color: white;
box-shadow: 0 4px 14px rgba(var(--color-secondary), 0.4);
}
.btn-secondary:hover:not(:disabled) {
background: rgb(var(--color-secondary-hover));
transform: translateY(-2px);
}
/* Outline Buttons */
.btn-outline {
background: transparent;
border: 2px solid rgb(var(--color-primary));
color: rgb(var(--color-primary));
}
.btn-outline:hover:not(:disabled) {
background: rgb(var(--color-primary));
color: white;
}
.btn-outline-secondary {
border-color: rgb(var(--color-secondary));
color: rgb(var(--color-secondary));
}
.btn-outline-accent {
border-color: rgb(var(--color-info));
color: rgb(var(--color-info));
}
.btn-outline-info {
border-color: rgb(var(--color-info));
color: rgb(var(--color-info));
}
/* Ghost Button */
.btn-ghost {
background: transparent;
color: rgb(var(--color-text));
}
.btn-ghost:hover:not(:disabled) {
background: rgba(var(--color-text), 0.05);
}
[data-theme="dark"] .btn-ghost:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
/* Button Sizes */
.btn-sm {
padding: 0.375rem 0.875rem;
font-size: 0.8125rem;
}
.btn-xs {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
}
.btn-lg {
padding: 0.875rem 1.75rem;
font-size: 1rem;
}
/* Button Shapes */
.btn-square {
padding: 0.625rem;
aspect-ratio: 1;
}
.btn-circle {
padding: 0.625rem;
aspect-ratio: 1;
border-radius: 9999px;
}
/* ============================================
CARDS
============================================ */
.card {
background: rgb(var(--color-surface));
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
border: 1px solid rgb(var(--color-border) / 0.5);
overflow: hidden;
transition: all var(--transition-base);
}
.card:hover {
box-shadow: var(--shadow-lg);
}
/* Glass Card */
.card-glass {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: var(--shadow-xl);
border-radius: var(--radius-lg);
}
[data-theme="dark"] .card-glass {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Card with gradient border */
.card-gradient {
position: relative;
background: rgb(var(--color-surface));
border: none;
}
.card-gradient::before {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary)));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
}
/* Input with icon wrapper */
.input-icon-wrapper {
position: relative;
}
.input-icon-wrapper .input-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
opacity: 0.5;
}
.input-icon-wrapper .input {
padding-left: 2.5rem;
}
/* Input with left icon */
.input-with-icon-left {
padding-left: 2.5rem;
}
/* Input with right icon */
.input-with-icon-right {
padding-right: 2.5rem;
}
/* ============================================
INPUTS & FORMS
============================================ */
.input,
.select,
.textarea {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.875rem;
line-height: 1.5;
color: rgb(var(--color-text));
background-color: rgb(var(--color-surface));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
transition: all var(--transition-base);
outline: none;
height: auto;
min-height: 42px;
}
/* Input with left icon - increased padding for icon */
.input.input-with-icon,
.input-with-icon.input {
padding-left: 2.75rem;
}
.input:focus,
.select:focus,
.textarea:focus {
border-color: rgb(var(--color-primary));
box-shadow: 0 0 0 3px rgba(var(--color-primary), 0.15);
background-color: rgb(var(--color-surface));
}
.input:disabled,
.select:disabled,
.textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Input with error */
.input-error {
border-color: rgb(var(--color-error));
}
.input-error:focus {
box-shadow: 0 0 0 3px rgba(var(--color-error), 0.15);
}
/* Input Sizes */
.input-sm {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
min-height: 34px;
}
.input-xs {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
min-height: 26px;
}
/* Checkbox */
.checkbox {
width: 1.25rem;
height: 1.25rem;
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-base);
appearance: none;
background-color: rgb(var(--color-surface));
}
.checkbox:checked {
background-color: rgb(var(--color-primary));
border-color: rgb(var(--color-primary));
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
.checkbox-sm {
width: 1rem;
height: 1rem;
}
/* Label */
.label {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
}
.label-text {
font-size: 0.875rem;
color: rgb(var(--color-text));
}
.label-text-alt {
font-size: 0.75rem;
color: rgb(var(--color-text-muted));
}
/* ============================================
TABLES
============================================ */
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.875rem;
}
.table thead {
background: rgb(var(--color-bg));
border-bottom: 1px solid rgb(var(--color-border));
}
.table th {
padding: 0.5rem 0.75rem;
text-align: left;
font-weight: 600;
color: rgb(var(--color-text-muted));
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
.table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgb(var(--color-border) / 0.5);
}
.table tbody tr {
transition: background-color var(--transition-fast);
}
.table tbody tr:hover {
background: rgb(var(--color-bg) / 0.5);
}
.table tbody tr:last-child td {
border-bottom: none;
}
/* Compact Table Variant */
.table.table-compact th,
.table.table-compact td {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
}
.table.table-compact .badge {
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
}
/* ============================================
BADGES
============================================ */
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1rem;
}
.badge-primary {
background: rgba(var(--color-primary), 0.15);
color: rgb(var(--color-primary));
}
.badge-secondary {
background: rgba(var(--color-secondary), 0.15);
color: rgb(var(--color-secondary));
}
.badge-success {
background: rgba(var(--color-success), 0.15);
color: rgb(var(--color-success));
}
.badge-warning {
background: rgba(var(--color-warning), 0.15);
color: rgb(var(--color-warning));
}
.badge-error {
background: rgba(var(--color-error), 0.15);
color: rgb(var(--color-error));
}
.badge-info {
background: rgba(var(--color-info), 0.15);
color: rgb(var(--color-info));
}
.badge-ghost {
background: rgba(var(--color-text), 0.1);
color: rgb(var(--color-text));
}
.badge-sm {
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
}
/* ============================================
ALERTS
============================================ */
.alert {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.alert-success {
background: rgba(var(--color-success), 0.1);
color: rgb(var(--color-success));
border: 1px solid rgba(var(--color-success), 0.3);
}
.alert-error {
background: rgba(var(--color-error), 0.1);
color: rgb(var(--color-error));
border: 1px solid rgba(var(--color-error), 0.3);
}
.alert-warning {
background: rgba(var(--color-warning), 0.1);
color: rgb(var(--color-warning));
border: 1px solid rgba(var(--color-warning), 0.3);
}
.alert-info {
background: rgba(var(--color-info), 0.1);
color: rgb(var(--color-info));
border: 1px solid rgba(var(--color-info), 0.3);
}
/* ============================================
MODALS
============================================ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal-content {
background: rgb(var(--color-surface));
border-radius: var(--radius-xl);
box-shadow: var(--shadow-2xl);
max-width: 56rem;
width: 100%;
max-height: 90vh;
overflow-y: auto;
animation: modalEnter var(--transition-slow) ease-out;
}
@keyframes modalEnter {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* ============================================
LOADING SPINNER
============================================ */
.spinner {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
border: 2px solid rgba(var(--color-primary), 0.3);
border-top-color: rgb(var(--color-primary));
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.spinner-sm {
width: 1rem;
height: 1rem;
border-width: 2px;
}
.spinner-lg {
width: 2rem;
height: 2rem;
border-width: 3px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ============================================
AVATAR
============================================ */
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.avatar-circle {
border-radius: 9999px;
}
.avatar-rounded {
border-radius: var(--radius-md);
}
/* ============================================
DIVIDER
============================================ */
.divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.5rem 0;
color: rgb(var(--color-text-muted));
font-size: 0.875rem;
font-weight: 500;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: rgb(var(--color-border));
}
/* ============================================
DROPDOWN
============================================ */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
position: absolute;
background: rgb(var(--color-surface));
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
border: 1px solid rgb(var(--color-border));
padding: 0.5rem;
min-width: 12rem;
z-index: 50;
animation: dropdownEnter var(--transition-fast) ease-out;
}
.dropdown-end .dropdown-content {
right: 0;
}
@keyframes dropdownEnter {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ============================================
MENU / NAVIGATION
============================================ */
.menu {
display: flex;
flex-direction: column;
gap: 0.25rem;
list-style: none;
padding: 0;
margin: 0;
}
.menu li {
display: block;
}
.menu a,
.menu button {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
color: rgb(var(--color-text));
text-decoration: none;
transition: all var(--transition-fast);
cursor: pointer;
border: none;
background: transparent;
width: 100%;
text-align: left;
font-size: 0.875rem;
}
.menu a:hover,
.menu button:hover {
background: rgb(var(--color-bg));
}
.menu a.active {
background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary)));
color: white;
box-shadow: 0 4px 12px rgba(var(--color-primary), 0.4);
}
.menu-sm a,
.menu-sm button {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
/* ============================================
SIDEBAR
============================================ */
.sidebar {
background: linear-gradient(180deg, rgb(30 41 59), rgb(15 23 42));
color: rgba(255, 255, 255, 0.9);
transition: width var(--transition-slow), transform var(--transition-slow);
}
[data-theme="dark"] .sidebar {
background: linear-gradient(180deg, rgb(15 23 42), rgb(0 0 0));
}
.sidebar .menu a,
.sidebar .menu button {
color: rgba(255, 255, 255, 0.7);
}
.sidebar .menu a:hover,
.sidebar .menu button:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.sidebar .menu a.active {
background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary)));
color: white;
}
/* ============================================
ANIMATIONS
============================================ */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideInUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-fadeIn {
animation: fadeIn var(--transition-base) ease-out;
}
.animate-slideInRight {
animation: slideInRight var(--transition-slow) ease-out;
}
.animate-slideInLeft {
animation: slideInLeft var(--transition-slow) ease-out;
}
.animate-slideInUp {
animation: slideInUp var(--transition-slow) ease-out;
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* ============================================
UTILITY CLASSES
============================================ */
.text-gradient {
background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary)));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.shadow-glow {
box-shadow: 0 0 20px rgba(var(--color-primary), 0.3);
}
.border-gradient {
border: 2px solid transparent;
background-image: linear-gradient(rgb(var(--color-surface)), rgb(var(--color-surface))),
linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary)));
background-origin: border-box;
background-clip: padding-box, border-box;
}

View File

@ -0,0 +1,94 @@
<?php
namespace Tests\Feature;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Firebase\JWT\JWT;
class ContactControllerTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $token;
protected function setUp(): void
{
parent::setUp();
// Generate JWT Token
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
'aud' => 'localhost',
'iat' => time(),
'nbf' => time(),
'exp' => time() + 3600,
'uid' => 1,
'email' => 'admin@admin.com'
];
$this->token = JWT::encode($payload, $key, 'HS256');
}
protected function callProtected($method, $path, $params = [])
{
return $this->withHeaders(['Cookie' => 'token=' . $this->token])
->call($method, $path, $params);
}
public function testIndexReturnsSuccess()
{
$result = $this->callProtected('get', 'api/contact');
$result->assertStatus(200);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
$this->assertIsArray($data['data']);
}
public function testShowReturnsDataIfFound()
{
$indexResult = $this->callProtected('get', 'api/contact');
$indexData = json_decode($indexResult->getJSON(), true);
if (empty($indexData['data'])) {
$this->markTestSkipped('No contacts found in database to test show.');
}
$id = $indexData['data'][0]['ContactID'];
$result = $this->callProtected('get', "api/contact/$id");
$result->assertStatus(200);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
$this->assertIsArray($data['data']);
$this->assertEquals($id, $data['data']['ContactID']);
}
public function testCreateContact()
{
$contactData = [
'NameFirst' => 'TestContact' . time(),
'NameLast' => 'LastName',
'Specialty' => 'GP',
'Occupation' => 'MD'
];
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($contactData))
->call('post', 'api/contact');
$result->assertStatus(201);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
$this->assertIsArray($data['data']);
$this->assertEquals('success', $data['data']['status']);
$this->assertIsInt($data['data']['ContactID']);
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace Tests\Feature;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Firebase\JWT\JWT;
class OrganizationControllerTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $token;
protected function setUp(): void
{
parent::setUp();
// Generate JWT Token
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
'aud' => 'localhost',
'iat' => time(),
'nbf' => time(),
'exp' => time() + 3600,
'uid' => 1,
'email' => 'admin@admin.com'
];
$this->token = JWT::encode($payload, $key, 'HS256');
}
protected function callProtected($method, $path, $params = [])
{
return $this->withHeaders(['Cookie' => 'token=' . $this->token])
->call($method, $path, $params);
}
public function testSiteIndexReturnsSuccess()
{
$result = $this->callProtected('get', 'api/organization/site');
$result->assertStatus(200);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
}
public function testAccountIndexReturnsSuccess()
{
$result = $this->callProtected('get', 'api/organization/account');
$result->assertStatus(200);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
}
public function testDepartmentIndexReturnsSuccess()
{
$result = $this->callProtected('get', 'api/organization/department');
$result->assertStatus(200);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
}
public function testCreateSite()
{
$siteData = [
'SiteCode' => 'S' . substr(time(), -5),
'SiteName' => 'Test Site ' . time()
];
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($siteData))
->call('post', 'api/organization/site');
$result->assertStatus(201);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
$this->assertIsInt($data['data']);
}
public function testCreateAccount()
{
$accountData = [
'AccountName' => 'Test Account ' . time()
];
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($accountData))
->call('post', 'api/organization/account');
$result->assertStatus(201);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
$this->assertIsInt($data['data']);
}
public function testCreateContainerDef()
{
$conData = [
'SiteID' => 1,
'ConCode' => 'C' . substr(time(), -2),
'ConName' => 'Test Container ' . time()
];
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($conData))
->call('post', 'api/specimen/containerdef');
$result->assertStatus(201);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
$this->assertIsInt($data['data']);
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace Tests\Feature;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Firebase\JWT\JWT;
class TestsControllerTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $token;
protected function setUp(): void
{
parent::setUp();
// Generate JWT Token
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
'aud' => 'localhost',
'iat' => time(),
'nbf' => time(),
'exp' => time() + 3600,
'uid' => 1,
'email' => 'admin@admin.com'
];
$this->token = JWT::encode($payload, $key, 'HS256');
}
protected function callProtected($method, $path, $params = [])
{
return $this->withHeaders(['Cookie' => 'token=' . $this->token])
->call($method, $path, $params);
}
public function testIndexReturnsSuccess()
{
$result = $this->callProtected('get', 'api/tests');
$result->assertStatus(200);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
$this->assertIsArray($data['data']);
}
public function testShowReturnsDataIfFound()
{
// First get an ID
$indexResult = $this->callProtected('get', 'api/tests');
$indexData = json_decode($indexResult->getJSON(), true);
if (empty($indexData['data'])) {
$this->markTestSkipped('No test definitions found in database to test show.');
}
$id = $indexData['data'][0]['TestSiteID'];
$result = $this->callProtected('get', "api/tests/$id");
$result->assertStatus(200);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
$this->assertIsArray($data['data']);
$this->assertEquals($id, $data['data']['TestSiteID']);
}
public function testCreateTestWithThreshold()
{
$testData = [
'TestSiteCode' => 'TH' . substr(time(), -4),
'TestSiteName' => 'Threshold Test ' . time(),
'TestType' => 'TEST',
'SiteID' => 1,
'details' => [
'RefType' => 'THOLD',
'ResultType' => 'NMRIC'
],
'refnum' => [
[
'NumRefType' => 'THOLD',
'RangeType' => 'VALUE',
'Sex' => '1',
'AgeStart' => 0,
'AgeEnd' => 100,
'LowSign' => '>',
'Low' => 5.5,
'Interpretation' => 'High'
]
]
];
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($testData))
->call('post', 'api/tests');
$result->assertStatus(201);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('created', $data['status']);
$id = $data['data']['TestSiteId'];
// Verify retrieval
$showResult = $this->callProtected('get', "api/tests/$id");
$showData = json_decode($showResult->getJSON(), true);
$this->assertArrayHasKey('refnum', $showData['data']);
$this->assertCount(1, $showData['data']['refnum']);
$this->assertEquals(5.5, $showData['data']['refnum'][0]['Low']);
$this->assertEquals('High', $showData['data']['refnum'][0]['Interpretation']);
}
}

View File

@ -364,10 +364,12 @@ class ValueSetTest extends CIUnitTestCase
'Country' => 'country' 'Country' => 'country'
]); ]);
$this->assertEquals('Female', $result[0]['Gender']); $this->assertEquals('1', $result[0]['Gender']);
$this->assertEquals('1', $result[0]['GenderKey']); $this->assertEquals('Female', $result[0]['GenderLabel']);
$this->assertEquals('Male', $result[1]['Gender']); $this->assertEquals('2', $result[1]['Gender']);
$this->assertEquals('2', $result[1]['GenderKey']); $this->assertEquals('Male', $result[1]['GenderLabel']);
$this->assertEquals('USA', $result[1]['Country']);
$this->assertEquals('United States of America', $result[1]['CountryLabel']);
} }
public function testGetOptions() public function testGetOptions()