feat: Implement audit trail system for dual-level validation workflow

This commit adds comprehensive audit logging for specimen requests and sample collection activities across all roles.
Changes Summary:
New Features:
- Added AUDIT_EVENTS table schema for tracking validation and sample collection events
- Created ApiRequestsAuditController with /api/requests/(:any)/audit endpoint to retrieve audit history
- Added dialog_audit.php view component for displaying audit trails in UI
- Integrated audit logging into validation workflow (VAL1, VAL2, UNVAL events)
Database:
- Created AUDIT_EVENTS table with columns: ACCESSNUMBER, EVENT_TYPE, USERID, EVENT_AT, REASON
- Supports tracking validation events and sample collection actions
Controllers:
- RequestsController: Now inserts audit records for all validation operations
- ApiRequestsAuditController: New API controller returning validation and sample collection history
Routes:
- Added GET /api/requests/(:any)/audit endpoint for retrieving audit trail
- Removed DELETE /api/samples/collect/(:any) endpoint (uncollect functionality)
Views Refactoring:
- Consolidated dashboard layouts into shared components:
  - layout.php (from layout_dashboard.php)
  - script_requests.php (from script_dashboard.php)
  - script_validation.php (from script_validate.php)
  - content_requests.php (from dashboard_table.php)
  - content_validation.php (from dashboard_validate.php)
- Added content_validation_new.php for enhanced validation interface
This commit is contained in:
mahdahar 2026-01-23 16:41:12 +07:00
parent 8e82cbad52
commit 3cf4cc7f3f
35 changed files with 863 additions and 399 deletions

244
AGENTS.md
View File

@ -4,14 +4,14 @@ This file provides guidance to agents when working with code in this repository.
## Project Overview ## Project Overview
This is a CodeIgniter 4 PHP application for a laboratory management system (CMOD). It uses SQL Server database with role-based access control for different user types (admin, doctor/analyst, customer service). CodeIgniter 4 PHP application for laboratory management (GDC CMOD). Handles specimen tracking, request validation, and result management with role-based access control. SQL Server database with Firebird legacy patient data.
## Commands ## Commands
```bash ```bash
# Run all tests # Run all tests
./vendor/bin/phpunit
composer test composer test
./vendor/bin/phpunit
# Run single test file # Run single test file
./vendor/bin/phpunit tests/unit/HealthTest.php ./vendor/bin/phpunit tests/unit/HealthTest.php
@ -19,249 +19,145 @@ composer test
# Run single test method # Run single test method
./vendor/bin/phpunit tests/unit/HealthTest.php --filter testIsDefinedAppPath ./vendor/bin/phpunit tests/unit/HealthTest.php --filter testIsDefinedAppPath
# Development server (Linux/Mac) # Development server
php spark serve php spark serve
# List all routes # List all routes
php spark list php spark list
# Create controller # Create controller/model
php spark make:controller Admin php spark make:controller Admin
# Create model
php spark make:model User php spark make:model User
``` ```
## Code Style Guidelines ## PHP Standards
### PHP Standards - PHP 8.1+ features (typed properties, match expressions)
- Use PHP 8.1+ features (typed properties, match expressions where appropriate)
- Always declare return types for public methods - Always declare return types for public methods
- Use `strict_types=1` not required (CodeIgniter doesn't use it) - No comments unless explaining complex logic
- No comments unless explaining complex logic (per project convention) - Use `esc()` when outputting user data in views
### Naming Conventions ## Naming Conventions
- **Classes**: PascalCase (e.g., `Admin`, `UserController`)
- **Methods**: camelCase (e.g., `index()`, `getUsers()`)
- **Variables**: camelCase (e.g., `$userId`, `$dataList`)
- **Constants**: UPPER_SNAKE_CASE (e.g., `DB_HOST`)
- **Database tables**: UPPER_SNAKE_CASE (e.g., `GDC_CMOD.dbo.USERS`)
- **Views**: lowercase with underscores (e.g., `admin/index.php`)
### Controller Patterns | Type | Convention | Example |
|------|------------|---------|
| Classes | PascalCase | `Admin`, `UserController` |
| Methods/Variables | camelCase | `getUsers()`, `$userId` |
| Constants | UPPER_SNAKE_CASE | `DB_HOST` |
| Database tables | UPPER_SNAKE_CASE | `GDC_CMOD.dbo.USERS` |
| Views | lowercase_underscores | `admin/index.php` |
#### Base Controllers ## Role-Based Access Control
- All controllers extend `App\Controllers\BaseController`
- BaseController extends CodeIgniter\Controller | Role ID | Name | Route Prefix |
|---------|------|--------------|
| 0 | Superuser | `/superuser` |
| 1 | Admin | `/admin` |
| 2 | Lab | `/lab` |
| 3 | Phlebo | `/phlebo` |
| 4 | CS | `/cs` |
```php
// Single role
['filter' => 'role:1']
// Multiple roles
['filter' => 'role:1,2']
```
## Controller Patterns
```php ```php
namespace App\Controllers; namespace App\Controllers;
class Admin extends BaseController { class Admin extends BaseController {
public function index() { public function index() { }
// Method body
}
} }
```
#### API Controllers
- Controllers use `ResponseTrait` for API responses
- Use `$this->respond()` or `$this->response->setJSON()` for responses
```php
namespace App\Controllers;
// API Controllers use ResponseTrait
use App\Controllers\BaseController; use App\Controllers\BaseController;
use CodeIgniter\API\ResponseTrait; use CodeIgniter\API\ResponseTrait;
class Users extends BaseController { class Users extends BaseController {
use ResponseTrait; use ResponseTrait;
protected $db; protected $db;
public function __construct() { public function __construct() {
$this->db = \Config\Database::connect(); $this->db = \Config\Database::connect();
helper(['url', 'form', 'text']); helper(['url', 'form', 'text']);
} }
public function index() {
$query = $this->db->query("SELECT * FROM table");
return $this->respond(['data' => $query->getResultArray()]);
}
} }
``` ```
### Database Operations ## Database Operations
#### Connection Pattern
```php ```php
$this->db = \Config\Database::connect(); $this->db = \Config\Database::connect();
```
#### Query Methods // Parameterized queries only
- `getRowArray()` - returns single row as associative array
- `getResultArray()` - returns multiple rows as array of arrays
- Use parameterized queries to prevent SQL injection
```php
$query = $this->db->query("SELECT * FROM table WHERE id = ?", [$id]); $query = $this->db->query("SELECT * FROM table WHERE id = ?", [$id]);
$row = $query->getRowArray(); $row = $query->getRowArray();
$results = $query->getResultArray(); $results = $query->getResultArray();
```
#### Transactions // Transactions
```php
$this->db->transBegin(); $this->db->transBegin();
try { try {
$this->db->query("INSERT INTO ...", [$data]); $this->db->query("INSERT INTO ...", [$data]);
$this->db->transCommit(); $this->db->transCommit();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->db->transRollback(); $this->db->transRollback();
return $this->response->setJSON(['message' => 'Error']);
} }
``` ```
### Session Management ## Request/Response Patterns
#### Session Structure
```php ```php
$session->set([ // GET input
'isLoggedIn' => true,
'userid' => (string) $user['USERID'],
'userlevel' => (int) $user['USERLEVEL'],
'userrole' => (string) $role, // 'admin', 'doctor', 'analyst', 'cs'
]);
```
#### Session Values
- `isLoggedIn`: bool
- `userid`: string
- `userlevel`: int
- `userrole`: string
### Role-Based Access Control
#### Role Values
- `1` = admin
- `2` = doctor (or lab/analyst)
- `3` = analyst
- `4` = cs (customer service)
#### Route Filter Syntax
```php
// Single role
['filter' => 'role:1']
// Multiple roles
['filter' => 'role:1,2']
```
### Request/Response Patterns
#### Getting Input
```php
// POST data
$input = $this->request->getJSON(true);
$userid = $input['userid'];
// Query parameters
$date1 = $this->request->getVar('date1') ?? date('Y-m-d'); $date1 = $this->request->getVar('date1') ?? date('Y-m-d');
```
#### JSON Response (V2 API) // POST JSON
```php $input = $this->request->getJSON(true);
// JSON response
return $this->respond(['data' => $results]); return $this->respond(['data' => $results]);
return $this->response->setJSON(['message' => 'Success']); return $this->response->setJSON(['message' => 'Success']);
```
#### View Response (Traditional) // View response
```php
return view('admin/index', $data); return view('admin/index', $data);
```
#### Redirect with Errors // Redirect with errors
```php
return redirect()->back()->with('errors', ['key' => 'message']); return redirect()->back()->with('errors', ['key' => 'message']);
``` ```
### API Endpoint Patterns ## Session Structure
```php
session()->set([
'isLoggedIn' => true,
'userid' => (string) $user['USERID'],
'userroleid' => (int) $user['USERROLEID'],
'userrole' => (string) $role,
]);
```
## Validation Endpoints
#### Validation Endpoints
- `POST /api/{resource}/validate/{id}` - validate a record - `POST /api/{resource}/validate/{id}` - validate a record
- `DELETE /api/{resource}/validate/{id}` - unvalidate a record - `DELETE /api/{resource}/validate/{id}` - unvalidate a record
#### Route Examples ## Security
```php
// Admin routes
$routes->group('admin', ['filter' => 'role:1'], function($routes) {
$routes->get('/', 'Admin::index');
$routes->get('users', 'Admin::users');
$routes->get('api/users', 'Users::index');
$routes->post('api/users', 'Users::create');
});
// Lab routes - Use parameterized queries (never interpolate directly)
$routes->group('lab', ['filter' => 'role:2'], function($routes) { - Hash passwords with `password_hash()` / `password_verify()`
$routes->get('/', 'Lab::index');
$routes->get('api/requests', 'Requests::index');
});
```
### Error Handling
#### Database Operations
```php
try {
// DB operations
} catch (\Throwable $e) {
// Handle error
return $this->response->setJSON(['message' => 'Server error']);
}
```
#### Validation Errors
```php
if ($condition) {
return $this->response->setJSON(['message' => 'Error message']);
}
```
### Views
#### View Pattern
- Views are plain PHP files in `app/Views/`
- Use `<?php echo $variable; ?>` syntax
- Pass data as associative array
```php
// Controller
$data['dataList'] = $results;
return view('admin/index', $data);
// View
<?php foreach ($dataList as $item): ?>
<tr><td><?= esc($item['name']) ?></td></tr>
<?php endforeach; ?>
```
### Security
- Always use parameterized queries (never interpolate directly)
- Use `esc()` when outputting user data in views
- Hash passwords with `password_hash()` and verify with `password_verify()`
- Validate and sanitize all input before use - Validate and sanitize all input before use
### Helper Functions ## Database Schema
Available helpers loaded via `helper(['name', 'name2'])`: - Primary: SQL Server (`GDC_CMOD.dbo`)
- `url` - URL helpers - Legacy: Firebird (`GLENEAGLES` via ODBC)
- `form` - Form helpers - No CI4 Models - raw SQL queries via `Database::connect()`
- `text` - Text formatting
### Database Schema ## Dual-Level Validation
- Database: SQL Server Validation requires 2 different users:
- Schema: `dbo` 1. First: `ISVAL1=1`, `VAL1USER`, `VAL1DATE`
- Main database: `GDC_CMOD` 2. Second (different user): `ISVAL2=1`, `VAL2USER`, `VAL2DATE`
- Reference database: `glendb`
- Table naming: `GDC_CMOD.dbo.TABLENAME`

View File

@ -3,16 +3,16 @@
**Last Updated:** January 22, 2026 **Last Updated:** January 22, 2026
Pending: Pending:
- Restrict 'UnValidate' to Admin
- Restrict Print/Save-to-PDF to CS Role only (Lab can only preview, CS can print/save) - Restrict Print/Save-to-PDF to CS Role only (Lab can only preview, CS can print/save)
- Add Dedicated Print Button (Trigger browser/system print dialog)
- Create 'Detail Unvalidated' History Log/View (Log unvalidation actions with timestamp, user ID, and reason) - Create 'Detail Unvalidated' History Log/View (Log unvalidation actions with timestamp, user ID, and reason)
- Enhanced Patient Detail Logging (Track: Sample Collection Time, Sample Received Time, Print History) - Enhanced Patient Detail Logging (Track: Sample Collection Time, Sample Received Time, Print History)
- Add Dedicated Print Button (Trigger browser/system print dialog)
- Add Error Handling for Preview Button (Handle empty data gracefully) - Add Error Handling for Preview Button (Handle empty data gracefully)
- Ensure 'Uncollect' Feature Functional (Maintain Uncollect feature functionality) - Ensure 'Uncollect' Feature Functional (Maintain Uncollect feature functionality)
- Backend Performance & Connectivity (Investigate intermittent connection issues with Server 253) - Backend Performance & Connectivity (Investigate intermittent connection issues with Server 253)
- Update PDF Report Metadata (Replace 'Printed By' with validating user's name) - Update PDF Report Metadata (Replace 'Printed By' with validating user's name)
- Reprint Label (Add functionality to reprint labels)
- **Print Result Audit** (Track when result reports are printed/exported, log user and timestamp)
Completed: Completed:
- 01 : Update User Role levels (Standardize roles: Superuser, Admin, Lab, Phlebo, CS) - 01 : Update User Role levels (Standardize roles: Superuser, Admin, Lab, Phlebo, CS)
@ -29,6 +29,9 @@ Completed:
- 12 : Remove 'status' field on dashboard - 12 : Remove 'status' field on dashboard
- 13 : Restrict 'Validate' to Lab, Admin, Superuser - 13 : Restrict 'Validate' to Lab, Admin, Superuser
- 14 : Hide/Disable 'Validation' button after 2nd validation (Prevent redundant validation actions) - 14 : Hide/Disable 'Validation' button after 2nd validation (Prevent redundant validation actions)
- 15 : Restrict 'UnValidate' to Admin, Superuser
- 16 : Remove 'UnCollect'
Addition on dev : Addition on dev :
- adding init-isDev on index.php to set default date on dev dashboard - adding init-isDev on index.php to set default date on dev dashboard

View File

@ -47,7 +47,7 @@ This application uses role-based access control with four user roles.
- **Dashboard** - View all requests with status filters (Pend, Coll, Recv, Inc, Fin, Val) - **Dashboard** - View all requests with status filters (Pend, Coll, Recv, Inc, Fin, Val)
- **User Management** - Create, edit, delete users; assign roles - **User Management** - Create, edit, delete users; assign roles
- **Request Management** - View, validate, unvalidate all requests - **Request Management** - View, validate, unvalidate all requests
- **Sample Management** - Collect, uncollect, receive, unreceive samples - **Sample Management** - Collect, receive, unreceive samples
- **Result Management** - Preview and print results - **Result Management** - Preview and print results
#### Lab #### Lab

View File

@ -35,6 +35,7 @@ $routes->group('api', function ($routes) {
// Requests - All Roles (0,1,2,3,4) // Requests - All Roles (0,1,2,3,4)
$routes->group('requests', ['filter' => 'role:0,1,2,3,4'], function ($routes) { $routes->group('requests', ['filter' => 'role:0,1,2,3,4'], function ($routes) {
$routes->get('', 'RequestsController::index'); $routes->get('', 'RequestsController::index');
$routes->get('(:any)/audit', 'ApiRequestsAuditController::show/$1');
$routes->post('validate/(:any)', 'RequestsController::val/$1'); $routes->post('validate/(:any)', 'RequestsController::val/$1');
$routes->delete('validate/(:any)', 'RequestsController::unval/$1'); $routes->delete('validate/(:any)', 'RequestsController::unval/$1');
}); });
@ -52,9 +53,8 @@ $routes->group('api', function ($routes) {
$routes->get('(:any)', 'SamplesController::show/$1'); $routes->get('(:any)', 'SamplesController::show/$1');
}); });
// Uncollect & Unreceive - Only Superuser (0) and Admin (1) // Unreceive - Only Superuser (0) and Admin (1)
$routes->group('', ['filter' => 'role:0,1'], function ($routes) { $routes->group('', ['filter' => 'role:0,1'], function ($routes) {
$routes->delete('collect/(:any)', 'SamplesController::uncollect/$1');
$routes->delete('receive/(:any)', 'SamplesController::unreceive/$1'); $routes->delete('receive/(:any)', 'SamplesController::unreceive/$1');
}); });
}); });

View File

@ -0,0 +1,52 @@
<?php
namespace App\Controllers;
use App\Controllers\BaseController;
use CodeIgniter\API\ResponseTrait;
class ApiRequestsAuditController extends BaseController {
use ResponseTrait;
public function show($accessnumber) {
$db = \Config\Database::connect();
$result = [
'accessnumber' => $accessnumber,
'validation' => [],
'sample_collection' => []
];
$sqlAudit = "SELECT EVENT_TYPE, USERID, EVENT_AT, REASON
FROM GDC_CMOD.dbo.AUDIT_EVENTS
WHERE ACCESSNUMBER = ?
ORDER BY EVENT_AT ASC";
$auditRows = $db->query($sqlAudit, [$accessnumber])->getResultArray();
foreach ($auditRows as $row) {
$isUnval = $row['EVENT_TYPE'] === 'UNVAL';
$result['validation'][] = [
'type' => $row['EVENT_TYPE'],
'user' => trim($row['USERID']),
'datetime' => $row['EVENT_AT'] ? date('Y-m-d H:i:s', strtotime($row['EVENT_AT'])) : null,
'reason' => $isUnval ? trim($row['REASON']) : null
];
}
$sqlTube = "SELECT TUBENUMBER, USERID, STATUS, LOGDATE
FROM GDC_CMOD.dbo.AUDIT_TUBES
WHERE ACCESSNUMBER = ?
ORDER BY LOGDATE ASC";
$tubeRows = $db->query($sqlTube, [$accessnumber])->getResultArray();
foreach ($tubeRows as $row) {
$action = $row['STATUS'] == 1 ? 'COLLECTED' : 'UNRECEIVED';
$result['sample_collection'][] = [
'tubenumber' => trim($row['TUBENUMBER']),
'user' => trim($row['USERID']),
'datetime' => $row['LOGDATE'] ? date('Y-m-d H:i:s', strtotime($row['LOGDATE'])) : null,
'action' => $action
];
}
return $this->respond(['status' => 'success', 'data' => $result]);
}
}

View File

@ -6,7 +6,8 @@ class LabelController extends BaseController
public function coll($reqnum) public function coll($reqnum)
{ {
$db = \Config\Database::connect(); $db = \Config\Database::connect();
//$reqnum = str_pad($reqnum, 10, 0, STR_PAD_LEFT); $userid = session()->get('userid') ?? 'system';
$sql = "select p.PATNUMBER, $sql = "select p.PATNUMBER,
[Name] = case [Name] = case
when p.TITLEID is not null then ISNULL(p.FIRSTNAME,'') + ' ' + ISNULL(p.NAME,'') + ', ' + tx.SHORTTEXT when p.TITLEID is not null then ISNULL(p.FIRSTNAME,'') + ' ' + ISNULL(p.NAME,'') + ', ' + tx.SHORTTEXT
@ -70,6 +71,8 @@ P1\n]";
public function dispatch($reqnum, $samid) public function dispatch($reqnum, $samid)
{ {
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$userid = session()->get('userid') ?? 'system';
$sql = "select p.PATNUMBER, $sql = "select p.PATNUMBER,
[Name] = case [Name] = case
when p.TITLEID is not null then ISNULL(p.FIRSTNAME,'') + ' ' + ISNULL(p.NAME,'') + ', ' + tx.SHORTTEXT when p.TITLEID is not null then ISNULL(p.FIRSTNAME,'') + ' ' + ISNULL(p.NAME,'') + ', ' + tx.SHORTTEXT
@ -147,6 +150,8 @@ P1
public function print_all($accessnumber) public function print_all($accessnumber)
{ {
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$userid = session()->get('userid') ?? 'system';
$this->coll($accessnumber); $this->coll($accessnumber);
$sql = "select SAMPCODE from GDC_CMOD.dbo.v_sp_reqtube where SP_ACCESSNUMBER='$accessnumber'"; $sql = "select SAMPCODE from GDC_CMOD.dbo.v_sp_reqtube where SP_ACCESSNUMBER='$accessnumber'";
$rows = $db->query($sql)->getResultArray(); $rows = $db->query($sql)->getResultArray();

View File

@ -4,24 +4,29 @@ namespace App\Controllers\Pages;
use App\Controllers\BaseController; use App\Controllers\BaseController;
class AdminController extends BaseController { class AdminController extends BaseController
{
public function __construct() { public function __construct()
{
helper(['url', 'form', 'text']); helper(['url', 'form', 'text']);
} }
public function index() { public function index()
$config = require APPPATH . 'Views/shared/dashboard_config.php'; {
$config = require APPPATH . 'Views/shared/config.php';
return view('admin/index', ['roleConfig' => $config['admin']]); return view('admin/index', ['roleConfig' => $config['admin']]);
} }
public function users() { public function users()
$config = require APPPATH . 'Views/shared/dashboard_config.php'; {
$config = require APPPATH . 'Views/shared/config.php';
return view('admin/users', ['roleConfig' => $config['admin']]); return view('admin/users', ['roleConfig' => $config['admin']]);
} }
public function validationPage() { public function validationPage()
$config = require APPPATH . 'Views/shared/dashboard_config.php'; {
$config = require APPPATH . 'Views/shared/config.php';
return view('admin/validate', ['roleConfig' => $config['admin']]); return view('admin/validate', ['roleConfig' => $config['admin']]);
} }

View File

@ -4,14 +4,17 @@ namespace App\Controllers\Pages;
use App\Controllers\BaseController; use App\Controllers\BaseController;
class CsController extends BaseController { class CsController extends BaseController
{
public function __construct() { public function __construct()
{
helper(['url', 'form', 'text']); helper(['url', 'form', 'text']);
} }
public function index() { public function index()
$config = require APPPATH . 'Views/shared/dashboard_config.php'; {
$config = require APPPATH . 'Views/shared/config.php';
return view('cs/index', ['roleConfig' => $config['cs']]); return view('cs/index', ['roleConfig' => $config['cs']]);
} }

View File

@ -4,19 +4,23 @@ namespace App\Controllers\Pages;
use App\Controllers\BaseController; use App\Controllers\BaseController;
class LabController extends BaseController { class LabController extends BaseController
{
public function __construct() { public function __construct()
{
helper(['url', 'form', 'text']); helper(['url', 'form', 'text']);
} }
public function index() { public function index()
$config = require APPPATH . 'Views/shared/dashboard_config.php'; {
$config = require APPPATH . 'Views/shared/config.php';
return view('lab/index', ['roleConfig' => $config['lab']]); return view('lab/index', ['roleConfig' => $config['lab']]);
} }
public function validationPage() { public function validationPage()
$config = require APPPATH . 'Views/shared/dashboard_config.php'; {
$config = require APPPATH . 'Views/shared/config.php';
return view('lab/validate', ['roleConfig' => $config['lab']]); return view('lab/validate', ['roleConfig' => $config['lab']]);
} }

View File

@ -4,14 +4,17 @@ namespace App\Controllers\Pages;
use App\Controllers\BaseController; use App\Controllers\BaseController;
class PhlebotomistController extends BaseController { class PhlebotomistController extends BaseController
{
public function __construct() { public function __construct()
{
helper(['url', 'form', 'text']); helper(['url', 'form', 'text']);
} }
public function index() { public function index()
$config = require APPPATH . 'Views/shared/dashboard_config.php'; {
$config = require APPPATH . 'Views/shared/config.php';
return view('phlebo/index', ['roleConfig' => $config['phlebo']]); return view('phlebo/index', ['roleConfig' => $config['phlebo']]);
} }

View File

@ -14,19 +14,19 @@ class SuperuserController extends BaseController
public function index() public function index()
{ {
$config = require APPPATH . 'Views/shared/dashboard_config.php'; $config = require APPPATH . 'Views/shared/config.php';
return view('superuser/index', ['roleConfig' => $config['superuser']]); return view('superuser/index', ['roleConfig' => $config['superuser']]);
} }
public function users() public function users()
{ {
$config = require APPPATH . 'Views/shared/dashboard_config.php'; $config = require APPPATH . 'Views/shared/config.php';
return view('superuser/users', ['roleConfig' => $config['superuser']]); return view('superuser/users', ['roleConfig' => $config['superuser']]);
} }
public function validatePage() public function validatePage()
{ {
$config = require APPPATH . 'Views/shared/dashboard_config.php'; $config = require APPPATH . 'Views/shared/config.php';
return view('superuser/validate', ['roleConfig' => $config['superuser']]); return view('superuser/validate', ['roleConfig' => $config['superuser']]);
} }

View File

@ -63,13 +63,17 @@ class RequestsController extends BaseController
public function unval($accessnumber) public function unval($accessnumber)
{ {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
// Securely get userid from session
$userid = session('userid'); $userid = session('userid');
$comment = $input['comment']; $comment = $input['comment'];
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$sql = "update GDC_CMOD.dbo.CM_REQUESTS set ISVAL1=null, VAL1USER=null, VAL1DATE=null, ISVAL2=null, VAL2USER=null, VAL2DATE=null, $sql = "update GDC_CMOD.dbo.CM_REQUESTS set ISVAL1=null, VAL1USER=null, VAL1DATE=null, ISVAL2=null, VAL2USER=null, VAL2DATE=null,
ISPENDING=1, PENDINGTEXT='$comment', PENDINGUSER='$userid', PENDINGDATE=GETDATE() where ACCESSNUMBER='$accessnumber'"; ISPENDING=1, PENDINGTEXT='$comment', PENDINGUSER='$userid', PENDINGDATE=GETDATE() where ACCESSNUMBER='$accessnumber'";
$db->query($sql); $db->query($sql);
$logAudit = "INSERT INTO GDC_CMOD.dbo.AUDIT_EVENTS (ACCESSNUMBER, EVENT_TYPE, USERID, EVENT_AT, REASON)
VALUES (?, 'UNVAL', ?, GETDATE(), ?)";
$db->query($logAudit, [$accessnumber, $userid, $comment]);
$data = ['status' => 'success', 'message' => 'Data updated successfully', 'data' => "$accessnumber"]; $data = ['status' => 'success', 'message' => 'Data updated successfully', 'data' => "$accessnumber"];
return $this->response->setJSON($data); return $this->response->setJSON($data);
@ -86,6 +90,10 @@ class RequestsController extends BaseController
if (!isset($result[0])) { if (!isset($result[0])) {
$sql = "insert into GDC_CMOD.dbo.CM_REQUESTS(ACCESSNUMBER, ISVAL1, VAL1USER, VAL1DATE) VALUES ('$accessnumber', 1, '$userid', GETDATE())"; $sql = "insert into GDC_CMOD.dbo.CM_REQUESTS(ACCESSNUMBER, ISVAL1, VAL1USER, VAL1DATE) VALUES ('$accessnumber', 1, '$userid', GETDATE())";
$db->query($sql); $db->query($sql);
$logAudit = "INSERT INTO GDC_CMOD.dbo.AUDIT_EVENTS (ACCESSNUMBER, EVENT_TYPE, USERID, EVENT_AT) VALUES (?, 'VAL1', ?, GETDATE())";
$db->query($logAudit, [$accessnumber, $userid]);
$data['val'] = 1; $data['val'] = 1;
$data['userid'] = $userid; $data['userid'] = $userid;
} else { } else {
@ -99,6 +107,10 @@ class RequestsController extends BaseController
} else { } else {
if ($val1user != $userid) { if ($val1user != $userid) {
$sql = "update GDC_CMOD.dbo.CM_REQUESTS set ISVAL2=1, VAL2USER='$userid', VAL2DATE=GETDATE() where ACCESSNUMBER='$accessnumber'"; $sql = "update GDC_CMOD.dbo.CM_REQUESTS set ISVAL2=1, VAL2USER='$userid', VAL2DATE=GETDATE() where ACCESSNUMBER='$accessnumber'";
$logAudit = "INSERT INTO GDC_CMOD.dbo.AUDIT_EVENTS (ACCESSNUMBER, EVENT_TYPE, USERID, EVENT_AT) VALUES (?, 'VAL2', ?, GETDATE())";
$db->query($logAudit, [$accessnumber, $userid]);
$data['val'] = 2; $data['val'] = 2;
$data['userid'] = $userid; $data['userid'] = $userid;
} else { } else {
@ -108,6 +120,10 @@ class RequestsController extends BaseController
} }
} else { } else {
$sql = "update GDC_CMOD.dbo.CM_REQUESTS set ISVAL1=1, VAL1USER='$userid', VAL1DATE=GETDATE() where ACCESSNUMBER='$accessnumber'"; $sql = "update GDC_CMOD.dbo.CM_REQUESTS set ISVAL1=1, VAL1USER='$userid', VAL1DATE=GETDATE() where ACCESSNUMBER='$accessnumber'";
$logAudit = "INSERT INTO GDC_CMOD.dbo.AUDIT_EVENTS (ACCESSNUMBER, EVENT_TYPE, USERID, EVENT_AT) VALUES (?, 'VAL1', ?, GETDATE())";
$db->query($logAudit, [$accessnumber, $userid]);
$data['val'] = 1; $data['val'] = 1;
$data['userid'] = $userid; $data['userid'] = $userid;
} }

View File

@ -76,20 +76,6 @@ class SamplesController extends BaseController
return $this->respondCreated(['status' => 'success', 'message' => 'Data updated successfully', 'data' => "$accessnumber-$samplenumber"], 201); return $this->respondCreated(['status' => 'success', 'message' => 'Data updated successfully', 'data' => "$accessnumber-$samplenumber"], 201);
} }
public function uncollect($accessnumber)
{
$db = \Config\Database::connect();
$input = $this->request->getJSON(true);
$samplenumber = $input['samplenumber'];
$userid = session('userid');
$sql = "update GDC_CMOD.dbo.TUBES set USERID='$userid',STATUS='0', COLLECTIONDATE=getdate() where ACCESSNUMBER='$accessnumber' and TUBENUMBER='$samplenumber'";
$db->query($sql);
$sql = "INSERT INTO GDC_CMOD.dbo.AUDIT_TUBES(ACCESSNUMBER, TUBENUMBER, USERID, STATUS, LOGDATE)
VALUES ('$accessnumber', '$samplenumber', '$userid', '0', getdate())";
$db->query($sql);
return $this->respondCreated(['status' => 'success', 'message' => 'Data updated successfully', 'data' => "$accessnumber-$samplenumber"], 201);
}
public function unreceive($accessnumber) public function unreceive($accessnumber)
{ {
$db = \Config\Database::connect(); $db = \Config\Database::connect();

View File

@ -72,19 +72,6 @@ class Sample extends BaseController {
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => "$accessnumber-$samplenumber" ], 201); return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => "$accessnumber-$samplenumber" ], 201);
} }
public function uncollect($accessnumber) {
$db = \Config\Database::connect();
$input = $this->request->getJSON(true);
$samplenumber = $input['samplenumber'];
$userid = $input['userid'];
$sql = "update GDC_CMOD.dbo.TUBES set USERID='$userid',STATUS='0', COLLECTIONDATE=getdate() where ACCESSNUMBER='$accessnumber' and TUBENUMBER='$samplenumber'";
$db->query($sql);
$sql = "INSERT INTO GDC_CMOD.dbo.AUDIT_TUBES(ACCESSNUMBER, TUBENUMBER, USERID, STATUS, LOGDATE)
VALUES ('$accessnumber', '$samplenumber', '$userid', '0', getdate())";
$db->query($sql);
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => "$accessnumber-$samplenumber" ], 201);
}
public function unreceive($accessnumber) { public function unreceive($accessnumber) {
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);

View File

@ -1,22 +1,23 @@
<?php <?php
$config = include __DIR__ . '/../shared/dashboard_config.php'; $config = include __DIR__ . '/../shared/config.php';
$roleConfig = $config['admin']; $roleConfig = $config['admin'];
?> ?>
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?> <?= $this->extend('shared/layout', ['roleConfig' => $roleConfig]); ?>
<?= $this->section('content'); ?> <?= $this->section('content'); ?>
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard"> <main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?> <?= $this->include('shared/content_requests', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?> <?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?> <?= $this->include('shared/dialog_unval'); ?>
<?= $this->include('shared/dialog_preview'); ?> <?= $this->include('shared/dialog_preview'); ?>
<?= $this->include('shared/dialog_audit'); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script'); ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_dashboard'); ?> <?= $this->include('shared/script_requests'); ?>
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -1,19 +1,19 @@
<?php <?php
$config = include __DIR__ . '/../shared/dashboard_config.php'; $config = include __DIR__ . '/../shared/config.php';
$roleConfig = $config['admin']; $roleConfig = $config['admin'];
?> ?>
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?> <?= $this->extend('shared/layout', ['roleConfig' => $roleConfig]); ?>
<?= $this->section('content'); ?> <?= $this->section('content'); ?>
<main class="p-4 flex-1 flex flex-col gap-2" x-data="validatePage"> <main class="p-4 flex-1 flex flex-col gap-2" x-data="validatePage">
<?= $this->include('shared/dashboard_validate', ['config' => $roleConfig]); ?> <?= $this->include('shared/content_validation', ['config' => $roleConfig]); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script'); ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_validate'); ?> <?= $this->include('shared/script_validation'); ?>
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -1,22 +1,23 @@
<?php <?php
$config = include __DIR__ . '/../shared/dashboard_config.php'; $config = include __DIR__ . '/../shared/config.php';
$roleConfig = $config['cs']; $roleConfig = $config['cs'];
?> ?>
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?> <?= $this->extend('shared/layout', ['roleConfig' => $roleConfig]); ?>
<?= $this->section('content'); ?> <?= $this->section('content'); ?>
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard"> <main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?> <?= $this->include('shared/content_requests', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?> <?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?> <?= $this->include('shared/dialog_unval'); ?>
<?= $this->include('shared/dialog_preview'); ?> <?= $this->include('shared/dialog_preview'); ?>
<?= $this->include('shared/dialog_audit'); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script'); ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_dashboard'); ?> <?= $this->include('shared/script_requests'); ?>
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -10,6 +11,7 @@
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
@ -22,51 +24,71 @@
text-align: center; text-align: center;
padding: 20px; padding: 20px;
} }
.container { .container {
max-width: 600px; max-width: 600px;
} }
h1 { h1 {
font-size: 3rem; font-size: 3rem;
margin-bottom: 1rem; margin-bottom: 1rem;
animation: bounce 2s infinite; animation: bounce 2s infinite;
} }
@keyframes bounce { @keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-20px); } 0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
} }
.message { .message {
font-size: 1.5rem; font-size: 1.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
opacity: 0.9; opacity: 0.9;
} }
.loader { .loader {
width: 80px; width: 80px;
height: 80px; height: 80px;
border: 8px solid rgba(255,255,255,0.3); border: 8px solid rgba(255, 255, 255, 0.3);
border-top-color: white; border-top-color: white;
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
margin: 0 auto 2rem; margin: 0 auto 2rem;
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
.cat { .cat {
font-size: 5rem; font-size: 5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.fact { .fact {
background: rgba(255,255,255,0.2); background: rgba(255, 255, 255, 0.2);
padding: 1.5rem; padding: 1.5rem;
border-radius: 15px; border-radius: 15px;
margin-top: 2rem; margin-top: 2rem;
} }
.fact h3 { .fact h3 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.buttons { .buttons {
margin-top: 2rem; margin-top: 2rem;
} }
.btn { .btn {
display: inline-block; display: inline-block;
padding: 12px 30px; padding: 12px 30px;
@ -78,16 +100,19 @@
font-weight: bold; font-weight: bold;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
} }
.btn:hover { .btn:hover {
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 5px 20px rgba(0,0,0,0.3); box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
} }
.shrug { .shrug {
font-size: 2rem; font-size: 2rem;
margin: 1rem 0; margin: 1rem 0;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="cat">&#128064;</div> <div class="cat">&#128064;</div>
@ -101,11 +126,6 @@
</div> </div>
<div class="shrug">&#128633; &#129472; &#129469;</div> <div class="shrug">&#128633; &#129472; &#129469;</div>
<div class="buttons">
<a href="javascript:history.back()" class="btn">Go Back</a>
<a href="/gdc_cmod/" class="btn">Home</a>
</div>
</div> </div>
<script> <script>
@ -125,4 +145,5 @@
document.getElementById('fact').textContent = facts[Math.floor(Math.random() * facts.length)]; document.getElementById('fact').textContent = facts[Math.floor(Math.random() * facts.length)];
</script> </script>
</body> </body>
</html> </html>

View File

@ -1,22 +1,23 @@
<?php <?php
$config = include __DIR__ . '/../shared/dashboard_config.php'; $config = include __DIR__ . '/../shared/config.php';
$roleConfig = $config['lab']; $roleConfig = $config['lab'];
?> ?>
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?> <?= $this->extend('shared/layout', ['roleConfig' => $roleConfig]); ?>
<?= $this->section('content'); ?> <?= $this->section('content'); ?>
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard"> <main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?> <?= $this->include('shared/content_requests', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?> <?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?> <?= $this->include('shared/dialog_unval'); ?>
<?= $this->include('shared/dialog_preview'); ?> <?= $this->include('shared/dialog_preview'); ?>
<?= $this->include('shared/dialog_audit'); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script'); ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_dashboard'); ?> <?= $this->include('shared/script_requests'); ?>
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -1,19 +1,19 @@
<?php <?php
$config = include __DIR__ . '/../shared/dashboard_config.php'; $config = include __DIR__ . '/../shared/config.php';
$roleConfig = $config['lab']; $roleConfig = $config['lab'];
?> ?>
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?> <?= $this->extend('shared/layout', ['roleConfig' => $roleConfig]); ?>
<?= $this->section('content'); ?> <?= $this->section('content'); ?>
<main class="p-4 flex-1 flex flex-col gap-2" x-data="validatePage"> <main class="p-4 flex-1 flex flex-col gap-2" x-data="validatePage">
<?= $this->include('shared/dashboard_validate', ['config' => $roleConfig]); ?> <?= $this->include('shared/content_validation', ['config' => $roleConfig]); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script'); ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_validate'); ?> <?= $this->include('shared/script_validation'); ?>
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -1,22 +1,23 @@
<?php <?php
$config = include __DIR__ . '/../shared/dashboard_config.php'; $config = include __DIR__ . '/../shared/config.php';
$roleConfig = $config['phlebo']; $roleConfig = $config['phlebo'];
?> ?>
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?> <?= $this->extend('shared/layout', ['roleConfig' => $roleConfig]); ?>
<?= $this->section('content'); ?> <?= $this->section('content'); ?>
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard"> <main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?> <?= $this->include('shared/content_requests', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?> <?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?> <?= $this->include('shared/dialog_unval'); ?>
<?= $this->include('shared/dialog_preview'); ?> <?= $this->include('shared/dialog_preview'); ?>
<?= $this->include('shared/dialog_audit'); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script'); ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_dashboard'); ?> <?= $this->include('shared/script_requests'); ?>
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -189,6 +189,7 @@
<th style='width:3%;'>ResTo</th> <th style='width:3%;'>ResTo</th>
<th style='width:5%;'>Val</th> <th style='width:5%;'>Val</th>
<th style='width:5%;'>Result</th> <th style='width:5%;'>Result</th>
<th style='width:3%;'></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -212,12 +213,15 @@
</div> </div>
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1"> <template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
<div class='text-center'> <div class='text-center'>
<?php if (session()->get('userlevel') <= 1): ?>
<template <template
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'"> x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
<button class="btn btn-xs btn-outline btn-secondary" <button class="btn btn-xs btn-outline btn-secondary"
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i @click="openUnvalDialog(req.SP_ACCESSNUMBER)">
class="fa-solid fa-rotate-right"></i></button> <span class="text-error font-bold">UnVal</span>
</button>
</template> </template>
<?php endif; ?>
</div> </div>
</template> </template>
</div> </div>
@ -228,6 +232,13 @@
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button> @click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
</template> </template>
</td> </td>
<td>
<button class="btn btn-xs btn-ghost"
@click="openAuditDialog(req.SP_ACCESSNUMBER)"
title="View Audit Trail">
<i class="fa fa-history text-info"></i>
</button>
</td>
</tr> </tr>
</template> </template>
</tbody> </tbody>

View File

@ -2,15 +2,18 @@
<div class="card-body p-0 h-full flex flex-col"> <div class="card-body p-0 h-full flex flex-col">
<!-- Header & Filters --> <!-- Header & Filters -->
<div class="p-4 border-b border-base-200 bg-base-50"> <div class="p-4 border-b border-base-200 bg-gradient-to-r from-primary/10 to-base-100">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4"> <div class="flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex-1"> <h2 class="text-2xl font-bold flex items-center gap-3">
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content"> <span class="w-10 h-10 rounded-lg bg-primary text-primary-content flex items-center justify-center shadow-lg">
<i class="fa fa-check-circle text-primary"></i> Pending Validation <i class="fa fa-clipboard-check"></i>
</h2> </span>
</div> Pending Validation
<div class="badge badge-lg badge-primary"> </h2>
<span x-text="unvalidatedCount"></span> requests <div class="badge badge-lg badge-primary gap-2 px-4 py-3 shadow-lg">
<i class="fa fa-layer-group animate-pulse"></i>
<span class="font-bold text-lg" x-text="unvalidatedCount"></span>
<span class="font-medium">requests</span>
</div> </div>
</div> </div>
@ -51,36 +54,30 @@
<table class="table table-xs table-zebra w-full"> <table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10"> <thead class="bg-base-100 sticky top-0 z-10">
<tr> <tr>
<th style="width: 12%;"> <th style="width: 18%;">
<div class="skeleton h-4 w-24"></div> <div class="skeleton h-4 w-24"></div>
</th> </th>
<th style="width: 18%;"> <th style="width: 25%;">
<div class="skeleton h-4 w-32"></div> <div class="skeleton h-4 w-32"></div>
</th> </th>
<th style="width: 10%;"> <th style="width: 12%;">
<div class="skeleton h-4 w-20"></div> <div class="skeleton h-4 w-20"></div>
</th> </th>
<th style="width: 12%;"> <th style="width: 12%;">
<div class="skeleton h-4 w-24"></div> <div class="skeleton h-4 w-24"></div>
</th> </th>
<th style="width: 10%;"> <th style="width: 13%;">
<div class="skeleton h-4 w-20"></div> <div class="skeleton h-4 w-20"></div>
</th> </th>
<th style="width: 10%;"> <th style="width: 20%;">
<div class="skeleton h-4 w-20"></div>
</th>
<th style="width: 18%;">
<div class="skeleton h-4 w-full"></div> <div class="skeleton h-4 w-full"></div>
</th> </th>
<th style="width: 10%;">
<div class="skeleton h-4 w-16"></div>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template x-for="i in 5" :key="i"> <template x-for="i in 5" :key="i">
<tr> <tr>
<td colspan="8"> <td colspan="6">
<div class="skeleton h-4 w-full"></div> <div class="skeleton h-4 w-full"></div>
</td> </td>
</tr> </tr>
@ -100,7 +97,7 @@
<table class="table table-xs table-zebra w-full"> <table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10"> <thead class="bg-base-100 sticky top-0 z-10">
<tr> <tr>
<th style="width: 12%;" @click="sort('REQDATE')" <th style="width: 18%;" @click="sort('REQDATE')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none"> class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
Order Datetime Order Datetime
@ -108,7 +105,7 @@
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i> :class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div> </div>
</th> </th>
<th style="width: 18%;" @click="sort('Name')" <th style="width: 25%;" @click="sort('Name')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none"> class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
Patient Name Patient Name
@ -116,7 +113,7 @@
:class="sortCol === 'Name' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i> :class="sortCol === 'Name' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div> </div>
</th> </th>
<th style="width: 10%;" @click="sort('SP_ACCESSNUMBER')" <th style="width: 12%;" @click="sort('SP_ACCESSNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none"> class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
No Lab No Lab
@ -132,7 +129,7 @@
:class="sortCol === 'HOSTORDERNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i> :class="sortCol === 'HOSTORDERNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div> </div>
</th> </th>
<th style="width: 10%;" @click="sort('REFF')" <th style="width: 13%;" @click="sort('REFF')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none"> class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
Reff Reff
@ -140,7 +137,7 @@
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i> :class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div> </div>
</th> </th>
<th style="width: 10%;" @click="sort('DOC')" <th style="width: 20%;" @click="sort('DOC')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none"> class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
Doctor Doctor
@ -148,8 +145,6 @@
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i> :class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div> </div>
</th> </th>
<th style="width: 18%;">Tests</th>
<th style="width: 10%;">ResTo</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -165,8 +160,6 @@
<td x-text="req.HOSTORDERNUMBER" class="font-bold"></td> <td x-text="req.HOSTORDERNUMBER" class="font-bold"></td>
<td x-text="req.REFF"></td> <td x-text="req.REFF"></td>
<td x-text="req.DOC"></td> <td x-text="req.DOC"></td>
<td x-text="req.TESTNAMES || req.TESTS"></td>
<td x-text="req.ODR_CRESULT_TO"></td>
</tr> </tr>
</template> </template>
</tbody> </tbody>

View File

@ -0,0 +1,276 @@
<div class="card bg-base-100 shadow-xl h-full border border-base-200 overflow-hidden">
<div class="card-body p-0 h-full flex flex-col">
<!-- Header & Statistics -->
<div class="p-4 border-b border-base-200 bg-gradient-to-r from-blue-50 to-indigo-50">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-4">
<div class="flex-1">
<h2 class="text-2xl font-bold flex items-center gap-2 text-primary">
<i class="fa fa-shield-alt"></i> Pending Validation
</h2>
</div>
<div class="flex flex-wrap gap-2 text-xs">
<div class="badge badge-lg badge-outline gap-1 bg-base-100">
<span class="inline-flex items-center justify-center w-2 h-2 rounded-full bg-base-300"></span>
<span class="text-base-content/70">Not Started</span>
<span class="badge badge-sm badge-ghost ml-1" x-text="valStats.notStarted"></span>
</div>
<div class="badge badge-lg badge-primary gap-1 bg-base-100 border-primary">
<span class="inline-flex items-center justify-center w-2 h-2 rounded-full bg-primary"></span>
<span class="text-primary font-medium">1st Val</span>
<span class="badge badge-sm badge-primary ml-1" x-text="valStats.firstVal"></span>
</div>
<div class="badge badge-lg badge-success gap-1 bg-base-100 border-success">
<span class="inline-flex items-center justify-center w-2 h-2 rounded-full bg-success"></span>
<span class="text-success font-medium">Done</span>
<span class="badge badge-sm badge-success ml-1" x-text="valStats.fullyValidated"></span>
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="bg-base-100 rounded-lg p-3 border border-base-200 shadow-sm">
<div class="flex justify-between items-center mb-2">
<span class="text-xs font-medium text-base-content/70">Overall Progress</span>
<span class="text-xs font-bold text-primary" x-text="valStats.progress + '%'"></span>
</div>
<div class="w-full bg-base-200 rounded-full h-2 overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 flex"
:style="'width: ' + valStats.progress + '%'">
<div class="h-full bg-primary flex-1 first-val-progress"></div>
<div class="h-full bg-success flex-1 second-val-progress"></div>
</div>
</div>
<div class="flex justify-between mt-1 text-xs text-base-content/60">
<span x-text="valStats.fullyValidated + ' fully validated'"></span>
<span x-text="valStats.firstVal + ' need 2nd validation'"></span>
</div>
</div>
<!-- Date Filter -->
<div class="flex flex-col md:flex-row gap-3 items-end mt-4 bg-base-100 p-3 rounded-lg border border-base-200 shadow-sm">
<div class="form-control">
<label class="label text-xs font-bold py-1 text-base-content/60">Date Range</label>
<div class="join">
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date1" />
<span class="join-item btn btn-sm btn-ghost no-animation bg-base-200 font-normal px-2">-</span>
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date2" />
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-primary" @click="fetchUnvalidated()">
<i class="fa fa-search"></i> Search
</button>
<button class="btn btn-sm btn-neutral" @click="resetUnvalidated()">
<i class="fa fa-sync-alt"></i> Reset
</button>
</div>
<span class="flex-1"></span>
<div class="form-control w-full md:w-auto">
<label class="input input-sm input-bordered">
<i class="fa fa-filter"></i>
<input type="text" placeholder="Type to filter..." x-model="filterTable" />
</label>
</div>
</div>
</div>
<!-- Table Section -->
<div class="flex-1 overflow-y-auto px-4 pb-4">
<!-- Legend -->
<div class="flex items-center gap-4 mb-2 text-xs text-base-content/60">
<span class="font-medium">Legend:</span>
<div class="flex items-center gap-1">
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full bg-base-300"></span>
<span>Pending</span>
</div>
<div class="flex items-center gap-1">
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full bg-primary"></span>
<span>1st Val</span>
</div>
<div class="flex items-center gap-1">
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full bg-success"></span>
<span>Done</span>
</div>
</div>
<template x-if="isLoading">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style="width: 15%;">
<div class="skeleton h-4 w-28"></div>
</th>
<th style="width: 12%;">
<div class="skeleton h-4 w-24"></div>
</th>
<th style="width: 10%;">
<div class="skeleton h-4 w-20"></div>
</th>
<th style="width: 10%;">
<div class="skeleton h-4 w-20"></div>
</th>
<th style="width: 10%;">
<div class="skeleton h-4 w-20"></div>
</th>
<th style="width: 10%;">
<div class="skeleton h-4 w-20"></div>
</th>
<th style="width: 13%;">
<div class="skeleton h-4 w-24"></div>
</th>
<th style="width: 8%;">
<div class="skeleton h-4 w-16"></div>
</th>
<th style="width: 12%;">
<div class="skeleton h-4 w-full"></div>
</th>
</tr>
</thead>
<tbody>
<template x-for="i in 5" :key="i">
<tr>
<td colspan="9">
<div class="skeleton h-4 w-full"></div>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="!isLoading && !unvalidatedList.length">
<div class="flex flex-col items-center justify-center py-16">
<div class="relative">
<div class="w-24 h-24 rounded-full bg-success/10 flex items-center justify-center animate-pulse">
<i class="fa fa-check-circle text-6xl text-success"></i>
</div>
<div class="absolute -bottom-2 -right-2 w-8 h-8 bg-success rounded-full flex items-center justify-center text-white text-sm font-bold">
<i class="fa fa-check"></i>
</div>
</div>
<h3 class="text-xl font-bold text-success mt-4">All Caught Up!</h3>
<p class="text-base-content/60 mt-1">No pending validations for this date range</p>
<div class="flex gap-4 mt-4 text-sm text-base-content/70">
<div class="text-center">
<div class="text-2xl font-bold text-success" x-text="valStats.fullyValidated"></div>
<div>Fully Validated</div>
</div>
</div>
</div>
</template>
<template x-if="!isLoading && unvalidatedList.length">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10 shadow-sm">
<tr>
<th style="width: 15%;" @click="sort('Name')"
class="cursor-pointer hover:bg-blue-100 transition-colors select-none">
<div class="flex items-center gap-1">
Patient
<i class="fa text-xs"
:class="sortCol === 'Name' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style="width: 12%;" @click="sort('SP_ACCESSNUMBER')"
class="cursor-pointer hover:bg-blue-100 transition-colors select-none">
<div class="flex items-center gap-1">
Lab No
<i class="fa text-xs"
:class="sortCol === 'SP_ACCESSNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style="width: 10%;" @click="sort('HOSTORDERNUMBER')"
class="cursor-pointer hover:bg-blue-100 transition-colors select-none">
<div class="flex items-center gap-1">
Reg No
<i class="fa text-xs"
:class="sortCol === 'HOSTORDERNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style="width: 10%;">Reff</th>
<th style="width: 10%;">Doctor</th>
<th style="width: 10%;">ResTo</th>
<th style="width: 13%;">Status</th>
<th style="width: 8%;">Action</th>
<th style="width: 12%;">Tests</th>
</tr>
</thead>
<tbody>
<template x-for="req in unvalidatedPaginated" :key="req.SP_ACCESSNUMBER">
<tr class="hover:bg-blue-50 cursor-pointer transition-colors"
@click="openValDialog(req.SP_ACCESSNUMBER)"
tabindex="0"
@keydown.enter="openValDialog(req.SP_ACCESSNUMBER)"
@keydown.escape="closeValDialog()">
<td>
<div class="font-medium" x-text="req.Name"></div>
<div class="text-xs opacity-60" x-text="req.PATNUMBER?.substring(14) || req.PATNUMBER"></div>
</td>
<td x-text="req.SP_ACCESSNUMBER" class="font-bold font-mono text-xs"></td>
<td x-text="req.HOSTORDERNUMBER" class="font-bold font-mono text-xs"></td>
<td x-text="req.REFF" class="text-xs"></td>
<td x-text="req.DOC" class="text-xs truncate max-w-[80px]" :title="req.DOC"></td>
<td x-text="req.ODR_CRESULT_TO" class="text-xs"></td>
<td>
<div class="flex items-center gap-1">
<div class="flex gap-0.5" :title="getValTooltip(req)">
<span class="w-3 h-3 rounded-full flex items-center justify-center text-[8px]"
:class="req.ISVAL1 == 1 ? 'bg-primary text-white' : 'bg-base-300 text-transparent'">
<i class="fa fa-check"></i>
</span>
<span class="w-3 h-3 rounded-full flex items-center justify-center text-[8px]"
:class="req.ISVAL2 == 1 ? 'bg-success text-white' : 'bg-base-300 text-transparent'">
<i class="fa fa-check"></i>
</span>
</div>
<div class="text-xs ml-1" :class="getValStatusClass(req)">
<span x-text="getValStatusText(req)"></span>
</div>
</div>
<div class="text-[10px] opacity-60 mt-0.5" x-show="req.VAL1USER || req.VAL2USER">
<span x-show="req.VAL1USER">1: <span x-text="req.VAL1USER"></span></span>
<span x-show="req.VAL2USER" class="ml-2">2: <span x-text="req.VAL2USER"></span></span>
</div>
</td>
<td>
<button class="btn btn-xs btn-primary btn-outline" @click.stop="openValDialog(req.SP_ACCESSNUMBER)">
<i class="fa fa-check"></i> <span x-text="req.ISVAL1 == 1 ? '2nd' : '1st'"></span>
</button>
</td>
<td class="max-w-[100px] truncate text-xs" :title="req.TESTNAMES || req.TESTS" x-text="req.TESTNAMES || req.TESTS"></td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Pagination Control -->
<div class="p-2 border-t border-base-200 bg-base-50 flex flex-col sm:flex-row justify-between items-center gap-2">
<div class="text-xs text-base-content/80">
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
<span class="font-bold" x-text="Math.min(currentPage * pageSize, unvalidatedFiltered.length)"></span> of
<span class="font-bold" x-text="unvalidatedFiltered.length"></span> entries
</div>
<div class="join">
<button class="join-item btn btn-sm" @click="prevPage()" :disabled="currentPage === 1">
<i class="fa fa-chevron-left"></i>
</button>
<button class="join-item btn btn-sm no-animation bg-base-100 cursor-default">
Page <span x-text="currentPage"></span> / <span x-text="unvalidatedTotalPages"></span>
</button>
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === unvalidatedTotalPages">
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Validate Dialog -->
<?= $this->include('shared/dialog_val'); ?>

View File

@ -0,0 +1,89 @@
<dialog class="modal" :open="isDialogAuditOpen">
<template x-if="auditAccessnumber">
<div class="modal-box w-11/12 max-w-4xl h-[80vh] flex flex-col p-0 overflow-hidden bg-base-100">
<div class="flex justify-between items-center p-3 bg-base-200 border-b border-base-300">
<h3 class="font-bold text-lg flex items-center gap-2">
<i class="fa fa-history text-primary"></i>
Audit Trail
<span class="badge badge-ghost text-xs" x-text="auditAccessnumber"></span>
</h3>
<button class="btn btn-sm btn-circle btn-ghost" @click="closeAuditDialog()">
<i class="fa fa-times"></i>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4 bg-base-100">
<div class="flex gap-2 mb-4">
<button @click="auditTab = 'all'"
:class="auditTab === 'all' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">All</button>
<button @click="auditTab = 'validation'"
:class="auditTab === 'validation' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">Validation</button>
<button @click="auditTab = 'sample'"
:class="auditTab === 'sample' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">Sample</button>
</div>
<div class="space-y-3">
<template x-if="!auditData">
<div class="text-center py-10">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-2">Loading audit data...</p>
</div>
</template>
<template x-if="auditData && getAllAuditEvents.length === 0">
<div class="text-center py-10 text-base-content/50">
<i class="fa fa-inbox text-4xl mb-2"></i>
<p>No audit events found</p>
</div>
</template>
<template x-if="auditData && getAllAuditEvents.length > 0">
<div class="relative border-l-2 border-base-300 ml-3 space-y-4">
<template x-for="event in getFilteredAuditEvents" :key="event.id">
<div class="ml-6 relative">
<div class="absolute -left-9 w-6 h-6 rounded-full flex items-center justify-center"
:class="{
'bg-success': event.category === 'validation' && event.type !== 'UNVAL',
'bg-info': event.category === 'sample',
'bg-error': event.category === 'validation' && event.type === 'UNVAL'
}">
<i class="fa text-xs text-white"
:class="{
'fa-check': event.category === 'validation' && event.type !== 'UNVAL',
'fa-vial': event.category === 'sample',
'fa-times': event.category === 'validation' && event.type === 'UNVAL'
}"></i>
</div>
<div class="bg-base-200 rounded-lg p-3 shadow-sm">
<div class="flex justify-between items-start">
<div>
<span class="badge badge-sm mb-1"
:class="{
'badge-success': event.category === 'validation' && event.type !== 'UNVAL',
'badge-info': event.category === 'sample',
'badge-error': event.category === 'validation' && event.type === 'UNVAL'
}"
x-text="event.type"></span>
<p class="font-medium text-sm" x-text="event.description"></p>
<template x-if="event.reason">
<p class="text-xs text-error mt-1" x-text="'Reason: ' + event.reason"></p>
</template>
</div>
<div class="text-right text-xs text-base-content/60">
<p x-text="event.datetime"></p>
<p x-text="event.user"></p>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
</template>
</dialog>

View File

@ -1,6 +1,15 @@
<dialog class="modal" :open="isDialogSampleOpen"> <dialog class="modal" :open="isDialogSampleOpen">
<div class="modal-box w-2/3 max-w-5xl"> <div class="modal-box w-2/3 max-w-5xl">
<p class='text-right'><button class="btn btn-xs btn-neutral" @click="closeSampleDialog()">X</button></p> <div class='flex justify-between items-center mb-2'>
<div class='flex gap-2 ml-auto'>
<template x-if="item.accessnumber">
<button class="btn btn-xs btn-outline btn-info" @click="openAuditDialog(item.accessnumber)">
<i class="fa fa-history"></i> Audit
</button>
</template>
<button class="btn btn-xs btn-neutral" @click="closeSampleDialog()">X</button>
</div>
</div>
<template x-if="isSampleLoading"> <template x-if="isSampleLoading">
<div class="text-center py-10"> <div class="text-center py-10">
@ -11,89 +20,98 @@
<template x-if="!isSampleLoading"> <template x-if="!isSampleLoading">
<div> <div>
<table class="table table-xs table-compact w-full mb-4"> <table class="table table-xs table-compact w-full mb-4">
<tr>
<td>MR# </td> <td x-text="': '+item.patnumber"></td>
<td>Patient Name </td> <td x-text="': '+item.patname"></td>
</tr>
<tr>
<td>KTP# </td> <td x-text="': '+item.ktp"></td>
<td>Sex / Age </td> <td x-text="': '+item.placeofbirth+' '+item.gender+' / '+item.age"></td>
</tr>
<tr>
<td>Note</td>
<td colspan='3'>
<textarea x-text="item.comment" class="textarea textarea-bordered w-full"
<?= ($config['sampleDialog']['commentEditable'] ?? true) ? '' : 'disabled' ?>></textarea>
<?php if ($config['sampleDialog']['commentEditable'] ?? true): ?>
<button class="btn btn-sm btn-primary mt-2" @click="saveComment(item.accessnumber)">Save</button>
<?php endif; ?>
</td>
</tr>
</table>
<table class="table table-xs table-compact w-full">
<thead>
<tr> <tr>
<th>Sample Code</th> <td>MR# </td>
<th>Sample Name</th> <td x-text="': '+item.patnumber"></td>
<th class='text-center'>Collected</th> <td>Patient Name </td>
<th class='text-center'>Received</th> <td x-text="': '+item.patname"></td>
<th>Action</th>
<th></th>
</tr> </tr>
</thead>
<tbody>
<tr> <tr>
<td></td> <td>All</td> <td></td> <td></td> <td>KTP# </td>
<td> <td x-text="': '+item.ktp"></td>
<button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></button> <td>Sex / Age </td>
<?php if ($config['sampleDialog']['showCollectButtons'] ?? true): ?> <td x-text="': '+item.placeofbirth+' '+item.gender+' / '+item.age"></td>
<button class="btn btn-sm btn-success px-2 py-1" onclick=""><h6 class="p-0 m-0">Coll.</h6></button> </tr>
<tr>
<td>Note</td>
<td colspan='3'>
<textarea x-text="item.comment" class="textarea textarea-bordered w-full"
<?= ($config['sampleDialog']['commentEditable'] ?? true) ? '' : 'disabled' ?>></textarea>
<?php if ($config['sampleDialog']['commentEditable'] ?? true): ?>
<button class="btn btn-sm btn-primary mt-2" @click="saveComment(item.accessnumber)">Save</button>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
<tr> </table>
<td></td> <td>Collection</td> <td></td> <td></td>
<td><button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></button></td>
</tr>
<template x-for="sample in item.samples"> <table class="table table-xs table-compact w-full">
<thead>
<tr> <tr>
<td x-text="sample.sampcode"></td> <th>Sample Code</th>
<td x-text="sample.name"></td> <th>Sample Name</th>
<td class='text-center'> <th class='text-center'>Collected</th>
<input type="checkbox" class="checkbox" x-bind:checked="sample.colstatus == 1" disabled> <th class='text-center'>Received</th>
</td> <th>Action</th>
<td class='text-center'> <th></th>
<input type="checkbox" class="checkbox" x-bind:checked="sample.tubestatus != 0" disabled> </tr>
</td> </thead>
<tbody>
<tr>
<td></td>
<td>All</td>
<td></td>
<td></td>
<td> <td>
<button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></button> <button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></button>
<?php if ($config['sampleDialog']['showCollectButtons'] ?? true): ?> <?php if ($config['sampleDialog']['showCollectButtons'] ?? true): ?>
<template x-if="sample.colstatus == 0"> <button class="btn btn-sm btn-success px-2 py-1" onclick="">
<button class="btn btn-sm btn-success px-2 py-1" @click="collect(sample.sampcode, item.accessnumber)">
<h6 class="p-0 m-0">Coll.</h6> <h6 class="p-0 m-0">Coll.</h6>
</button> </button>
</template>
<template x-if="sample.colstatus == 1">
<button class="btn btn-sm btn-error px-2 py-1" @click="uncollect(sample.sampcode, item.accessnumber)">
<h6 class="p-0 m-0">Un-Coll.</h6>
</button>
</template>
<template x-if="sample.tubestatus != 0">
<button class="btn btn-sm btn-error px-2 py-1" @click="unreceive(sample.sampcode, item.accessnumber)">
<h6 class="p-0 m-0">Un-Recv.</h6>
</button>
</template>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td></td>
</tr> </tr>
</template> <tr>
</tbody> <td></td>
</table> <td>Collection</td>
<td></td>
<td></td>
<td><button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></button></td>
</tr>
<template x-for="sample in item.samples">
<tr>
<td x-text="sample.sampcode"></td>
<td x-text="sample.name"></td>
<td class='text-center'>
<input type="checkbox" class="checkbox" x-bind:checked="sample.colstatus == 1" disabled>
</td>
<td class='text-center'>
<input type="checkbox" class="checkbox" x-bind:checked="sample.tubestatus != 0" disabled>
</td>
<td>
<button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></button>
<?php if ($config['sampleDialog']['showCollectButtons'] ?? true): ?>
<template x-if="sample.colstatus == 0">
<button class="btn btn-sm btn-success px-2 py-1"
@click="collect(sample.sampcode, item.accessnumber)">
<h6 class="p-0 m-0">Coll.</h6>
</button>
</template>
<template x-if="sample.tubestatus != 0">
<button class="btn btn-sm btn-error px-2 py-1"
@click="unreceive(sample.sampcode, item.accessnumber)">
<h6 class="p-0 m-0">Un-Recv.</h6>
</button>
</template>
<?php endif; ?>
</td>
<td></td>
</tr>
</template>
</tbody>
</table>
</div> </div>
</template> </template>
</div> </div>

View File

@ -9,11 +9,13 @@
x-model="unvalReason" placeholder="Enter reason for unvalidation..."></textarea> x-model="unvalReason" placeholder="Enter reason for unvalidation..."></textarea>
<p class='text-right mt-3'> <p class='text-right mt-3'>
<button class="btn btn-sm btn-ghost" @click="closeUnvalDialog()">Cancel</button> <button class="btn btn-sm btn-ghost" @click="closeUnvalDialog()">Cancel</button>
<?php if (session()->get('userlevel') <= 1): ?>
<button id="unvalidate-btn" class="btn btn-sm btn-warning" <button id="unvalidate-btn" class="btn btn-sm btn-warning"
@click="unvalidate(unvalAccessnumber, '<?=session('userid');?>')" @click="unvalidate(unvalAccessnumber, '<?=session('userid');?>')"
:disabled="!unvalReason.trim()"> :disabled="!unvalReason.trim()">
<i class="fa fa-undo mr-1"></i>Unvalidate <span class="text-error font-bold">UnVal</span>
</button> </button>
<?php endif; ?>
</p> </p>
</div> </div>
</template> </template>

View File

@ -193,17 +193,6 @@ document.addEventListener('alpine:init', () => {
}); });
}, },
uncollect(sampcode, accessnumber) {
if (!confirm(`Uncollect sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
unreceive(sampcode, accessnumber) { unreceive(sampcode, accessnumber) {
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; } if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, { fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
@ -283,5 +272,76 @@ document.addEventListener('alpine:init', () => {
closeUnvalDialog() { closeUnvalDialog() {
this.isDialogUnvalOpen = false; this.isDialogUnvalOpen = false;
}, },
/*
audit dialog
*/
isDialogAuditOpen: false,
auditData: null,
auditAccessnumber: null,
auditTab: 'all',
openAuditDialog(accessnumber) {
this.auditAccessnumber = accessnumber;
this.auditData = null;
this.auditTab = 'all';
this.isDialogAuditOpen = true;
this.fetchAudit(accessnumber);
},
closeAuditDialog() {
this.isDialogAuditOpen = false;
this.auditData = null;
this.auditAccessnumber = null;
},
fetchAudit(accessnumber) {
fetch(`${BASEURL}/api/requests/${accessnumber}/audit`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
}).then(res => res.json()).then(data => {
this.auditData = data.data;
});
},
get getAllAuditEvents() {
if (!this.auditData) return [];
let events = [];
let id = 0;
this.auditData.validation.forEach(v => {
let desc = `Validated by ${v.user}`;
if (v.type === 'UNVAL') {
desc = `Unvalidated by ${v.user}`;
}
events.push({
id: id++,
category: 'validation',
type: v.type,
description: desc,
datetime: v.datetime,
user: v.user,
reason: v.reason || null
});
});
this.auditData.sample_collection.forEach(s => {
events.push({
id: id++,
category: 'sample',
type: s.action === 'COLLECTED' ? 'COLLECT' : 'UNRECEIVE',
description: `Tube ${s.tubenumber}: ${s.action} by ${s.user}`,
datetime: s.datetime,
user: s.user,
reason: null
});
});
return events.sort((a, b) => {
if (!a.datetime) return 1;
if (!b.datetime) return -1;
return new Date(a.datetime) - new Date(b.datetime);
});
},
get getFilteredAuditEvents() {
if (this.auditTab === 'all') return this.getAllAuditEvents;
return this.getAllAuditEvents.filter(e => e.category === this.auditTab);
},
})); }));
}); });

View File

@ -1,18 +1,19 @@
<?= $this->extend('shared/layout_dashboard'); ?> <?= $this->extend('shared/layout'); ?>
<?= $this->section('content'); ?> <?= $this->section('content'); ?>
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard"> <main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?> <?= $this->include('shared/content_requests', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?> <?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?> <?= $this->include('shared/dialog_unval'); ?>
<?= $this->include('shared/dialog_preview'); ?> <?= $this->include('shared/dialog_preview'); ?>
<?= $this->include('shared/dialog_audit'); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script'); ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_dashboard'); ?> <?= $this->include('shared/script_requests'); ?>
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -1,4 +1,4 @@
<?= $this->extend('shared/layout_dashboard'); ?> <?= $this->extend('shared/layout'); ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>
<div x-data="users" class="contents"> <div x-data="users" class="contents">

View File

@ -1,19 +1,19 @@
<?php <?php
$config = include __DIR__ . '/../shared/dashboard_config.php'; $config = include __DIR__ . '/../shared/config.php';
$roleConfig = $config['superuser']; $roleConfig = $config['superuser'];
?> ?>
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?> <?= $this->extend('shared/layout', ['roleConfig' => $roleConfig]); ?>
<?= $this->section('content'); ?> <?= $this->section('content'); ?>
<main class="p-4 flex-1 flex flex-col gap-2" x-data="validatePage"> <main class="p-4 flex-1 flex flex-col gap-2" x-data="validatePage">
<?= $this->include('shared/dashboard_validate', ['config' => $roleConfig]); ?> <?= $this->include('shared/content_validation', ['config' => $roleConfig]); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script'); ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_validate'); ?> <?= $this->include('shared/script_validation'); ?>
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

29
new_table.sql Normal file
View File

@ -0,0 +1,29 @@
USE [GDC_CMOD]
GO
/****** Object: Table [dbo].[AUDIT_EVENTS] Script Date: 1/23/2026 4:38:31 PM ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[AUDIT_EVENTS](
[ACCESSNUMBER] [varchar](20) NOT NULL,
[EVENT_TYPE] [varchar](20) NOT NULL,
[USERID] [varchar](50) NOT NULL,
[EVENT_AT] [datetime] NOT NULL,
[REASON] [varchar](500) NULL,
CONSTRAINT [PK_AUDIT_EVENTS] PRIMARY KEY CLUSTERED
(
[ACCESSNUMBER] ASC,
[EVENT_TYPE] ASC,
[EVENT_AT] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[AUDIT_EVENTS] ADD DEFAULT (getdate()) FOR [EVENT_AT]
GO