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:
parent
fcdbc3f20a
commit
40ecb4e6e8
@ -84,6 +84,27 @@ excluded_tools: []
|
||||
# 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).
|
||||
initial_prompt: ""
|
||||
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "clqms01"
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
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
200
CLAUDE.md
@ -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
115
GEMINI.md
Normal 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
4
PRD.md
@ -498,7 +498,7 @@ POST /api/edge/results
|
||||
|-------|---------|------------|
|
||||
| patient | Patient registry | InternalPID, PatientID, NameFirst, NameLast, Sex, Birthdate |
|
||||
| porder | Laboratory orders | OrderID, InternalPID, OrderStatus, Priority |
|
||||
| orderitem | Order tests | OrderID, TestID |
|
||||
|
||||
| specimen | Specimens | SID, SpecimenID, SpecimenStatus |
|
||||
| patresult | Patient results | ResultID, OrderID, TestID, ResultValue |
|
||||
| patresultdetail | Result details | ResultID, ParameterID, Value |
|
||||
@ -684,7 +684,7 @@ The MVP is considered complete when:
|
||||
### 10.1 Open Questions
|
||||
| Question | Impact | Target Date |
|
||||
|----------|--------|-------------|
|
||||
| Reference range types (refthold, refvset) - are they needed for MVP? | Medium | Phase 0 |
|
||||
|
||||
| Multi-site deployment requirements? | High | Phase 0 |
|
||||
| Specific instrument integrations needed? | High | Phase 2 |
|
||||
| Report format requirements (PDF/HTML)? | Medium | Phase 1 |
|
||||
|
||||
@ -423,9 +423,7 @@ Reference Ranges define normal and critical values for test results. The system
|
||||
| Type | Table | Description |
|
||||
|------|-------|-------------|
|
||||
| Numeric | `refnum` | Numeric ranges with age/sex criteria |
|
||||
| Threshold | `refthold` | Critical threshold values |
|
||||
| Text | `reftxt` | Text-based reference values |
|
||||
| Value Set | `refvset` | Coded reference values |
|
||||
|
||||
#### Numeric Reference Range Structure
|
||||
|
||||
@ -513,7 +511,7 @@ valuesetdef (VSetDefID, VSName, VSDesc)
|
||||
| Category | Tables | Purpose |
|
||||
|----------|--------|---------|
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
117
TODO.md
117
TODO.md
@ -6,31 +6,16 @@
|
||||
|
||||
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
|
||||
-- 1. Patient (already exists in codebase)
|
||||
-- Just need at least 1 patient
|
||||
|
||||
-- 2. Order Status Values (VSetID=11)
|
||||
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
|
||||
-- 2. Counter for Order ID
|
||||
INSERT INTO counter (CounterName, CounterValue) VALUES ('ORDER', 1);
|
||||
|
||||
-- Run seeder: php spark db:seed MinimalMasterDataSeeder
|
||||
```
|
||||
|
||||
### 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
|
||||
- [ ] Complete `OrderTestController` create/update/delete
|
||||
- [ ] Implement order ID generation (LLYYMMDDXXXXX format)
|
||||
- [ ] Implement order attachment handling (ordercom, orderatt tables)
|
||||
- [ ] Add order status tracking (ORD→SCH→ANA→VER→REV→REP)
|
||||
- [ ] Create order test mapping (testmap table)
|
||||
- [ ] Add calculated test parameter auto-selection
|
||||
- [x] Complete `OrderTestController` create/update/delete
|
||||
- [x] Implement order ID generation (LLYYMMDDXXXXX format)
|
||||
- [x] Implement order comment handling (ordercom table)
|
||||
- [ ] Implement order attachment handling (orderatt table)
|
||||
- [x] Add order status tracking (ORD→SCH→ANA→VER→REV→REP)
|
||||
- [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
|
||||
- [ ] Implement specimen ID generation (OrderID + SSS + C)
|
||||
- [ ] Build specimen collection API (Collection status)
|
||||
@ -93,7 +81,7 @@ Order → Collection → Reception → Preparation → Analysis → Verification
|
||||
- [ ] Build specimen dispatching API (Dispatch status)
|
||||
- [ ] Implement specimen condition tracking (HEM, ITC, LIP flags)
|
||||
|
||||
### 1.3 Result Management
|
||||
### 2.2 Result Management
|
||||
- [ ] Complete `ResultController` with full CRUD
|
||||
- [ ] Implement result entry API (numeric, text, valueset, range)
|
||||
- [ ] Implement result verification workflow (Technical + Clinical)
|
||||
@ -102,17 +90,16 @@ Order → Collection → Reception → Preparation → Analysis → Verification
|
||||
- [ ] Implement result rerun with AspCnt tracking
|
||||
- [ ] Add result report generation API
|
||||
|
||||
### 1.4 Patient Visit
|
||||
### 2.3 Patient Visit
|
||||
- [ ] Complete `PatVisitController` create/read
|
||||
- [ ] Implement patient visit to order linking
|
||||
- [ ] Add admission/discharge/transfer (ADT) tracking
|
||||
- [ ] 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
|
||||
- [ ] Implement edgeres table data handling
|
||||
- [ ] Implement edgestatus tracking
|
||||
@ -121,14 +108,14 @@ Order → Collection → Reception → Preparation → Analysis → Verification
|
||||
- [ ] Build order acknowledgment endpoint (/api/edge/orders/:id/ack)
|
||||
- [ ] Build status logging endpoint (/api/edge/status)
|
||||
|
||||
### 2.2 Test Mapping
|
||||
### 3.2 Test Mapping
|
||||
- [ ] Implement test mapping CRUD (TestMapModel)
|
||||
- [ ] Build instrument code to LQMS test mapping
|
||||
- [ ] 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
|
||||
- [ ] Build QC result entry API
|
||||
@ -161,10 +148,10 @@ Order → Collection → Reception → Preparation → Analysis → Verification
|
||||
- [ ] Test parameters
|
||||
|
||||
### 4.2 Reference Ranges ✅ Existing
|
||||
- [ ] Numeric ranges (refnum)
|
||||
- [ ] Threshold ranges (refthold)
|
||||
- [ ] Text ranges (reftxt)
|
||||
- [ ] Value set ranges (refvset)
|
||||
- [x] Numeric ranges (refnum)
|
||||
- [x] Threshold ranges (refthold)
|
||||
- [x] Text ranges (reftxt)
|
||||
- [x] Value set ranges (refvset)
|
||||
|
||||
### 4.3 Organizations ✅ Existing
|
||||
- [ ] Sites (SiteController)
|
||||
@ -243,42 +230,42 @@ curl -X POST http://localhost:8080/api/ordertest/status \
|
||||
## Success Criteria
|
||||
|
||||
### Functional
|
||||
- Patient registration works ✅
|
||||
- Test ordering generates valid OrderID and SID
|
||||
- Specimens track through collection → transport → reception → preparation → analysis
|
||||
- Results can be entered with reference range validation
|
||||
- Results verified through VER → REV → REP workflow
|
||||
- Instruments can send results via Edge API
|
||||
- [x] Patient registration works
|
||||
- [ ] Test ordering generates valid OrderID and SID
|
||||
- [ ] Specimens track through collection → transport → reception → preparation → analysis
|
||||
- [ ] Results can be entered with reference range validation
|
||||
- [ ] Results verified through VER → REV → REP workflow
|
||||
- [ ] Instruments can send results via Edge API
|
||||
|
||||
### Non-Functional
|
||||
- JWT authentication required for all endpoints
|
||||
- Soft delete (DelDate) on all transactions
|
||||
- UTC timezone for all datetime fields
|
||||
- Audit logging for data changes
|
||||
- < 2s response time for standard queries
|
||||
- [ ] JWT authentication required for all endpoints
|
||||
- [ ] Soft delete (DelDate) on all transactions
|
||||
- [ ] UTC timezone for all datetime fields
|
||||
- [ ] Audit logging for data changes
|
||||
- [ ] < 2s response time for standard queries
|
||||
|
||||
---
|
||||
|
||||
## Current Codebase Status
|
||||
|
||||
### Controllers (Need Work)
|
||||
- ❌ OrderTestController - placeholder code, incomplete
|
||||
- ❌ ResultController - only validates JWT
|
||||
- ✅ PatientController - complete
|
||||
- ✅ TestsController - complete
|
||||
- ✅ PatVisitController - partial
|
||||
- [ ] OrderTestController - placeholder code, incomplete
|
||||
- [ ] ResultController - only validates JWT
|
||||
- [x] PatientController - complete
|
||||
- [x] TestsController - complete
|
||||
- [x] PatVisitController - partial
|
||||
|
||||
### Models (Good)
|
||||
- ✅ PatientModel - complete
|
||||
- ✅ TestDef* models - complete
|
||||
- ✅ Ref* models - complete
|
||||
- ✅ ValueSet* models - complete
|
||||
- ✅ SpecimenModel - exists, needs API
|
||||
- [x] PatientModel - complete
|
||||
- [x] TestDef* models - complete
|
||||
- [x] Ref* models - complete
|
||||
- [x] ValueSet* models - complete
|
||||
- [x] SpecimenModel - exists, needs API
|
||||
|
||||
### Missing Controllers
|
||||
- ❌ SpecimenController - need full implementation
|
||||
- ❌ ResultController - need full implementation
|
||||
- ❌ QualityControlController - not exist
|
||||
- ❌ CalibrationController - not exist
|
||||
- ❌ AuditController - not exist
|
||||
- ❌ BillingController - not exist
|
||||
- [ ] SpecimenController - need full implementation
|
||||
- [ ] ResultController - need full implementation
|
||||
- [ ] QualityControlController - not exist
|
||||
- [ ] CalibrationController - not exist
|
||||
- [ ] AuditController - not exist
|
||||
- [ ] BillingController - not exist
|
||||
|
||||
@ -6,7 +6,7 @@ use CodeIgniter\Router\RouteCollection;
|
||||
* @var RouteCollection $routes
|
||||
*/
|
||||
$routes->get('/', function () {
|
||||
return redirect()->to('/v2');
|
||||
return "Backend Running";
|
||||
});
|
||||
|
||||
$routes->options('(:any)', function () {
|
||||
@ -19,8 +19,7 @@ $routes->group('api', ['filter' => 'auth'], function ($routes) {
|
||||
$routes->get('sample', 'SampleController::index');
|
||||
});
|
||||
|
||||
// Public Routes (no auth required)
|
||||
$routes->get('/v2/login', 'PagesController::login');
|
||||
|
||||
|
||||
// Swagger API Documentation (public - no filters)
|
||||
$routes->add('swagger', 'PagesController::swagger');
|
||||
@ -33,32 +32,7 @@ $routes->group('v2/auth', function ($routes) {
|
||||
$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
|
||||
$routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1');
|
||||
|
||||
@ -36,7 +36,7 @@ class OrderTestController extends Controller {
|
||||
} else {
|
||||
$rows = $this->db->table('ordertest')
|
||||
->where('DelDate', null)
|
||||
->orderBy('OrderDateTime', 'DESC')
|
||||
->orderBy('TrnDate', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
}
|
||||
@ -134,7 +134,7 @@ class OrderTestController extends Controller {
|
||||
if (isset($input['WorkstationID'])) $updateData['WorkstationID'] = $input['WorkstationID'];
|
||||
|
||||
if (!empty($updateData)) {
|
||||
$this->model->update($input['OrderID'], $updateData);
|
||||
$this->model->update($order['InternalOID'], $updateData);
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
|
||||
@ -10,193 +10,7 @@ namespace App\Controllers;
|
||||
*/
|
||||
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
|
||||
|
||||
@ -67,7 +67,7 @@ class ContainerDefController extends BaseController {
|
||||
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
|
||||
try {
|
||||
$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) {
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
@ -44,6 +44,7 @@ class DemoOrderController extends Controller {
|
||||
'Priority' => $input['Priority'] ?? 'R',
|
||||
'OrderingProvider' => $input['OrderingProvider'] ?? 'Dr. Demo',
|
||||
'DepartmentID' => $input['DepartmentID'] ?? 1,
|
||||
'Tests' => $input['Tests'] ?? []
|
||||
];
|
||||
|
||||
$orderID = $this->orderModel->createOrder($orderData);
|
||||
|
||||
@ -148,7 +148,7 @@ class TestsController extends BaseController
|
||||
$techData = $row['testdeftech'][0];
|
||||
$refType = $techData['RefType'];
|
||||
|
||||
if ($refType === '1') {
|
||||
if ($refType === '1' || $refType === 'NMRC' || $refType === '3' || $refType === 'THOLD') {
|
||||
$refnumData = $this->modelRefNum
|
||||
->where('TestSiteID', $id)
|
||||
->where('EndDate IS NULL')
|
||||
@ -159,24 +159,28 @@ class TestsController extends BaseController
|
||||
return [
|
||||
'RefNumID' => $r['RefNumID'],
|
||||
'NumRefType' => $r['NumRefType'],
|
||||
'NumRefTypeLabel' => ValueSet::getLabel('numeric_ref_type', $r['NumRefType']),
|
||||
'NumRefTypeLabel' => $r['NumRefType'] ? ValueSet::getLabel('numeric_ref_type', $r['NumRefType']) : '',
|
||||
'RangeType' => $r['RangeType'],
|
||||
'RangeTypeLabel' => ValueSet::getLabel('range_type', $r['RangeType']),
|
||||
'RangeTypeLabel' => $r['RangeType'] ? ValueSet::getLabel('range_type', $r['RangeType']) : '',
|
||||
'Sex' => $r['Sex'],
|
||||
'SexLabel' => ValueSet::getLabel('gender', $r['Sex']),
|
||||
'SexLabel' => $r['Sex'] ? ValueSet::getLabel('gender', $r['Sex']) : '',
|
||||
'LowSign' => $r['LowSign'],
|
||||
'LowSignLabel' => ValueSet::getLabel('math_sign', $r['LowSign']),
|
||||
'LowSignLabel' => $r['LowSign'] ? ValueSet::getLabel('math_sign', $r['LowSign']) : '',
|
||||
'HighSign' => $r['HighSign'],
|
||||
'HighSignLabel' => ValueSet::getLabel('math_sign', $r['HighSign']),
|
||||
'High' => $r['High'] !== null ? (int) $r['High'] : null,
|
||||
'Flag' => $r['Flag']
|
||||
'HighSignLabel' => $r['HighSign'] ? ValueSet::getLabel('math_sign', $r['HighSign']) : '',
|
||||
'High' => $r['High'] !== null ? (float) $r['High'] : null,
|
||||
'Low' => $r['Low'] !== null ? (float) $r['Low'] : null,
|
||||
'AgeStart' => (int) $r['AgeStart'],
|
||||
'AgeEnd' => (int) $r['AgeEnd'],
|
||||
'Flag' => $r['Flag'],
|
||||
'Interpretation' => $r['Interpretation']
|
||||
];
|
||||
}, $refnumData ?? []);
|
||||
|
||||
$row['rangeTypeOptions'] = ValueSet::getOptions('range_type');
|
||||
}
|
||||
|
||||
if ($refType === '2') {
|
||||
if ($refType === '2' || $refType === 'TEXT' || $refType === '4' || $refType === 'VSET') {
|
||||
$reftxtData = $this->modelRefTxt
|
||||
->where('TestSiteID', $id)
|
||||
->where('EndDate IS NULL')
|
||||
@ -187,17 +191,15 @@ class TestsController extends BaseController
|
||||
return [
|
||||
'RefTxtID' => $r['RefTxtID'],
|
||||
'TxtRefType' => $r['TxtRefType'],
|
||||
'TxtRefTypeLabel' => ValueSet::getLabel('text_ref_type', $r['TxtRefType']),
|
||||
'TxtRefTypeLabel' => $r['TxtRefType'] ? ValueSet::getLabel('text_ref_type', $r['TxtRefType']) : '',
|
||||
'Sex' => $r['Sex'],
|
||||
'SexLabel' => ValueSet::getLabel('gender', $r['Sex']),
|
||||
'SexLabel' => $r['Sex'] ? ValueSet::getLabel('gender', $r['Sex']) : '',
|
||||
'AgeStart' => (int) $r['AgeStart'],
|
||||
'AgeEnd' => (int) $r['AgeEnd'],
|
||||
'RefTxt' => $r['RefTxt'],
|
||||
'Flag' => $r['Flag']
|
||||
];
|
||||
}, $reftxtData ?? []);
|
||||
|
||||
// $row['txtRefTypeOptions'] = ValueSet::getOptions('text_ref_type');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -431,13 +433,13 @@ class TestsController extends BaseController
|
||||
$this->saveTechDetails($testSiteID, $details, $action, $typeCode);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -503,10 +505,11 @@ class TestsController extends BaseController
|
||||
'AgeStart' => (int) ($range['AgeStart'] ?? 0),
|
||||
'AgeEnd' => (int) ($range['AgeEnd'] ?? 150),
|
||||
'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,
|
||||
'High' => !empty($range['High']) ? (int) $range['High'] : null,
|
||||
'High' => !empty($range['High']) ? (float) $range['High'] : null,
|
||||
'Flag' => $range['Flag'] ?? null,
|
||||
'Interpretation' => $range['Interpretation'] ?? null,
|
||||
'Display' => $index,
|
||||
'CreateDate' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
@ -22,8 +22,6 @@ class CreateResults extends Migration {
|
||||
'WorkstationID' => ['type' => 'INT', 'null' => true],
|
||||
'EquipmentID' => ['type' => 'INT', 'null' => true],
|
||||
'RefNumID' => ['type' => 'INT', 'null' => true],
|
||||
'RefTHoldID' => ['type' => 'INT', 'null' => true],
|
||||
'RefVSetID' => ['type' => 'INT', 'null' => true],
|
||||
'RefTxtID' => ['type' => 'INT', 'null' => true],
|
||||
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
|
||||
'EndDate' => ['type' => 'DATETIME', 'null' => true],
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
"VCategory": "System",
|
||||
"values": [
|
||||
{"key": "S", "value": "Stat"},
|
||||
{"key": "A", "value": "ASAP"},
|
||||
{"key": "R", "value": "Routine"},
|
||||
{"key": "A", "value": "ASAP"},
|
||||
{"key": "P", "value": "Preop"},
|
||||
{"key": "C", "value": "Callback"},
|
||||
{"key": "T", "value": "Timing critical"},
|
||||
|
||||
@ -2,6 +2,12 @@
|
||||
"VSName": "Order Status",
|
||||
"VCategory": "System",
|
||||
"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": "CA", "value": "Order is cancelled"},
|
||||
{"key": "CM", "value": "Order is completed"},
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
"VCategory": "System",
|
||||
"values": [
|
||||
{"key": "S", "value": "Stat"},
|
||||
{"key": "A", "value": "ASAP"},
|
||||
{"key": "R", "value": "Routine"},
|
||||
{"key": "A", "value": "ASAP"},
|
||||
{"key": "P", "value": "Preop"},
|
||||
{"key": "C", "value": "Callback"},
|
||||
{"key": "T", "value": "Timing critical"},
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"VCategory": "System",
|
||||
"values": [
|
||||
{"key": "NMRC", "value": "Numeric"},
|
||||
{"key": "TEXT", "value": "Text"}
|
||||
{"key": "TEXT", "value": "Text"},
|
||||
{"key": "THOLD", "value": "Threshold"},
|
||||
{"key": "VSET", "value": "Value Set"}
|
||||
]
|
||||
}
|
||||
|
||||
@ -5,21 +5,22 @@ use App\Models\BaseModel;
|
||||
|
||||
class OrderTestModel extends BaseModel {
|
||||
protected $table = 'ordertest';
|
||||
protected $primaryKey = 'OrderID';
|
||||
protected $primaryKey = 'InternalOID';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $allowedFields = [
|
||||
'InternalOID',
|
||||
'OrderID',
|
||||
'PlacerID',
|
||||
'InternalPID',
|
||||
'PatVisitID',
|
||||
'OrderDateTime',
|
||||
'Priority',
|
||||
'OrderStatus',
|
||||
'OrderedBy',
|
||||
'OrderingProvider',
|
||||
'SiteID',
|
||||
'SourceSiteID',
|
||||
'DepartmentID',
|
||||
'WorkstationID',
|
||||
'BillingAccount',
|
||||
'PVADTID',
|
||||
'ReqApp',
|
||||
'Priority',
|
||||
'TrnDate',
|
||||
'EffDate',
|
||||
'CreateDate',
|
||||
'EndDate',
|
||||
'ArchiveDate',
|
||||
'DelDate'
|
||||
];
|
||||
|
||||
@ -52,29 +53,101 @@ class OrderTestModel extends BaseModel {
|
||||
}
|
||||
|
||||
public function createOrder(array $data): string {
|
||||
$orderID = $data['OrderID'] ?? $this->generateOrderID();
|
||||
$orderID = $data['OrderID'] ?? $this->generateOrderID($data['SiteCode'] ?? '00');
|
||||
|
||||
$orderData = [
|
||||
'OrderID' => $orderID,
|
||||
'PlacerID' => $data['PlacerID'] ?? null,
|
||||
'InternalPID' => $data['InternalPID'],
|
||||
'PatVisitID' => $data['PatVisitID'] ?? null,
|
||||
'OrderDateTime' => $data['OrderDateTime'] ?? date('Y-m-d H:i:s'),
|
||||
'SiteID' => $data['SiteID'] ?? '1',
|
||||
'PVADTID' => $data['PatVisitID'] ?? $data['PVADTID'] ?? 0,
|
||||
'ReqApp' => $data['ReqApp'] ?? null,
|
||||
'Priority' => $data['Priority'] ?? 'R',
|
||||
'OrderStatus' => $data['OrderStatus'] ?? 'ORD',
|
||||
'OrderedBy' => $data['OrderedBy'] ?? null,
|
||||
'OrderingProvider' => $data['OrderingProvider'] ?? null,
|
||||
'SiteID' => $data['SiteID'] ?? 1,
|
||||
'SourceSiteID' => $data['SourceSiteID'] ?? 1,
|
||||
'DepartmentID' => $data['DepartmentID'] ?? null,
|
||||
'WorkstationID' => $data['WorkstationID'] ?? null,
|
||||
'BillingAccount' => $data['BillingAccount'] ?? null,
|
||||
'TrnDate' => $data['OrderDateTime'] ?? $data['TrnDate'] ?? date('Y-m-d H:i:s'),
|
||||
'EffDate' => $data['EffDate'] ?? 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
return $this->select('*')
|
||||
->where('OrderID', $orderID)
|
||||
@ -87,16 +160,23 @@ class OrderTestModel extends BaseModel {
|
||||
return $this->select('*')
|
||||
->where('InternalPID', $internalPID)
|
||||
->where('DelDate', null)
|
||||
->orderBy('OrderDateTime', 'DESC')
|
||||
->orderBy('TrnDate', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
}
|
||||
|
||||
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 {
|
||||
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')]);
|
||||
}
|
||||
}
|
||||
|
||||
28
app/Models/PatResultModel.php
Normal file
28
app/Models/PatResultModel.php
Normal 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'
|
||||
];
|
||||
}
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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;
|
||||
}
|
||||
94
tests/feature/ContactControllerTest.php
Normal file
94
tests/feature/ContactControllerTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
122
tests/feature/OrganizationControllerTest.php
Normal file
122
tests/feature/OrganizationControllerTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
118
tests/feature/TestsControllerTest.php
Normal file
118
tests/feature/TestsControllerTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@ -364,10 +364,12 @@ class ValueSetTest extends CIUnitTestCase
|
||||
'Country' => 'country'
|
||||
]);
|
||||
|
||||
$this->assertEquals('Female', $result[0]['Gender']);
|
||||
$this->assertEquals('1', $result[0]['GenderKey']);
|
||||
$this->assertEquals('Male', $result[1]['Gender']);
|
||||
$this->assertEquals('2', $result[1]['GenderKey']);
|
||||
$this->assertEquals('1', $result[0]['Gender']);
|
||||
$this->assertEquals('Female', $result[0]['GenderLabel']);
|
||||
$this->assertEquals('2', $result[1]['Gender']);
|
||||
$this->assertEquals('Male', $result[1]['GenderLabel']);
|
||||
$this->assertEquals('USA', $result[1]['Country']);
|
||||
$this->assertEquals('United States of America', $result[1]['CountryLabel']);
|
||||
}
|
||||
|
||||
public function testGetOptions()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user