tau ah
This commit is contained in:
parent
7ee5332edf
commit
71f08e2e94
3
.gitignore
vendored
3
.gitignore
vendored
@ -125,4 +125,5 @@ _modules/*
|
||||
/results/
|
||||
/phpunit*.xml
|
||||
|
||||
.venv/
|
||||
.claude/
|
||||
.serena/
|
||||
72
CHECKLIST.md
72
CHECKLIST.md
@ -1,50 +1,34 @@
|
||||
# Project Checklist: Glen RME & Lab Management System
|
||||
|
||||
**Last Updated:** January 21, 2026
|
||||
**Last Updated:** January 22, 2026
|
||||
|
||||
---
|
||||
Pending:
|
||||
|
||||
- [ ] **T-002:** Hide/Disable 'Validation' button after 2nd validation
|
||||
- Prevent redundant validation actions
|
||||
- [ ] **T-003:** Restrict Print/Save-to-PDF to CS Role only
|
||||
- Lab can only preview, CS can print/save
|
||||
- [X] **T-004:** Update User Role levels
|
||||
- Standardize roles: Superuser, Admin, Lab, Phlebo, CS
|
||||
- [X] **T-005:** Role-Based Dashboard Filtering
|
||||
- Filter by patient_status or service_type (Klinik+Lab vs Lab Only)
|
||||
- [ ] **T-006:** Create Clinical Patients Dashboard
|
||||
- Hide "No Lab" column for clinical workflows
|
||||
- [X] **T-007:** Fix Table Sorting
|
||||
- Enable sorting by "No Register" and "Patient Name"
|
||||
- [X] **T-008:** Fix Language Toggle (ID/EN)
|
||||
- Toggle lab result preview between Indonesian and English
|
||||
- [X] **T-009:** Apply Row Color-Coding
|
||||
- Color-code "No Register" column (Yellow/Blue/Green)
|
||||
- [X] **T-011:** Initialize RME Sidebar Menu
|
||||
- Create menu items: Dashboard, Patient, Hasil Lab, Validation, Unreceived, Report, Sample Collection, User Management, Unvalidate
|
||||
- [ ] **T-012:** Create 'Detail Unvalidated' History Log/View
|
||||
- Log unvalidation actions with timestamp, user ID, and reason
|
||||
- [ ] **T-013:** Enhanced Patient Detail Logging
|
||||
- Track: Sample Collection Time, Sample Received Time, Print History
|
||||
- [ ] **T-014:** Add Dedicated Print Button
|
||||
- Trigger browser/system print dialog
|
||||
- [ ] **T-015:** Add Error Handling for Preview Button
|
||||
- Handle empty data gracefully
|
||||
- [ ] **T-016:** Ensure 'Uncollect' Feature Functional
|
||||
- Maintain Uncollect feature functionality
|
||||
- [ ] **T-017:** Backend Performance & Connectivity
|
||||
- Investigate intermittent connection issues with Server 253
|
||||
- Plan SSD upgrade for database server
|
||||
- Verify API integration: GDC_cmod, GDC_CS2, Report2
|
||||
- [X] **T-018: Delayed** Dashboard Performance
|
||||
- When getting data more than 100 rows, it load too slow.
|
||||
- [ ] **T-010:** Update PDF Report Metadata
|
||||
- Replace 'Printed By' with validating user's name
|
||||
- Add 'Finish Validation' status per sample
|
||||
- Restrict 'UnValidate' to Admin
|
||||
- Restrict Print/Save-to-PDF to CS Role only (Lab can only preview, CS can print/save)
|
||||
- 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)
|
||||
- Add Dedicated Print Button (Trigger browser/system print dialog)
|
||||
- Add Error Handling for Preview Button (Handle empty data gracefully)
|
||||
- Ensure 'Uncollect' Feature Functional (Maintain Uncollect feature functionality)
|
||||
- Backend Performance & Connectivity (Investigate intermittent connection issues with Server 253)
|
||||
- Update PDF Report Metadata (Replace 'Printed By' with validating user's name)
|
||||
|
||||
---
|
||||
Completed:
|
||||
- 01 : Update User Role levels (Standardize roles: Superuser, Admin, Lab, Phlebo, CS)
|
||||
- 02 : Role-Based Dashboard Filtering (Filter by patient_status or service_type)
|
||||
- 03 : Fix Table Sorting (Enable sorting by "No Register" and "Patient Name")
|
||||
- 04 : Fix Language Toggle (Toggle lab result preview between Indonesian and English)
|
||||
- 05 : Apply Row Color-Coding (Color-code "No Register" column)
|
||||
- 06 : Initialize RME Sidebar Menu (Create menu items)
|
||||
- 07 : Dashboard Performance (When getting data more than 100 rows, it load too slow)
|
||||
- 08 : Dashboard for Lab -> no test with only number, remove request with empty test
|
||||
- 09 : Dashboard for Others -> complete
|
||||
- 10 : Refactor same views/*role* to views/shared
|
||||
- 11 : Move all CDN to local
|
||||
- 12 : Remove 'status' field on dashboard
|
||||
- 13 : Restrict 'Validate' to Lab, Admin, Superuser
|
||||
- 14 : Hide/Disable 'Validation' button after 2nd validation (Prevent redundant validation actions)
|
||||
|
||||
## Legend
|
||||
|
||||
- Check items as you complete them
|
||||
- Refer to PROJECT_BACKLOG.md for detailed technical specifications
|
||||
Addition on dev :
|
||||
- adding init-isDev on index.php to set default date on dev dashboard
|
||||
@ -25,19 +25,19 @@ class Filters extends BaseFilters
|
||||
* or [filter_name => [classname1, classname2, ...]]
|
||||
*/
|
||||
public array $aliases = [
|
||||
'csrf' => CSRF::class,
|
||||
'toolbar' => DebugToolbar::class,
|
||||
'honeypot' => Honeypot::class,
|
||||
'invalidchars' => InvalidChars::class,
|
||||
'csrf' => CSRF::class,
|
||||
'toolbar' => DebugToolbar::class,
|
||||
'honeypot' => Honeypot::class,
|
||||
'invalidchars' => InvalidChars::class,
|
||||
'secureheaders' => SecureHeaders::class,
|
||||
'cors' => Cors::class,
|
||||
'forcehttps' => ForceHTTPS::class,
|
||||
'pagecache' => PageCache::class,
|
||||
'performance' => PerformanceMetrics::class,
|
||||
'cors' => Cors::class,
|
||||
'forcehttps' => ForceHTTPS::class,
|
||||
'pagecache' => PageCache::class,
|
||||
'performance' => PerformanceMetrics::class,
|
||||
|
||||
// Dipakai Untuk Roles
|
||||
'role' => \App\Filters\RoleFilter::class, // Sudah Login
|
||||
'guest' => \App\Filters\GuestFilter::class, // Tidak Login
|
||||
'role' => \App\Filters\RoleFilter::class, // Sudah Login
|
||||
'guest' => \App\Filters\GuestFilter::class, // Tidak Login
|
||||
];
|
||||
|
||||
/**
|
||||
@ -61,7 +61,7 @@ class Filters extends BaseFilters
|
||||
'after' => [
|
||||
'pagecache', // Web Page Caching
|
||||
'performance', // Performance Metrics
|
||||
'toolbar', // Debug Toolbar
|
||||
#'toolbar', // Debug Toolbar
|
||||
],
|
||||
];
|
||||
|
||||
@ -77,7 +77,7 @@ class Filters extends BaseFilters
|
||||
public array $globals = [
|
||||
'before' => [
|
||||
// 'honeypot',
|
||||
// 'csrf',
|
||||
//'csrf',
|
||||
// 'invalidchars',
|
||||
],
|
||||
'after' => [
|
||||
|
||||
@ -39,6 +39,11 @@ $routes->group('api', function ($routes) {
|
||||
$routes->delete('validate/(:any)', 'RequestsController::unval/$1');
|
||||
});
|
||||
|
||||
// Validate API - Lab (2), Admin (1), Superuser (0)
|
||||
$routes->group('validate', ['filter' => 'role:0,1,2'], function ($routes) {
|
||||
$routes->get('unvalidated', 'ApiValidateController::unvalidated');
|
||||
});
|
||||
|
||||
// Samples
|
||||
$routes->group('samples', function ($routes) {
|
||||
// Collect & Show - All Roles
|
||||
@ -62,15 +67,18 @@ $routes->group('api', function ($routes) {
|
||||
$routes->group('superuser', ['filter' => 'role:0'], function ($routes) {
|
||||
$routes->get('', 'Pages\SuperuserController::index');
|
||||
$routes->get('users', 'Pages\SuperuserController::users');
|
||||
$routes->get('validate', 'Pages\SuperuserController::validatePage');
|
||||
});
|
||||
|
||||
$routes->group('admin', ['filter' => 'role:1'], function ($routes) {
|
||||
$routes->get('', 'Pages\AdminController::index');
|
||||
$routes->get('users', 'Pages\AdminController::users');
|
||||
$routes->get('validate', 'Pages\AdminController::validationPage');
|
||||
});
|
||||
|
||||
$routes->group('lab', ['filter' => 'role:2'], function ($routes) {
|
||||
$routes->get('', 'Pages\LabController::index');
|
||||
$routes->get('validate', 'Pages\LabController::validationPage');
|
||||
});
|
||||
|
||||
$routes->group('phlebo', ['filter' => 'role:3'], function ($routes) {
|
||||
|
||||
50
app/Controllers/ApiValidateController.php
Normal file
50
app/Controllers/ApiValidateController.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
namespace App\Controllers;
|
||||
use CodeIgniter\API\ResponseTrait;
|
||||
use App\Controllers\BaseController;
|
||||
|
||||
class ApiValidateController extends BaseController
|
||||
{
|
||||
use ResponseTrait;
|
||||
|
||||
/**
|
||||
* GET /api/requests/unvalidated?date1=...&date2=...
|
||||
* Fetch requests that have not been validated yet by the current user
|
||||
*/
|
||||
public function unvalidated()
|
||||
{
|
||||
$date1 = $this->request->getGet('date1');
|
||||
$date2 = $this->request->getGet('date2');
|
||||
$userid = session()->get('userid');
|
||||
|
||||
if (empty($date1) || empty($date2)) {
|
||||
return $this->response->setJSON(['status' => 'error', 'message' => 'Date range required']);
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$sql = "SELECT d.*, r.ISVAL1, r.ISVAL2, r.VAL1USER, r.VAL2USER
|
||||
FROM GDC_CMOD.dbo.V_DASHBOARD_DEV d
|
||||
LEFT JOIN GDC_CMOD.dbo.CM_REQUESTS r ON r.ACCESSNUMBER = d.SP_ACCESSNUMBER
|
||||
WHERE d.STATS = 'Fin'
|
||||
AND d.TESTS LIKE '%[A-Za-z]%'
|
||||
-- Exclude fully validated (both ISVAL1 and ISVAL2 are 1)
|
||||
AND (COALESCE(r.ISVAL1, 0) = 0 OR COALESCE(r.ISVAL2, 0) = 0)
|
||||
-- Exclude requests already validated by current user
|
||||
AND (r.VAL1USER != '$userid' OR r.VAL1USER IS NULL)
|
||||
AND (r.VAL2USER != '$userid' OR r.VAL2USER IS NULL)
|
||||
AND d.REQDATE BETWEEN '$date1 00:00:00' AND '$date2 23:59:59'
|
||||
ORDER BY d.REQDATE DESC";
|
||||
|
||||
$result = $db->query($sql)->getResultArray();
|
||||
|
||||
// Format dates
|
||||
foreach ($result as &$row) {
|
||||
$row['REQDATE'] = date('Y-m-d H:i', strtotime($row['REQDATE']));
|
||||
$row['ODR_DDATE'] = date('Y-m-d H:i', strtotime($row['ODR_DDATE']));
|
||||
$row['COLLECTIONDATE'] = date('Y-m-d H:i', strtotime($row['COLLECTIONDATE']));
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['status' => 'success', 'data' => $result]);
|
||||
}
|
||||
}
|
||||
@ -11,11 +11,18 @@ class AdminController extends BaseController {
|
||||
}
|
||||
|
||||
public function index() {
|
||||
return view('admin/index');
|
||||
$config = require APPPATH . 'Views/shared/dashboard_config.php';
|
||||
return view('admin/index', ['roleConfig' => $config['admin']]);
|
||||
}
|
||||
|
||||
public function users() {
|
||||
return view('admin/users');
|
||||
$config = require APPPATH . 'Views/shared/dashboard_config.php';
|
||||
return view('admin/users', ['roleConfig' => $config['admin']]);
|
||||
}
|
||||
|
||||
public function validationPage() {
|
||||
$config = require APPPATH . 'Views/shared/dashboard_config.php';
|
||||
return view('admin/validate', ['roleConfig' => $config['admin']]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -11,7 +11,8 @@ class CsController extends BaseController {
|
||||
}
|
||||
|
||||
public function index() {
|
||||
return view('cs/index');
|
||||
$config = require APPPATH . 'Views/shared/dashboard_config.php';
|
||||
return view('cs/index', ['roleConfig' => $config['cs']]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -11,7 +11,13 @@ class LabController extends BaseController {
|
||||
}
|
||||
|
||||
public function index() {
|
||||
return view('lab/index');
|
||||
$config = require APPPATH . 'Views/shared/dashboard_config.php';
|
||||
return view('lab/index', ['roleConfig' => $config['lab']]);
|
||||
}
|
||||
|
||||
public function validationPage() {
|
||||
$config = require APPPATH . 'Views/shared/dashboard_config.php';
|
||||
return view('lab/validate', ['roleConfig' => $config['lab']]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -11,7 +11,8 @@ class PhlebotomistController extends BaseController {
|
||||
}
|
||||
|
||||
public function index() {
|
||||
return view('phlebo/index');
|
||||
$config = require APPPATH . 'Views/shared/dashboard_config.php';
|
||||
return view('phlebo/index', ['roleConfig' => $config['phlebo']]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -4,18 +4,30 @@ namespace App\Controllers\Pages;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
|
||||
class SuperuserController extends BaseController {
|
||||
class SuperuserController extends BaseController
|
||||
{
|
||||
|
||||
public function __construct() {
|
||||
public function __construct()
|
||||
{
|
||||
helper(['url', 'form', 'text']);
|
||||
}
|
||||
|
||||
public function index() {
|
||||
return view('superuser/index');
|
||||
public function index()
|
||||
{
|
||||
$config = require APPPATH . 'Views/shared/dashboard_config.php';
|
||||
return view('superuser/index', ['roleConfig' => $config['superuser']]);
|
||||
}
|
||||
|
||||
public function users() {
|
||||
return view('superuser/users');
|
||||
public function users()
|
||||
{
|
||||
$config = require APPPATH . 'Views/shared/dashboard_config.php';
|
||||
return view('superuser/users', ['roleConfig' => $config['superuser']]);
|
||||
}
|
||||
|
||||
public function validatePage()
|
||||
{
|
||||
$config = require APPPATH . 'Views/shared/dashboard_config.php';
|
||||
return view('superuser/validate', ['roleConfig' => $config['superuser']]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -12,10 +12,20 @@ class RequestsController extends BaseController
|
||||
$db = \Config\Database::connect();
|
||||
$date1 = $this->request->getGet('date1');
|
||||
$date2 = $this->request->getGet('date2');
|
||||
$userroleid = session()->get('userroleid');
|
||||
|
||||
// Only allow Lab role (role 2)
|
||||
if ($userroleid == 2) {
|
||||
$sql = "SELECT * from GDC_CMOD.dbo.V_DASHBOARD_DEV where
|
||||
COLLECTIONDATE between '$date1 00:00' and '$date2 23:59'
|
||||
and ODR_DDATE between '$date1 00:00' and '$date2 23:59'
|
||||
and (TESTS IS NOT NULL AND TESTS like '%[A-Za-z]%')";
|
||||
} else {
|
||||
$sql = "SELECT * from GDC_CMOD.dbo.V_DASHBOARD_DEV where
|
||||
COLLECTIONDATE between '$date1 00:00' and '$date2 23:59'
|
||||
and ODR_DDATE between '$date1 00:00' and '$date2 23:59'";
|
||||
}
|
||||
|
||||
$sql = "SELECT * from GDC_CMOD.dbo.V_DASHBOARD_DEV where
|
||||
COLLECTIONDATE between '$date1 00:00' and '$date2 23:59'
|
||||
and ODR_DDATE between '$date1 00:00' and '$date2 23:59'";
|
||||
$rows = $db->query($sql)->getResultArray();
|
||||
foreach ($rows as &$row) {
|
||||
$row['COLLECTIONDATE'] = date('Y-m-d H:i', strtotime($row['COLLECTIONDATE']));
|
||||
|
||||
@ -29,10 +29,18 @@ class UsersController extends BaseController
|
||||
public function create()
|
||||
{
|
||||
$input = $this->request->getJSON(true);
|
||||
$userid = $input['userid'];
|
||||
$userroleid = $input['userroleid'];
|
||||
$password = $input['password'];
|
||||
$password_2 = $input['password_2'];
|
||||
if (!$input) {
|
||||
return $this->fail('Invalid JSON input');
|
||||
}
|
||||
|
||||
$userid = $input['userid'] ?? null;
|
||||
$userroleid = $input['userroleid'] ?? null;
|
||||
$password = $input['password'] ?? null;
|
||||
$password_2 = $input['password_2'] ?? null;
|
||||
|
||||
if (!$userid || !$userroleid || !$password || !$password_2) {
|
||||
return $this->fail('Missing required fields');
|
||||
}
|
||||
|
||||
if ($password != $password_2) {
|
||||
return $this->response->setJSON(['message' => 'Password not the same']);
|
||||
@ -70,11 +78,19 @@ class UsersController extends BaseController
|
||||
public function update($id = null)
|
||||
{
|
||||
$input = $this->request->getJSON(true);
|
||||
$userid = $input['userid'];
|
||||
$username = $input['username'];
|
||||
$userroleid = $input['userroleid'];
|
||||
$password = $input['password'];
|
||||
$password_2 = $input['password_2'];
|
||||
if (!$input) {
|
||||
return $this->fail('Invalid JSON input');
|
||||
}
|
||||
|
||||
$userid = $input['userid'] ?? null;
|
||||
$username = $input['username'] ?? null;
|
||||
$userroleid = $input['userroleid'] ?? null;
|
||||
$password = $input['password'] ?? '';
|
||||
$password_2 = $input['password_2'] ?? '';
|
||||
|
||||
if (!$userid) {
|
||||
return $this->fail('User ID is required');
|
||||
}
|
||||
|
||||
if ($password != '' || $password_2 != '') {
|
||||
if ($password != $password_2) {
|
||||
|
||||
@ -1,99 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogSampleOpen">
|
||||
<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>
|
||||
|
||||
<template x-if="isSampleLoading">
|
||||
<div class="text-center py-10">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="mt-2">Loading data...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!isSampleLoading">
|
||||
<div>
|
||||
<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"></textarea>
|
||||
<button class="btn btn-sm btn-primary mt-2" @click="saveComment(item.accessnumber)">Save</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="table table-xs table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sample Code</th>
|
||||
<th>Sample Name</th>
|
||||
<th class='text-center'>Collected</th>
|
||||
<th class='text-center'>Received</th>
|
||||
<th>Action</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<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>
|
||||
<tr>
|
||||
<td></td> <td>All</td> <td></td> <td></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></i></button>
|
||||
<button class="btn btn-sm btn-success px-2 py-1" onclick=""><h6 class="p-0 m-0">Coll.</h6></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>
|
||||
<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.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>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</dialog>
|
||||
@ -1,20 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogUnvalOpen">
|
||||
<template x-if="unvalAccessnumber">
|
||||
<div class="modal-box w-96">
|
||||
<h3 class="font-bold text-lg mb-4 text-warning">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i>Unvalidate Request
|
||||
</h3>
|
||||
<p class="text-sm mb-3" x-text="'Access Number: ' + unvalAccessnumber"></p>
|
||||
<textarea class="textarea textarea-bordered w-full" rows="4"
|
||||
x-model="unvalReason" placeholder="Enter reason for unvalidation..."></textarea>
|
||||
<p class='text-right mt-3'>
|
||||
<button class="btn btn-sm btn-ghost" @click="closeUnvalDialog()">Cancel</button>
|
||||
<button id="unvalidate-btn" class="btn btn-sm btn-warning"
|
||||
@click="unvalidate(unvalAccessnumber, '<?=session('userid');?>')"
|
||||
:disabled="!unvalReason.trim()">
|
||||
<i class="fa fa-undo mr-1"></i>Unvalidate
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</dialog>
|
||||
@ -1,571 +1,22 @@
|
||||
<?= $this->extend('admin/main'); ?>
|
||||
<?php
|
||||
$config = include __DIR__ . '/../shared/dashboard_config.php';
|
||||
$roleConfig = $config['admin'];
|
||||
?>
|
||||
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<?= $this->section('content'); ?>
|
||||
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
|
||||
<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 & Filters -->
|
||||
<div class="p-4 border-b border-base-200 bg-base-50">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
|
||||
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Status Filters -->
|
||||
<div class="join shadow-sm bg-base-100 rounded-lg">
|
||||
<button @click="filterKey = 'Total'"
|
||||
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Pend'"
|
||||
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Coll'"
|
||||
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Recv'"
|
||||
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Inc'"
|
||||
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Fin'"
|
||||
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Validated'"
|
||||
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Date Filter -->
|
||||
<div
|
||||
class="flex flex-col md:flex-row gap-3 items-end 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='fetchList()'><i class='fa fa-search'></i> Search</button>
|
||||
<button class="btn btn-sm btn-neutral" @click='reset()'><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>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<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:7%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:3%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:4%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="i in 5" :key="i">
|
||||
<tr>
|
||||
<td colspan="11">
|
||||
<div class="skeleton h-4 w-full"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<template x-if="!isLoading && !list.length">
|
||||
<div class="text-center py-10">
|
||||
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
|
||||
<p>No records found</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!isLoading && list.length">
|
||||
<table class="table table-xs table-zebra w-full">
|
||||
<thead class="bg-base-100 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th style='width:7%;' @click="sort('REQDATE')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Order Datetime
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;' @click="sort('Name')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Patient Name
|
||||
<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:7%;' @click="sort('SP_ACCESSNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Lab
|
||||
<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:7%;' @click="sort('HOSTORDERNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Register
|
||||
<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:8%;' @click="sort('REFF')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Reff
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:8%;' @click="sort('DOC')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Doctor
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;'>Tests</th>
|
||||
<th style='width:3%;'>ResTo</th>
|
||||
<th style='width:5%;'>Val</th>
|
||||
<th style='width:5%;'>Result</th>
|
||||
<th style='width:4%;' @click="sort('STATS')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Status
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'STATS' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody>
|
||||
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
|
||||
<tr class="hover:bg-base-300">
|
||||
<td x-text="req.REQDATE"></td>
|
||||
<td x-text="req.Name"></td>
|
||||
<td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.REFF"></td>
|
||||
<td x-text="req.DOC"></td>
|
||||
<td x-text="req.TESTS"></td>
|
||||
<td x-text="req.ODR_CRESULT_TO"></td>
|
||||
<td>
|
||||
<div class='flex gap-1 items-center'>
|
||||
<div class='w-15'>
|
||||
<p>1: <span x-text="req.VAL1USER"></span></p>
|
||||
<p>2: <span x-text="req.VAL2USER"></span></p>
|
||||
</div>
|
||||
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
|
||||
<div class='text-center'>
|
||||
<template
|
||||
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
|
||||
<button class="btn btn-xs btn-outline btn-secondary"
|
||||
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
|
||||
class="fa-solid fa-rotate-right"></i></button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="req.STATS !== 'PartColl' && req.STATS !== 'Coll' && req.STATS !== 'Pend'">
|
||||
<button class="btn btn-xs btn-outline btn-primary"
|
||||
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
|
||||
</template>
|
||||
</td>
|
||||
<td x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="font-bold cursor-pointer"
|
||||
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Control -->
|
||||
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
|
||||
x-show="!isLoading && list.length > 0">
|
||||
<div class="text-xs text-base-content/60">
|
||||
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
|
||||
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
|
||||
<span class="font-bold" x-text="filtered.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="totalPages"></span>
|
||||
</button>
|
||||
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<?php echo $this->include('admin/dialog_sample'); ?>
|
||||
<?php echo $this->include('admin/dialog_unval'); ?>
|
||||
<?php echo $this->include('admin/dialog_preview'); ?>
|
||||
|
||||
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
|
||||
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
|
||||
<?= $this->include('shared/dialog_unval'); ?>
|
||||
<?= $this->include('shared/dialog_preview'); ?>
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section('script') ?>
|
||||
<?= $this->section('script'); ?>
|
||||
<script type="module">
|
||||
import Alpine from '<?= base_url("js/app.js"); ?>';
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("dashboard", () => ({
|
||||
// dashboard
|
||||
today: "",
|
||||
filter: { date1: "", date2: "" },
|
||||
list: [],
|
||||
isLoading: false,
|
||||
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
|
||||
statusColor: {
|
||||
Pend: 'bg-white text-black font-bold',
|
||||
PartColl: 'bg-orange-300 text-white font-bold',
|
||||
Coll: 'bg-orange-500 text-white font-bold',
|
||||
PartRecv: 'bg-blue-200 text-black font-bold',
|
||||
Recv: 'bg-blue-500 text-white font-bold',
|
||||
Inc: 'bg-yellow-500 text-white font-bold',
|
||||
Fin: 'bg-green-500 text-white font-bold',
|
||||
},
|
||||
filterTable: "",
|
||||
filterKey: 'Total',
|
||||
filterKey: 'Total',
|
||||
statusMap: {
|
||||
Total: [],
|
||||
Pend: ['Pend'],
|
||||
Coll: ['Coll', 'PartColl'],
|
||||
Recv: ['Recv', 'PartRecv'],
|
||||
Inc: ['Inc'],
|
||||
Fin: ['Fin'],
|
||||
},
|
||||
|
||||
// Sorting & Pagination
|
||||
sortCol: 'REQDATE',
|
||||
sortAsc: false,
|
||||
currentPage: 1,
|
||||
pageSize: 30,
|
||||
|
||||
sort(col) {
|
||||
if (this.sortCol === col) {
|
||||
this.sortAsc = !this.sortAsc;
|
||||
} else {
|
||||
this.sortCol = col;
|
||||
this.sortAsc = true;
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) this.currentPage++;
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) this.currentPage--;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.filtered.length / this.pageSize) || 1;
|
||||
},
|
||||
|
||||
get sorted() {
|
||||
return this.filtered.slice().sort((a, b) => {
|
||||
let modifier = this.sortAsc ? 1 : -1;
|
||||
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
|
||||
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
get paginated() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.sorted.slice(start, end);
|
||||
},
|
||||
|
||||
init() {
|
||||
this.today = new Date().toISOString().slice(0, 10);
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.$watch('filterTable', () => {
|
||||
this.currentPage = 1;
|
||||
});
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
fetchList() {
|
||||
this.isLoading = true;
|
||||
this.list = [];
|
||||
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
|
||||
let param = new URLSearchParams(this.filter).toString();
|
||||
for (let k in this.counters) { this.counters[k] = 0; }
|
||||
fetch(`${BASEURL}/api/requests?${param}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(res => res.json()).then(data => {
|
||||
this.list = data.data ?? [];
|
||||
this.filterKey = 'Total';
|
||||
this.list.forEach(item => {
|
||||
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
|
||||
else {
|
||||
if (item.STATS == 'PartColl') { this.counters.Coll++; }
|
||||
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
|
||||
this.counters.Total++;
|
||||
}
|
||||
});
|
||||
this.list.sort((a, b) => {
|
||||
let codeA = statusOrder[a.STATS] ?? 0;
|
||||
let codeB = statusOrder[b.STATS] ?? 0;
|
||||
return codeA - codeB;
|
||||
});
|
||||
}).finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
isValidated(item) {
|
||||
return item.ISVAL == 1 && item.ISPENDING != 1;
|
||||
},
|
||||
get filtered() {
|
||||
// Reset pagination when filter changes (implied by this getter being accessed if dependencies change)
|
||||
// However, side-effects in getters are tricky.
|
||||
// Better to just let the user navigate back, or watch variables.
|
||||
// For now, let's keep it pure.
|
||||
let filteredList = this.list;
|
||||
if (this.filterKey === 'Validated') {
|
||||
filteredList = filteredList.filter(item => this.isValidated(item));
|
||||
} else {
|
||||
const validStatuses = this.statusMap[this.filterKey];
|
||||
if (validStatuses.length > 0) {
|
||||
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
|
||||
}
|
||||
}
|
||||
if (this.filterTable) {
|
||||
const searchTerm = this.filterTable.toLowerCase();
|
||||
filteredList = filteredList.filter(item =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm)
|
||||
)
|
||||
);
|
||||
}
|
||||
return filteredList;
|
||||
},
|
||||
get validatedCount() {
|
||||
return this.list.filter(r => this.isValidated(r)).length;
|
||||
},
|
||||
|
||||
/*
|
||||
sample dialog
|
||||
*/
|
||||
item: '',
|
||||
isDialogSampleOpen: false,
|
||||
isSampleLoading: false,
|
||||
|
||||
openSampleDialog(accessnumber) {
|
||||
this.isDialogSampleOpen = true;
|
||||
this.fetchItem(accessnumber)
|
||||
},
|
||||
|
||||
closeSampleDialog() {
|
||||
this.isDialogSampleOpen = false;
|
||||
},
|
||||
|
||||
fetchItem(accessnumber) {
|
||||
this.isSampleLoading = true;
|
||||
this.item = [];
|
||||
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
|
||||
.then(res => res.json()).then(data => {
|
||||
this.item = data.data ?? {};
|
||||
if (!Array.isArray(this.item.samples)) this.item.samples = [];
|
||||
}).finally(() => {
|
||||
this.isSampleLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
collect(sampcode, accessnumber) {
|
||||
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
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) {
|
||||
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
preview dialog
|
||||
*/
|
||||
isDialogPreviewOpen: false,
|
||||
reviewed: false,
|
||||
previewItem: null,
|
||||
openPreviewDialog(accessnumber, type, item) {
|
||||
this.previewAccessnumber = accessnumber;
|
||||
this.previewItem = item;
|
||||
this.previewType = type;
|
||||
this.isDialogPreviewOpen = true;
|
||||
this.reviewed = false;
|
||||
},
|
||||
closePreviewDialog() {
|
||||
this.isDialogPreviewOpen = false;
|
||||
this.previewItem = null;
|
||||
},
|
||||
setPreviewType(type) {
|
||||
this.previewType = type;
|
||||
},
|
||||
getPreviewUrl() {
|
||||
let base = 'http://glenlis/spooler_db/main_dev.php';
|
||||
let url = `${base}?acc=${this.previewAccessnumber}`;
|
||||
if (this.previewType === 'ind') url += '&lang=ID';
|
||||
if (this.previewType === 'eng') url += '&lang=EN';
|
||||
if (this.previewType === 'pdf') url += '&output=pdf';
|
||||
|
||||
// Keep fallback for local dev if needed, but the above is the expected logic
|
||||
// return "http://localhost/application.html";
|
||||
return url;
|
||||
},
|
||||
validate(accessnumber, userid) {
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}` })
|
||||
}).then(response => {
|
||||
this.closePreviewDialog();
|
||||
this.fetchList();
|
||||
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
unvalidate dialog
|
||||
*/
|
||||
isDialogUnvalOpen: false,
|
||||
unvalReason: '',
|
||||
unvalAccessnumber: null,
|
||||
openUnvalDialog(accessnumber) {
|
||||
this.unvalReason = '';
|
||||
this.isDialogUnvalOpen = true;
|
||||
this.unvalAccessnumber = accessnumber;
|
||||
},
|
||||
unvalidate(accessnumber, userid) {
|
||||
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
|
||||
}).then(response => {
|
||||
this.closeUnvalDialog();
|
||||
this.fetchList();
|
||||
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
|
||||
});
|
||||
},
|
||||
closeUnvalDialog() {
|
||||
this.isDialogUnvalOpen = false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
<?= $this->include('shared/script_dashboard'); ?>
|
||||
Alpine.start();
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
@ -1,74 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="corporate">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CMOD</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
font-size: 0.71rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 0.2rem 1rem;
|
||||
min-height: 0rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
font-size: 0.71rem !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-base-200 min-h-screen" x-data="main">
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar bg-base-100 shadow-md px-6 z-20">
|
||||
<div class='flex-1'>
|
||||
<a class='text-xl text-primary font-bold tracking-wide flex items-center gap-2 ml-2'>
|
||||
<i class="fa fa-cube"></i> CMOD <span class="text-base-content/40 font-light text-sm hidden sm:inline-block">|
|
||||
Admin Dashboard</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right hidden sm:block leading-tight">
|
||||
<div class="text-sm font-bold opacity-70">Hi, <?= session('userid'); ?></div>
|
||||
<div class="text-xs opacity-50"><?= session()->get('userrole') ?></div>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost avatar placeholder px-2">
|
||||
<span class="text-xl"><i class="fa fa-bars"></i></span>
|
||||
</div>
|
||||
<ul tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300">
|
||||
<li><a href="<?= base_url('logout') ?>" class="text-error hover:bg-error/10"><i
|
||||
class="fa fa-sign-out-alt mr-2"></i> Logout</a></li>
|
||||
<li><a @click.prevent="openDialogSetPassword()"><i class="fa fa-key mr-2"></i> Change Password</a></li>
|
||||
<div class="divider my-1"></div>
|
||||
<li><a href="<?= base_url('admin') ?>"><i class="fa fa-chart-bar mr-2"></i> Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page Content -->
|
||||
<?= $this->renderSection('content'); ?>
|
||||
<?= $this->include('admin/dialog_setPassword'); ?>
|
||||
|
||||
<footer class='bg-base-100 p-1 mt-auto'>© <?= date('Y'); ?> - 5Panda</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.BASEURL = "<?= base_url(); ?>";
|
||||
</script>
|
||||
<?= $this->renderSection('script'); ?>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
19
app/Views/admin/validate.php
Normal file
19
app/Views/admin/validate.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
$config = include __DIR__ . '/../shared/dashboard_config.php';
|
||||
$roleConfig = $config['admin'];
|
||||
?>
|
||||
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?>
|
||||
|
||||
<?= $this->section('content'); ?>
|
||||
<main class="p-4 flex-1 flex flex-col gap-2" x-data="validatePage">
|
||||
<?= $this->include('shared/dashboard_validate', ['config' => $roleConfig]); ?>
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section('script'); ?>
|
||||
<script type="module">
|
||||
import Alpine from '<?= base_url("js/app.js"); ?>';
|
||||
<?= $this->include('shared/script_validate'); ?>
|
||||
Alpine.start();
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
@ -1,54 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogPreviewOpen">
|
||||
<template x-if="previewAccessnumber">
|
||||
<div class="modal-box w-11/12 max-w-7xl h-[90vh] flex flex-col p-0 overflow-hidden bg-base-100">
|
||||
<!-- Header -->
|
||||
<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-eye text-primary"></i>
|
||||
Preview
|
||||
<span class="badge badge-ghost text-xs" x-text="previewAccessnumber"></span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="join shadow-sm" x-show="previewItem && previewItem.VAL1USER && previewItem.VAL2USER">
|
||||
<button @click="setPreviewType('preview')"
|
||||
:class="previewType === 'preview' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">Default</button>
|
||||
<button @click="setPreviewType('ind')"
|
||||
:class="previewType === 'ind' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">ID</button>
|
||||
<button @click="setPreviewType('eng')"
|
||||
:class="previewType === 'eng' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">EN</button>
|
||||
<button @click="setPreviewType('pdf')"
|
||||
:class="previewType === 'pdf' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">PDF</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" @click="closePreviewDialog()">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 bg-base-300 relative p-1">
|
||||
<iframe id="preview-iframe" x-ref="previewIframe" :src="getPreviewUrl()"
|
||||
class="w-full h-full rounded shadow-sm bg-white"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-3 bg-base-200 border-t border-base-300 flex justify-end items-center gap-4">
|
||||
<label class="label cursor-pointer gap-2 mb-0">
|
||||
<input type="checkbox" x-model="reviewed" class="checkbox checkbox-sm checkbox-primary" />
|
||||
<span class="label-text text-sm">I have reviewed the results</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-ghost" @click="closePreviewDialog()">Cancel</button>
|
||||
<button id="validate-btn" x-ref="validateBtn" class="btn btn-sm btn-success"
|
||||
@click="validate(previewAccessnumber, '<?= session('userid'); ?>')" :disabled="!reviewed">
|
||||
<i class="fa fa-check mr-1"></i> Validate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</dialog>
|
||||
@ -1,87 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogSampleOpen">
|
||||
<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>
|
||||
|
||||
<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" disabled></textarea>
|
||||
<!-- <button class="btn btn-sm btn-primary mt-2" @click="saveComment(item.accessnumber)">Save</button> -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="table table-xs table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sample Code</th>
|
||||
<th>Sample Name</th>
|
||||
<th class='text-center'>Collected</th>
|
||||
<th class='text-center'>Received</th>
|
||||
<th>Action</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td></td> <td>All</td> <td></td> <td></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></i></button>
|
||||
<!-- <button class="btn btn-sm btn-success px-2 py-1" onclick=""><h6 class="p-0 m-0">Coll.</h6></button> -->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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">
|
||||
<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>
|
||||
<!-- <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.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> -->
|
||||
</td>
|
||||
<td>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</dialog>
|
||||
@ -1,28 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogSetPasswordOpen">
|
||||
<div class="modal-box w-96">
|
||||
<h3 class="font-bold text-lg mb-4">Change Password</h3>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">New Password</span>
|
||||
</label>
|
||||
<input type="password" x-model="password" class="input input-bordered w-full" placeholder="Enter new password" />
|
||||
</div>
|
||||
<div class="form-control w-full mt-3">
|
||||
<label class="label">
|
||||
<span class="label-text">Confirm Password</span>
|
||||
</label>
|
||||
<input type="password" x-model="confirm_password" class="input input-bordered w-full" placeholder="Confirm new password" />
|
||||
</div>
|
||||
<div x-show="error" class="alert alert-error mt-3 text-sm">
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" @click="closeDialogSetPassword()">Cancel</button>
|
||||
<button class="btn btn-primary" @click="savePassword('<?=session('userid'); ?>')" :disabled="isLoading">
|
||||
<span x-show="isLoading" class="loading loading-spinner loading-sm"></span>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop bg-black/30" @click="closeDialogSetPassword()"></div>
|
||||
</dialog>
|
||||
@ -1,10 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogUnvalOpen">
|
||||
<div class="modal-box">
|
||||
<textarea class="textarea textarea-bordered w-full" rows="5" x-model="unvalReason" placeholder="Enter reason for unvalidation..."></textarea>
|
||||
<p class='text-right mt-2'>
|
||||
<button class="btn btn-sm btn-neutral" @click="closeUnvalDialog()">Cancel</button>
|
||||
<button id="unvalidate-btn" x-ref="unvalidateBtn" class="btn btn-sm btn-warning"
|
||||
@click="unvalidate(unvalAccessnumber, '<?=session('userid');?>')" :disabled="!unvalReason.trim()">Unvalidate</button>
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
@ -1,13 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogValOpen">
|
||||
<template x-if="valAccessnumber">
|
||||
<div class="modal-box w-2/3 max-w-5xl">
|
||||
<p class='text-right mx-3 mb-2'>
|
||||
<button class="btn btn-sm btn-neutral" @click="closeValDialog()">Cancel</button>
|
||||
<button id="validate-btn" x-ref="validateBtn" class="btn btn-sm btn-success"
|
||||
@click="validate(valAccessnumber, '<?=session('userid');?>')" :disabled="!isValidateEnabled">Validate</button>
|
||||
</p>
|
||||
<!-- <iframe id="result-iframe" src="http://glenlis/spooler_db/main_dev.php?acc=" width="750px" height="600px"></iframe> -->
|
||||
<iframe id="result-iframe" x-ref="resultIframe" src="<?=base_url('dummypage');?>" width="750px" height="600px"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
</dialog>
|
||||
@ -1,571 +1,22 @@
|
||||
<?= $this->extend('cs/main'); ?>
|
||||
<?php
|
||||
$config = include __DIR__ . '/../shared/dashboard_config.php';
|
||||
$roleConfig = $config['cs'];
|
||||
?>
|
||||
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<?= $this->section('content'); ?>
|
||||
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
|
||||
<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 & Filters -->
|
||||
<div class="p-4 border-b border-base-200 bg-base-50">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
|
||||
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Status Filters -->
|
||||
<div class="join shadow-sm bg-base-100 rounded-lg">
|
||||
<button @click="filterKey = 'Total'"
|
||||
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Pend'"
|
||||
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Coll'"
|
||||
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Recv'"
|
||||
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Inc'"
|
||||
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Fin'"
|
||||
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Validated'"
|
||||
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Date Filter -->
|
||||
<div
|
||||
class="flex flex-col md:flex-row gap-3 items-end 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='fetchList()'><i class='fa fa-search'></i> Search</button>
|
||||
<button class="btn btn-sm btn-neutral" @click='reset()'><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>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<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:7%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:3%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:4%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="i in 5" :key="i">
|
||||
<tr>
|
||||
<td colspan="11">
|
||||
<div class="skeleton h-4 w-full"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<template x-if="!isLoading && !list.length">
|
||||
<div class="text-center py-10">
|
||||
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
|
||||
<p>No records found</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!isLoading && list.length">
|
||||
<table class="table table-xs table-zebra w-full">
|
||||
<thead class="bg-base-100 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th style='width:7%;' @click="sort('REQDATE')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Order Datetime
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;' @click="sort('Name')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Patient Name
|
||||
<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:7%;' @click="sort('SP_ACCESSNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Lab
|
||||
<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:7%;' @click="sort('HOSTORDERNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Register
|
||||
<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:8%;' @click="sort('REFF')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Reff
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:8%;' @click="sort('DOC')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Doctor
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;'>Tests</th>
|
||||
<th style='width:3%;'>ResTo</th>
|
||||
<th style='width:5%;'>Val</th>
|
||||
<th style='width:5%;'>Result</th>
|
||||
<th style='width:4%;' @click="sort('STATS')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Status
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'STATS' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody>
|
||||
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
|
||||
<tr class="hover:bg-base-300">
|
||||
<td x-text="req.REQDATE"></td>
|
||||
<td x-text="req.Name"></td>
|
||||
<!-- <td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td> -->
|
||||
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.REFF"></td>
|
||||
<td x-text="req.DOC"></td>
|
||||
<td x-text="req.TESTS"></td>
|
||||
<td x-text="req.ODR_CRESULT_TO"></td>
|
||||
<td>
|
||||
<div class='flex gap-1 items-center'>
|
||||
<div class='w-15'>
|
||||
<p>1: <span x-text="req.VAL1USER"></span></p>
|
||||
<p>2: <span x-text="req.VAL2USER"></span></p>
|
||||
</div>
|
||||
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
|
||||
<div class='text-center'>
|
||||
<template
|
||||
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
|
||||
<button class="btn btn-xs btn-outline btn-secondary"
|
||||
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
|
||||
class="fa-solid fa-rotate-right"></i></button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="req.VAL1USER && req.VAL2USER">
|
||||
<button class="btn btn-xs btn-outline btn-primary"
|
||||
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
|
||||
</template>
|
||||
</td>
|
||||
<td x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="font-bold cursor-pointer"
|
||||
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Control -->
|
||||
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
|
||||
x-show="!isLoading && list.length > 0">
|
||||
<div class="text-xs text-base-content/60">
|
||||
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
|
||||
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
|
||||
<span class="font-bold" x-text="filtered.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="totalPages"></span>
|
||||
</button>
|
||||
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<?php echo $this->include('cs/dialog_sample'); ?>
|
||||
<?php echo $this->include('cs/dialog_unval'); ?>
|
||||
<?php echo $this->include('cs/dialog_preview'); ?>
|
||||
|
||||
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
|
||||
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
|
||||
<?= $this->include('shared/dialog_unval'); ?>
|
||||
<?= $this->include('shared/dialog_preview'); ?>
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section('script') ?>
|
||||
<?= $this->section('script'); ?>
|
||||
<script type="module">
|
||||
import Alpine from '<?= base_url("js/app.js"); ?>';
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("dashboard", () => ({
|
||||
// dashboard
|
||||
today: "",
|
||||
filter: { date1: "", date2: "" },
|
||||
list: [],
|
||||
isLoading: false,
|
||||
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
|
||||
statusColor: {
|
||||
Pend: 'bg-white text-black font-bold',
|
||||
PartColl: 'bg-orange-300 text-white font-bold',
|
||||
Coll: 'bg-orange-500 text-white font-bold',
|
||||
PartRecv: 'bg-blue-200 text-black font-bold',
|
||||
Recv: 'bg-blue-500 text-white font-bold',
|
||||
Inc: 'bg-yellow-500 text-white font-bold',
|
||||
Fin: 'bg-green-500 text-white font-bold',
|
||||
},
|
||||
filterTable: "",
|
||||
filterKey: 'Total',
|
||||
filterKey: 'Total',
|
||||
statusMap: {
|
||||
Total: [],
|
||||
Pend: ['Pend'],
|
||||
Coll: ['Coll', 'PartColl'],
|
||||
Recv: ['Recv', 'PartRecv'],
|
||||
Inc: ['Inc'],
|
||||
Fin: ['Fin'],
|
||||
},
|
||||
|
||||
// Sorting & Pagination
|
||||
sortCol: 'REQDATE',
|
||||
sortAsc: false,
|
||||
currentPage: 1,
|
||||
pageSize: 30,
|
||||
|
||||
sort(col) {
|
||||
if (this.sortCol === col) {
|
||||
this.sortAsc = !this.sortAsc;
|
||||
} else {
|
||||
this.sortCol = col;
|
||||
this.sortAsc = true;
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) this.currentPage++;
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) this.currentPage--;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.filtered.length / this.pageSize) || 1;
|
||||
},
|
||||
|
||||
get sorted() {
|
||||
return this.filtered.slice().sort((a, b) => {
|
||||
let modifier = this.sortAsc ? 1 : -1;
|
||||
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
|
||||
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
get paginated() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.sorted.slice(start, end);
|
||||
},
|
||||
|
||||
init() {
|
||||
this.today = new Date().toISOString().slice(0, 10);
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.$watch('filterTable', () => {
|
||||
this.currentPage = 1;
|
||||
});
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
fetchList() {
|
||||
this.isLoading = true;
|
||||
this.list = [];
|
||||
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
|
||||
let param = new URLSearchParams(this.filter).toString();
|
||||
for (let k in this.counters) { this.counters[k] = 0; }
|
||||
fetch(`${BASEURL}/api/requests?${param}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(res => res.json()).then(data => {
|
||||
this.list = data.data ?? [];
|
||||
this.filterKey = 'Total';
|
||||
this.list.forEach(item => {
|
||||
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
|
||||
else {
|
||||
if (item.STATS == 'PartColl') { this.counters.Coll++; }
|
||||
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
|
||||
this.counters.Total++;
|
||||
}
|
||||
});
|
||||
this.list.sort((a, b) => {
|
||||
let codeA = statusOrder[a.STATS] ?? 0;
|
||||
let codeB = statusOrder[b.STATS] ?? 0;
|
||||
return codeA - codeB;
|
||||
});
|
||||
}).finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
isValidated(item) {
|
||||
return item.ISVAL == 1 && item.ISPENDING != 1;
|
||||
},
|
||||
get filtered() {
|
||||
// Reset pagination when filter changes (implied by this getter being accessed if dependencies change)
|
||||
// However, side-effects in getters are tricky.
|
||||
// Better to just let the user navigate back, or watch variables.
|
||||
// For now, let's keep it pure.
|
||||
let filteredList = this.list;
|
||||
if (this.filterKey === 'Validated') {
|
||||
filteredList = filteredList.filter(item => this.isValidated(item));
|
||||
} else {
|
||||
const validStatuses = this.statusMap[this.filterKey];
|
||||
if (validStatuses.length > 0) {
|
||||
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
|
||||
}
|
||||
}
|
||||
if (this.filterTable) {
|
||||
const searchTerm = this.filterTable.toLowerCase();
|
||||
filteredList = filteredList.filter(item =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm)
|
||||
)
|
||||
);
|
||||
}
|
||||
return filteredList;
|
||||
},
|
||||
get validatedCount() {
|
||||
return this.list.filter(r => this.isValidated(r)).length;
|
||||
},
|
||||
|
||||
/*
|
||||
sample dialog
|
||||
*/
|
||||
item: '',
|
||||
isDialogSampleOpen: false,
|
||||
isSampleLoading: false,
|
||||
|
||||
openSampleDialog(accessnumber) {
|
||||
this.isDialogSampleOpen = true;
|
||||
this.fetchItem(accessnumber)
|
||||
},
|
||||
|
||||
closeSampleDialog() {
|
||||
this.isDialogSampleOpen = false;
|
||||
},
|
||||
|
||||
fetchItem(accessnumber) {
|
||||
this.isSampleLoading = true;
|
||||
this.item = [];
|
||||
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
|
||||
.then(res => res.json()).then(data => {
|
||||
this.item = data.data ?? {};
|
||||
if (!Array.isArray(this.item.samples)) this.item.samples = [];
|
||||
}).finally(() => {
|
||||
this.isSampleLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
collect(sampcode, accessnumber) {
|
||||
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
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) {
|
||||
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
preview dialog
|
||||
*/
|
||||
isDialogPreviewOpen: false,
|
||||
reviewed: false,
|
||||
previewItem: null,
|
||||
openPreviewDialog(accessnumber, type, item) {
|
||||
this.previewAccessnumber = accessnumber;
|
||||
this.previewItem = item;
|
||||
this.previewType = type;
|
||||
this.isDialogPreviewOpen = true;
|
||||
this.reviewed = false;
|
||||
},
|
||||
closePreviewDialog() {
|
||||
this.isDialogPreviewOpen = false;
|
||||
this.previewItem = null;
|
||||
},
|
||||
setPreviewType(type) {
|
||||
this.previewType = type;
|
||||
},
|
||||
getPreviewUrl() {
|
||||
let base = 'http://glenlis/spooler_db/main_dev.php';
|
||||
let url = `${base}?acc=${this.previewAccessnumber}`;
|
||||
if (this.previewType === 'ind') url += '&lang=ID';
|
||||
if (this.previewType === 'eng') url += '&lang=EN';
|
||||
if (this.previewType === 'pdf') url += '&output=pdf';
|
||||
|
||||
// Keep fallback for local dev if needed, but the above is the expected logic
|
||||
// return "http://localhost/application.html";
|
||||
return url;
|
||||
},
|
||||
validate(accessnumber, userid) {
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}` })
|
||||
}).then(response => {
|
||||
this.closePreviewDialog();
|
||||
this.fetchList();
|
||||
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
unvalidate dialog
|
||||
*/
|
||||
isDialogUnvalOpen: false,
|
||||
unvalReason: '',
|
||||
unvalAccessnumber: null,
|
||||
openUnvalDialog(accessnumber) {
|
||||
this.unvalReason = '';
|
||||
this.isDialogUnvalOpen = true;
|
||||
this.unvalAccessnumber = accessnumber;
|
||||
},
|
||||
unvalidate(accessnumber, userid) {
|
||||
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
|
||||
}).then(response => {
|
||||
this.closeUnvalDialog();
|
||||
this.fetchList();
|
||||
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
|
||||
});
|
||||
},
|
||||
closeUnvalDialog() {
|
||||
this.isDialogUnvalOpen = false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
<?= $this->include('shared/script_dashboard'); ?>
|
||||
Alpine.start();
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
@ -1,74 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="corporate">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CMOD</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
font-size: 0.71rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 0.2rem 1rem;
|
||||
min-height: 0rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
font-size: 0.71rem !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-base-200 min-h-screen" x-data="main">
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar bg-base-100 shadow-md px-6 z-20">
|
||||
<div class='flex-1'>
|
||||
<a class='text-xl text-primary font-bold tracking-wide flex items-center gap-2 ml-2'>
|
||||
<i class="fa fa-cube"></i> CMOD <span class="text-base-content/40 font-light text-sm hidden sm:inline-block">|
|
||||
Customer Service Dashboard</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right hidden sm:block leading-tight">
|
||||
<div class="text-sm font-bold opacity-70">Hi, <?= session('userid'); ?></div>
|
||||
<div class="text-xs opacity-50"><?= session()->get('userrole') ?></div>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost avatar placeholder px-2">
|
||||
<span class="text-xl"><i class="fa fa-bars"></i></span>
|
||||
</div>
|
||||
<ul tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300">
|
||||
<li><a href="<?= base_url('logout') ?>" class="text-error hover:bg-error/10"><i
|
||||
class="fa fa-sign-out-alt mr-2"></i> Logout</a></li>
|
||||
<li><a @click.prevent="openDialogSetPassword()"><i class="fa fa-key mr-2"></i> Change Password</a></li>
|
||||
<div class="divider my-1"></div>
|
||||
<li><a href="<?= base_url('cs') ?>"><i class="fa fa-chart-bar mr-2"></i> Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page Content -->
|
||||
<?= $this->renderSection('content'); ?>
|
||||
<?= $this->include('cs/dialog_setPassword'); ?>
|
||||
|
||||
<footer class='bg-base-100 p-1 mt-auto'>© <?= date('Y'); ?> - 5Panda</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.BASEURL = "<?= base_url(); ?>";
|
||||
</script>
|
||||
<?= $this->renderSection('script'); ?>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
128
app/Views/dummy_page.php
Normal file
128
app/Views/dummy_page.php
Normal file
@ -0,0 +1,128 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Loading... Or Not</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
.message {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.loader {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 8px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.cat {
|
||||
font-size: 5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.fact {
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 1.5rem;
|
||||
border-radius: 15px;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.fact h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.buttons {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
margin: 10px;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
border-radius: 25px;
|
||||
font-weight: bold;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 5px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
.shrug {
|
||||
font-size: 2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="cat">👀</div>
|
||||
<h1>Nothing to See Here!</h1>
|
||||
<div class="loader"></div>
|
||||
<p class="message">The specimens are probably having a tea party somewhere...</p>
|
||||
|
||||
<div class="fact">
|
||||
<h3>💬 Did You Know?</h3>
|
||||
<p id="fact">Loading random science fact...</p>
|
||||
</div>
|
||||
|
||||
<div class="shrug">🙹 🧀 🦽</div>
|
||||
|
||||
<div class="buttons">
|
||||
<a href="javascript:history.back()" class="btn">Go Back</a>
|
||||
<a href="/gdc_cmod/" class="btn">Home</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const facts = [
|
||||
"A group of flamingos is called a 'flamboyance'.",
|
||||
"Octopuses have three hearts and blue blood.",
|
||||
"Bananas are berries, but strawberries aren't.",
|
||||
"Honey never spoils. Archaeologists found 3000-year-old honey still edible.",
|
||||
"Wombat poop is cube-shaped to mark territory.",
|
||||
"A day on Venus is longer than its year.",
|
||||
"Scotland has 421 words for 'snow'.",
|
||||
"Sloths can hold their breath longer than dolphins can (up to 40 minutes).",
|
||||
"The shortest war in history lasted 38-45 minutes (Britain vs Zanzibar, 1896).",
|
||||
"Electrons are actually just rumors spread by atoms."
|
||||
];
|
||||
|
||||
document.getElementById('fact').textContent = facts[Math.floor(Math.random() * facts.length)];
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,62 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="corporate">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Not Found!</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.9.6/lottie.min.js"></script>
|
||||
<title>Not Found</title>
|
||||
<link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
|
||||
<script src="<?= base_url('js/tailwind.min.js'); ?>"></script>
|
||||
<link href="<?= base_url('css/themes.min.css'); ?>" rel="stylesheet" type="text/css" />
|
||||
<script src="<?= base_url('js/fontawesome.min.js'); ?>"></script>
|
||||
<style>
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-content h1 {
|
||||
font-size: 6rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-content p {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.lottie-animation {
|
||||
max-width: 400px;
|
||||
margin-bottom: 2rem;
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="lottie-animation"></div>
|
||||
<div class="error-content">
|
||||
<h1>404</h1>
|
||||
<p>Oops! Halaman yang anda cari tidak tersedia.</p>
|
||||
<a href="/" class="btn btn-primary">Kembali</a>
|
||||
<body class="bg-base-200 min-h-screen flex items-center justify-center p-4">
|
||||
<div class="card bg-base-100 shadow-xl w-full max-w-md text-center">
|
||||
<div class="card-body items-center">
|
||||
<div class="text-base-content/20 text-8xl mb-2">
|
||||
<i class="fa fa-ghost"></i>
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold text-base-content mb-1">404</h1>
|
||||
<h2 class="text-xl font-semibold mb-2">Page Not Found</h2>
|
||||
<p class="text-base-content/70 mb-6">Oops! The page you are looking for does not exist or has been moved.
|
||||
</p>
|
||||
<div class="card-actions">
|
||||
<a href="<?= base_url() ?>" class="btn btn-primary btn-sm">
|
||||
<i class="fa fa-arrow-left mr-2"></i> Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const animation = lottie.loadAnimation({
|
||||
container: document.querySelector('.lottie-animation'),
|
||||
renderer: 'svg',
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
path: 'https://lottie.host/d987597c-7676-4424-8817-7fca6dc1a33e/BVrFXsaeui.json'
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,73 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="corporate">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Unauthorized!</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<title>Unauthorized</title>
|
||||
<link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
|
||||
<script src="<?= base_url('js/tailwind.min.js'); ?>"></script>
|
||||
<link href="<?= base_url('css/themes.min.css'); ?>" rel="stylesheet" type="text/css" />
|
||||
<script src="<?= base_url('js/fontawesome.min.js'); ?>"></script>
|
||||
<style>
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(45deg, #0d6efd 0%, #0dcaf0 100%);
|
||||
}
|
||||
|
||||
.error-container {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 12rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0.5));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-glass {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-glass:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="error-page d-flex align-items-center justify-content-center">
|
||||
<div class="error-container text-center p-4">
|
||||
<h1 class="error-code mb-0">401</h1>
|
||||
<h2 class="display-6 error-message mb-3">Akses Ditolak✋</h2>
|
||||
<p class="lead error-message mb-5">Anda tidak punya izin untuk mengakses halaman ini.</p>
|
||||
<div class="d-flex justify-content-center gap-3">
|
||||
<a href="/" class="btn btn-glass px-4 py-2">Kembali</a>
|
||||
<!-- <a href="#" class="btn btn-glass px-4 py-2">Report Problem</a> -->
|
||||
<body class="bg-base-200 min-h-screen flex items-center justify-center p-4">
|
||||
<div class="card bg-base-100 shadow-xl w-full max-w-md text-center">
|
||||
<div class="card-body items-center">
|
||||
<div class="text-error/80 text-8xl mb-2">
|
||||
<i class="fa fa-lock"></i>
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold text-base-content mb-1">401</h1>
|
||||
<h2 class="text-xl font-semibold mb-2">Access Denied</h2>
|
||||
<p class="text-base-content/70 mb-6">You do not have permission to access this page.</p>
|
||||
<div class="card-actions">
|
||||
<a href="<?= base_url() ?>" class="btn btn-primary btn-sm">
|
||||
<i class="fa fa-home mr-2"></i> Go Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogPreviewOpen">
|
||||
<template x-if="previewAccessnumber">
|
||||
<div class="modal-box w-11/12 max-w-7xl h-[90vh] flex flex-col p-0 overflow-hidden bg-base-100">
|
||||
<!-- Header -->
|
||||
<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-eye text-primary"></i>
|
||||
Preview
|
||||
<span class="badge badge-ghost text-xs" x-text="previewAccessnumber"></span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="join shadow-sm" x-show="previewItem && previewItem.VAL1USER && previewItem.VAL2USER">
|
||||
<button @click="setPreviewType('preview')"
|
||||
:class="previewType === 'preview' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">Default</button>
|
||||
<button @click="setPreviewType('ind')"
|
||||
:class="previewType === 'ind' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">ID</button>
|
||||
<button @click="setPreviewType('eng')"
|
||||
:class="previewType === 'eng' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">EN</button>
|
||||
<button @click="setPreviewType('pdf')"
|
||||
:class="previewType === 'pdf' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">PDF</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" @click="closePreviewDialog()">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 bg-base-300 relative p-1">
|
||||
<iframe id="preview-iframe" x-ref="previewIframe" :src="getPreviewUrl()"
|
||||
class="w-full h-full rounded shadow-sm bg-white"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-3 bg-base-200 border-t border-base-300 flex justify-end items-center gap-4">
|
||||
<label class="label cursor-pointer gap-2 mb-0">
|
||||
<input type="checkbox" x-model="reviewed" class="checkbox checkbox-sm checkbox-primary" />
|
||||
<span class="label-text text-sm">I have reviewed the results</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-ghost" @click="closePreviewDialog()">Cancel</button>
|
||||
<button id="validate-btn" x-ref="validateBtn" class="btn btn-sm btn-success"
|
||||
@click="validate(previewAccessnumber, '<?= session('userid'); ?>')" :disabled="!reviewed">
|
||||
<i class="fa fa-check mr-1"></i> Validate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</dialog>
|
||||
@ -1,88 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogSampleOpen">
|
||||
<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>
|
||||
|
||||
<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"></textarea>
|
||||
<button class="btn btn-sm btn-primary mt-2" @click="saveComment(item.accessnumber)">Save</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="table table-xs table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sample Code</th>
|
||||
<th>Sample Name</th>
|
||||
<th class='text-center'>Collected</th>
|
||||
<th class='text-center'>Received</th>
|
||||
<th>Action</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<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>
|
||||
<tr>
|
||||
<td></td> <td>All</td> <td></td> <td></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></i></button>
|
||||
<button class="btn btn-sm btn-success px-2 py-1" onclick=""><h6 class="p-0 m-0">Coll.</h6></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>
|
||||
<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.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>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</dialog>
|
||||
@ -1,28 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogSetPasswordOpen">
|
||||
<div class="modal-box w-96">
|
||||
<h3 class="font-bold text-lg mb-4">Change Password</h3>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">New Password</span>
|
||||
</label>
|
||||
<input type="password" x-model="password" class="input input-bordered w-full" placeholder="Enter new password" />
|
||||
</div>
|
||||
<div class="form-control w-full mt-3">
|
||||
<label class="label">
|
||||
<span class="label-text">Confirm Password</span>
|
||||
</label>
|
||||
<input type="password" x-model="confirm_password" class="input input-bordered w-full" placeholder="Confirm new password" />
|
||||
</div>
|
||||
<div x-show="error" class="alert alert-error mt-3 text-sm">
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" @click="closeDialogSetPassword()">Cancel</button>
|
||||
<button class="btn btn-primary" @click="savePassword('<?=session('userid'); ?>')" :disabled="isLoading">
|
||||
<span x-show="isLoading" class="loading loading-spinner loading-sm"></span>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop bg-black/30" @click="closeDialogSetPassword()"></div>
|
||||
</dialog>
|
||||
@ -1,10 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogUnvalOpen">
|
||||
<div class="modal-box">
|
||||
<textarea class="textarea textarea-bordered w-full" rows="5" x-model="unvalReason" placeholder="Enter reason for unvalidation..."></textarea>
|
||||
<p class='text-right mt-2'>
|
||||
<button class="btn btn-sm btn-neutral" @click="closeUnvalDialog()">Cancel</button>
|
||||
<button id="unvalidate-btn" x-ref="unvalidateBtn" class="btn btn-sm btn-warning"
|
||||
@click="unvalidate(unvalAccessnumber, '<?=session('userid');?>')" :disabled="!unvalReason.trim()">Unvalidate</button>
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
@ -1,13 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogValOpen">
|
||||
<template x-if="valAccessnumber">
|
||||
<div class="modal-box w-2/3 max-w-5xl">
|
||||
<p class='text-right mx-3 mb-2'>
|
||||
<button class="btn btn-sm btn-neutral" @click="closeValDialog()">Cancel</button>
|
||||
<button id="validate-btn" x-ref="validateBtn" class="btn btn-sm btn-success"
|
||||
@click="validate(valAccessnumber, '<?=session('userid');?>')" :disabled="!isValidateEnabled">Validate</button>
|
||||
</p>
|
||||
<!-- <iframe id="result-iframe" src="http://glenlis/spooler_db/main_dev.php?acc=" width="750px" height="600px"></iframe> -->
|
||||
<iframe id="result-iframe" x-ref="resultIframe" src="<?=base_url('dummypage');?>" width="750px" height="600px"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
</dialog>
|
||||
@ -1,571 +1,22 @@
|
||||
<?= $this->extend('lab/main'); ?>
|
||||
<?php
|
||||
$config = include __DIR__ . '/../shared/dashboard_config.php';
|
||||
$roleConfig = $config['lab'];
|
||||
?>
|
||||
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<?= $this->section('content'); ?>
|
||||
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
|
||||
<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 & Filters -->
|
||||
<div class="p-4 border-b border-base-200 bg-base-50">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
|
||||
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Status Filters -->
|
||||
<div class="join shadow-sm bg-base-100 rounded-lg">
|
||||
<button @click="filterKey = 'Total'"
|
||||
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Pend'"
|
||||
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Coll'"
|
||||
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Recv'"
|
||||
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Inc'"
|
||||
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Fin'"
|
||||
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Validated'"
|
||||
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Date Filter -->
|
||||
<div
|
||||
class="flex flex-col md:flex-row gap-3 items-end 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='fetchList()'><i class='fa fa-search'></i> Search</button>
|
||||
<button class="btn btn-sm btn-neutral" @click='reset()'><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>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<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:7%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:3%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:4%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="i in 5" :key="i">
|
||||
<tr>
|
||||
<td colspan="11">
|
||||
<div class="skeleton h-4 w-full"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<template x-if="!isLoading && !list.length">
|
||||
<div class="text-center py-10">
|
||||
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
|
||||
<p>No records found</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!isLoading && list.length">
|
||||
<table class="table table-xs table-zebra w-full">
|
||||
<thead class="bg-base-100 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th style='width:7%;' @click="sort('REQDATE')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Order Datetime
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;' @click="sort('Name')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Patient Name
|
||||
<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:7%;' @click="sort('SP_ACCESSNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Lab
|
||||
<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:7%;' @click="sort('HOSTORDERNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Register
|
||||
<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:8%;' @click="sort('REFF')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Reff
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:8%;' @click="sort('DOC')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Doctor
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;'>Tests</th>
|
||||
<th style='width:3%;'>ResTo</th>
|
||||
<th style='width:5%;'>Val</th>
|
||||
<th style='width:5%;'>Result</th>
|
||||
<th style='width:4%;' @click="sort('STATS')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Status
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'STATS' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody>
|
||||
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
|
||||
<tr class="hover:bg-base-300">
|
||||
<td x-text="req.REQDATE"></td>
|
||||
<td x-text="req.Name"></td>
|
||||
<td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.REFF"></td>
|
||||
<td x-text="req.DOC"></td>
|
||||
<td x-text="req.TESTS"></td>
|
||||
<td x-text="req.ODR_CRESULT_TO"></td>
|
||||
<td>
|
||||
<div class='flex gap-1 items-center'>
|
||||
<div class='w-15'>
|
||||
<p>1: <span x-text="req.VAL1USER"></span></p>
|
||||
<p>2: <span x-text="req.VAL2USER"></span></p>
|
||||
</div>
|
||||
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
|
||||
<div class='text-center'>
|
||||
<template
|
||||
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
|
||||
<button class="btn btn-xs btn-outline btn-secondary"
|
||||
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
|
||||
class="fa-solid fa-rotate-right"></i></button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="req.STATS !== 'PartColl' && req.STATS !== 'Coll' && req.STATS !== 'Pend'">
|
||||
<button class="btn btn-xs btn-outline btn-primary"
|
||||
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
|
||||
</template>
|
||||
</td>
|
||||
<td x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="font-bold cursor-pointer"
|
||||
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Control -->
|
||||
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
|
||||
x-show="!isLoading && list.length > 0">
|
||||
<div class="text-xs text-base-content/60">
|
||||
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
|
||||
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
|
||||
<span class="font-bold" x-text="filtered.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="totalPages"></span>
|
||||
</button>
|
||||
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<?php echo $this->include('lab/dialog_sample'); ?>
|
||||
<?php echo $this->include('lab/dialog_unval'); ?>
|
||||
<?php echo $this->include('lab/dialog_preview'); ?>
|
||||
|
||||
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
|
||||
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
|
||||
<?= $this->include('shared/dialog_unval'); ?>
|
||||
<?= $this->include('shared/dialog_preview'); ?>
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section('script') ?>
|
||||
<?= $this->section('script'); ?>
|
||||
<script type="module">
|
||||
import Alpine from '<?= base_url("js/app.js"); ?>';
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("dashboard", () => ({
|
||||
// dashboard
|
||||
today: "",
|
||||
filter: { date1: "", date2: "" },
|
||||
list: [],
|
||||
isLoading: false,
|
||||
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
|
||||
statusColor: {
|
||||
Pend: 'bg-white text-black font-bold',
|
||||
PartColl: 'bg-orange-300 text-white font-bold',
|
||||
Coll: 'bg-orange-500 text-white font-bold',
|
||||
PartRecv: 'bg-blue-200 text-black font-bold',
|
||||
Recv: 'bg-blue-500 text-white font-bold',
|
||||
Inc: 'bg-yellow-500 text-white font-bold',
|
||||
Fin: 'bg-green-500 text-white font-bold',
|
||||
},
|
||||
filterTable: "",
|
||||
filterKey: 'Total',
|
||||
filterKey: 'Total',
|
||||
statusMap: {
|
||||
Total: [],
|
||||
Pend: ['Pend'],
|
||||
Coll: ['Coll', 'PartColl'],
|
||||
Recv: ['Recv', 'PartRecv'],
|
||||
Inc: ['Inc'],
|
||||
Fin: ['Fin'],
|
||||
},
|
||||
|
||||
// Sorting & Pagination
|
||||
sortCol: 'REQDATE',
|
||||
sortAsc: false,
|
||||
currentPage: 1,
|
||||
pageSize: 30,
|
||||
|
||||
sort(col) {
|
||||
if (this.sortCol === col) {
|
||||
this.sortAsc = !this.sortAsc;
|
||||
} else {
|
||||
this.sortCol = col;
|
||||
this.sortAsc = true;
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) this.currentPage++;
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) this.currentPage--;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.filtered.length / this.pageSize) || 1;
|
||||
},
|
||||
|
||||
get sorted() {
|
||||
return this.filtered.slice().sort((a, b) => {
|
||||
let modifier = this.sortAsc ? 1 : -1;
|
||||
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
|
||||
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
get paginated() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.sorted.slice(start, end);
|
||||
},
|
||||
|
||||
init() {
|
||||
this.today = new Date().toISOString().slice(0, 10);
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.$watch('filterTable', () => {
|
||||
this.currentPage = 1;
|
||||
});
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
fetchList() {
|
||||
this.isLoading = true;
|
||||
this.list = [];
|
||||
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
|
||||
let param = new URLSearchParams(this.filter).toString();
|
||||
for (let k in this.counters) { this.counters[k] = 0; }
|
||||
fetch(`${BASEURL}/api/requests?${param}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(res => res.json()).then(data => {
|
||||
this.list = data.data ?? [];
|
||||
this.filterKey = 'Total';
|
||||
this.list.forEach(item => {
|
||||
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
|
||||
else {
|
||||
if (item.STATS == 'PartColl') { this.counters.Coll++; }
|
||||
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
|
||||
this.counters.Total++;
|
||||
}
|
||||
});
|
||||
this.list.sort((a, b) => {
|
||||
let codeA = statusOrder[a.STATS] ?? 0;
|
||||
let codeB = statusOrder[b.STATS] ?? 0;
|
||||
return codeA - codeB;
|
||||
});
|
||||
}).finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
isValidated(item) {
|
||||
return item.ISVAL == 1 && item.ISPENDING != 1;
|
||||
},
|
||||
get filtered() {
|
||||
// Reset pagination when filter changes (implied by this getter being accessed if dependencies change)
|
||||
// However, side-effects in getters are tricky.
|
||||
// Better to just let the user navigate back, or watch variables.
|
||||
// For now, let's keep it pure.
|
||||
let filteredList = this.list;
|
||||
if (this.filterKey === 'Validated') {
|
||||
filteredList = filteredList.filter(item => this.isValidated(item));
|
||||
} else {
|
||||
const validStatuses = this.statusMap[this.filterKey];
|
||||
if (validStatuses.length > 0) {
|
||||
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
|
||||
}
|
||||
}
|
||||
if (this.filterTable) {
|
||||
const searchTerm = this.filterTable.toLowerCase();
|
||||
filteredList = filteredList.filter(item =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm)
|
||||
)
|
||||
);
|
||||
}
|
||||
return filteredList;
|
||||
},
|
||||
get validatedCount() {
|
||||
return this.list.filter(r => this.isValidated(r)).length;
|
||||
},
|
||||
|
||||
/*
|
||||
sample dialog
|
||||
*/
|
||||
item: '',
|
||||
isDialogSampleOpen: false,
|
||||
isSampleLoading: false,
|
||||
|
||||
openSampleDialog(accessnumber) {
|
||||
this.isDialogSampleOpen = true;
|
||||
this.fetchItem(accessnumber)
|
||||
},
|
||||
|
||||
closeSampleDialog() {
|
||||
this.isDialogSampleOpen = false;
|
||||
},
|
||||
|
||||
fetchItem(accessnumber) {
|
||||
this.isSampleLoading = true;
|
||||
this.item = [];
|
||||
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
|
||||
.then(res => res.json()).then(data => {
|
||||
this.item = data.data ?? {};
|
||||
if (!Array.isArray(this.item.samples)) this.item.samples = [];
|
||||
}).finally(() => {
|
||||
this.isSampleLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
collect(sampcode, accessnumber) {
|
||||
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
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) {
|
||||
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
preview dialog
|
||||
*/
|
||||
isDialogPreviewOpen: false,
|
||||
reviewed: false,
|
||||
previewItem: null,
|
||||
openPreviewDialog(accessnumber, type, item) {
|
||||
this.previewAccessnumber = accessnumber;
|
||||
this.previewItem = item;
|
||||
this.previewType = type;
|
||||
this.isDialogPreviewOpen = true;
|
||||
this.reviewed = false;
|
||||
},
|
||||
closePreviewDialog() {
|
||||
this.isDialogPreviewOpen = false;
|
||||
this.previewItem = null;
|
||||
},
|
||||
setPreviewType(type) {
|
||||
this.previewType = type;
|
||||
},
|
||||
getPreviewUrl() {
|
||||
let base = 'http://glenlis/spooler_db/main_dev.php';
|
||||
let url = `${base}?acc=${this.previewAccessnumber}`;
|
||||
if (this.previewType === 'ind') url += '&lang=ID';
|
||||
if (this.previewType === 'eng') url += '&lang=EN';
|
||||
if (this.previewType === 'pdf') url += '&output=pdf';
|
||||
|
||||
// Keep fallback for local dev if needed, but the above is the expected logic
|
||||
// return "http://localhost/application.html";
|
||||
return url;
|
||||
},
|
||||
validate(accessnumber, userid) {
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}` })
|
||||
}).then(response => {
|
||||
this.closePreviewDialog();
|
||||
this.fetchList();
|
||||
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
unvalidate dialog
|
||||
*/
|
||||
isDialogUnvalOpen: false,
|
||||
unvalReason: '',
|
||||
unvalAccessnumber: null,
|
||||
openUnvalDialog(accessnumber) {
|
||||
this.unvalReason = '';
|
||||
this.isDialogUnvalOpen = true;
|
||||
this.unvalAccessnumber = accessnumber;
|
||||
},
|
||||
unvalidate(accessnumber, userid) {
|
||||
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
|
||||
}).then(response => {
|
||||
this.closeUnvalDialog();
|
||||
this.fetchList();
|
||||
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
|
||||
});
|
||||
},
|
||||
closeUnvalDialog() {
|
||||
this.isDialogUnvalOpen = false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
<?= $this->include('shared/script_dashboard'); ?>
|
||||
Alpine.start();
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
19
app/Views/lab/validate.php
Normal file
19
app/Views/lab/validate.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
$config = include __DIR__ . '/../shared/dashboard_config.php';
|
||||
$roleConfig = $config['lab'];
|
||||
?>
|
||||
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?>
|
||||
|
||||
<?= $this->section('content'); ?>
|
||||
<main class="p-4 flex-1 flex flex-col gap-2" x-data="validatePage">
|
||||
<?= $this->include('shared/dashboard_validate', ['config' => $roleConfig]); ?>
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section('script'); ?>
|
||||
<script type="module">
|
||||
import Alpine from '<?= base_url("js/app.js"); ?>';
|
||||
<?= $this->include('shared/script_validate'); ?>
|
||||
Alpine.start();
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
@ -1,14 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="corporate">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - CMOD</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js"></script>
|
||||
<link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
|
||||
<script src="<?= base_url('css/tailwind.min.js'); ?>"></script>
|
||||
<link href="<?= base_url('css/themes.min.css'); ?>" rel="stylesheet" type="text/css" />
|
||||
<script src="<?= base_url('js/fontawesome.min.js'); ?>"></script>
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<div class="w-full max-w-sm mx-auto">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
@ -20,7 +22,7 @@
|
||||
</div>
|
||||
<h2 class="card-title text-2xl font-semibold text-base-content mb-1">CMOD</h2>
|
||||
<p class="mb-6 text-sm text-base-content/70">Sign in to continue</p>
|
||||
<form method="post" action="<?=base_url('login')?>" class="w-full flex flex-col gap-4">
|
||||
<form method="post" action="<?= base_url('login') ?>" class="w-full flex flex-col gap-4">
|
||||
<div class="form-control">
|
||||
<label class="input input-bordered flex items-center gap-2 w-full">
|
||||
<i class="fa fa-user text-base-content/50"></i>
|
||||
@ -45,4 +47,5 @@
|
||||
<div class="text-center mt-6 text-xs text-base-content/40">© 2025 - 5Panda. All rights reserved.</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,54 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogPreviewOpen">
|
||||
<template x-if="previewAccessnumber">
|
||||
<div class="modal-box w-11/12 max-w-7xl h-[90vh] flex flex-col p-0 overflow-hidden bg-base-100">
|
||||
<!-- Header -->
|
||||
<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-eye text-primary"></i>
|
||||
Preview
|
||||
<span class="badge badge-ghost text-xs" x-text="previewAccessnumber"></span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="join shadow-sm" x-show="previewItem && previewItem.VAL1USER && previewItem.VAL2USER">
|
||||
<button @click="setPreviewType('preview')"
|
||||
:class="previewType === 'preview' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">Default</button>
|
||||
<button @click="setPreviewType('ind')"
|
||||
:class="previewType === 'ind' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">ID</button>
|
||||
<button @click="setPreviewType('eng')"
|
||||
:class="previewType === 'eng' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">EN</button>
|
||||
<button @click="setPreviewType('pdf')"
|
||||
:class="previewType === 'pdf' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">PDF</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" @click="closePreviewDialog()">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 bg-base-300 relative p-1">
|
||||
<iframe id="preview-iframe" x-ref="previewIframe" :src="getPreviewUrl()"
|
||||
class="w-full h-full rounded shadow-sm bg-white"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-3 bg-base-200 border-t border-base-300 flex justify-end items-center gap-4">
|
||||
<label class="label cursor-pointer gap-2 mb-0">
|
||||
<input type="checkbox" x-model="reviewed" class="checkbox checkbox-sm checkbox-primary" />
|
||||
<span class="label-text text-sm">I have reviewed the results</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-ghost" @click="closePreviewDialog()">Cancel</button>
|
||||
<button id="validate-btn" x-ref="validateBtn" class="btn btn-sm btn-success"
|
||||
@click="validate(previewAccessnumber, '<?= session('userid'); ?>')" :disabled="!reviewed">
|
||||
<i class="fa fa-check mr-1"></i> Validate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</dialog>
|
||||
@ -1,87 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogSampleOpen">
|
||||
<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>
|
||||
|
||||
<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"></textarea>
|
||||
<button class="btn btn-sm btn-primary mt-2" @click="saveComment(item.accessnumber)">Save</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="table table-xs table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sample Code</th>
|
||||
<th>Sample Name</th>
|
||||
<th class='text-center'>Collected</th>
|
||||
<th class='text-center'>Received</th>
|
||||
<th>Action</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td></td> <td>All</td> <td></td> <td></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></i></button>
|
||||
<button class="btn btn-sm btn-success px-2 py-1" onclick=""><h6 class="p-0 m-0">Coll.</h6></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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">
|
||||
<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>
|
||||
<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.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>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</dialog>
|
||||
@ -1,28 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogSetPasswordOpen">
|
||||
<div class="modal-box w-96">
|
||||
<h3 class="font-bold text-lg mb-4">Change Password</h3>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">New Password</span>
|
||||
</label>
|
||||
<input type="password" x-model="password" class="input input-bordered w-full" placeholder="Enter new password" />
|
||||
</div>
|
||||
<div class="form-control w-full mt-3">
|
||||
<label class="label">
|
||||
<span class="label-text">Confirm Password</span>
|
||||
</label>
|
||||
<input type="password" x-model="confirm_password" class="input input-bordered w-full" placeholder="Confirm new password" />
|
||||
</div>
|
||||
<div x-show="error" class="alert alert-error mt-3 text-sm">
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" @click="closeDialogSetPassword()">Cancel</button>
|
||||
<button class="btn btn-primary" @click="savePassword('<?=session('userid'); ?>')" :disabled="isLoading">
|
||||
<span x-show="isLoading" class="loading loading-spinner loading-sm"></span>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop bg-black/30" @click="closeDialogSetPassword()"></div>
|
||||
</dialog>
|
||||
@ -1,10 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogUnvalOpen">
|
||||
<div class="modal-box">
|
||||
<textarea class="textarea textarea-bordered w-full" rows="5" x-model="unvalReason" placeholder="Enter reason for unvalidation..."></textarea>
|
||||
<p class='text-right mt-2'>
|
||||
<button class="btn btn-sm btn-neutral" @click="closeUnvalDialog()">Cancel</button>
|
||||
<button id="unvalidate-btn" x-ref="unvalidateBtn" class="btn btn-sm btn-warning"
|
||||
@click="unvalidate(unvalAccessnumber, '<?=session('userid');?>')" :disabled="!unvalReason.trim()">Unvalidate</button>
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
@ -1,13 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogValOpen">
|
||||
<template x-if="valAccessnumber">
|
||||
<div class="modal-box w-2/3 max-w-5xl">
|
||||
<p class='text-right mx-3 mb-2'>
|
||||
<button class="btn btn-sm btn-neutral" @click="closeValDialog()">Cancel</button>
|
||||
<button id="validate-btn" x-ref="validateBtn" class="btn btn-sm btn-success"
|
||||
@click="validate(valAccessnumber, '<?=session('userid');?>')" :disabled="!isValidateEnabled">Validate</button>
|
||||
</p>
|
||||
<!-- <iframe id="result-iframe" src="http://glenlis/spooler_db/main_dev.php?acc=" width="750px" height="600px"></iframe> -->
|
||||
<iframe id="result-iframe" x-ref="resultIframe" src="<?=base_url('dummypage');?>" width="750px" height="600px"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
</dialog>
|
||||
@ -1,571 +1,22 @@
|
||||
<?= $this->extend('phlebo/main'); ?>
|
||||
<?php
|
||||
$config = include __DIR__ . '/../shared/dashboard_config.php';
|
||||
$roleConfig = $config['phlebo'];
|
||||
?>
|
||||
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<?= $this->section('content'); ?>
|
||||
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
|
||||
<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 & Filters -->
|
||||
<div class="p-4 border-b border-base-200 bg-base-50">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
|
||||
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Status Filters -->
|
||||
<div class="join shadow-sm bg-base-100 rounded-lg">
|
||||
<button @click="filterKey = 'Total'"
|
||||
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Pend'"
|
||||
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Coll'"
|
||||
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Recv'"
|
||||
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Inc'"
|
||||
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Fin'"
|
||||
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Validated'"
|
||||
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Date Filter -->
|
||||
<div
|
||||
class="flex flex-col md:flex-row gap-3 items-end 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='fetchList()'><i class='fa fa-search'></i> Search</button>
|
||||
<button class="btn btn-sm btn-neutral" @click='reset()'><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>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<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:7%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:3%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:4%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="i in 5" :key="i">
|
||||
<tr>
|
||||
<td colspan="11">
|
||||
<div class="skeleton h-4 w-full"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<template x-if="!isLoading && !list.length">
|
||||
<div class="text-center py-10">
|
||||
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
|
||||
<p>No records found</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!isLoading && list.length">
|
||||
<table class="table table-xs table-zebra w-full">
|
||||
<thead class="bg-base-100 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th style='width:7%;' @click="sort('REQDATE')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Order Datetime
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;' @click="sort('Name')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Patient Name
|
||||
<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:7%;' @click="sort('SP_ACCESSNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Lab
|
||||
<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:7%;' @click="sort('HOSTORDERNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Register
|
||||
<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:8%;' @click="sort('REFF')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Reff
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:8%;' @click="sort('DOC')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Doctor
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;'>Tests</th>
|
||||
<th style='width:3%;'>ResTo</th>
|
||||
<th style='width:5%;'>Val</th>
|
||||
<!-- <th style='width:5%;'>Result</th> -->
|
||||
<th style='width:4%;' @click="sort('STATS')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Status
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'STATS' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody>
|
||||
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
|
||||
<tr class="hover:bg-base-300">
|
||||
<td x-text="req.REQDATE"></td>
|
||||
<td x-text="req.Name"></td>
|
||||
<td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.REFF"></td>
|
||||
<td x-text="req.DOC"></td>
|
||||
<td x-text="req.TESTS"></td>
|
||||
<td x-text="req.ODR_CRESULT_TO"></td>
|
||||
<td>
|
||||
<div class='flex gap-1 items-center'>
|
||||
<div class='w-15'>
|
||||
<p>1: <span x-text="req.VAL1USER"></span></p>
|
||||
<p>2: <span x-text="req.VAL2USER"></span></p>
|
||||
</div>
|
||||
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
|
||||
<div class='text-center'>
|
||||
<template
|
||||
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
|
||||
<button class="btn btn-xs btn-outline btn-secondary"
|
||||
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
|
||||
class="fa-solid fa-rotate-right"></i></button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<!-- <td>
|
||||
<template x-if="req.STATS !== 'PartColl' && req.STATS !== 'Coll' && req.STATS !== 'Pend'">
|
||||
<button class="btn btn-xs btn-outline btn-primary"
|
||||
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
|
||||
</template>
|
||||
</td> -->
|
||||
<td x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="font-bold cursor-pointer"
|
||||
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Control -->
|
||||
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
|
||||
x-show="!isLoading && list.length > 0">
|
||||
<div class="text-xs text-base-content/60">
|
||||
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
|
||||
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
|
||||
<span class="font-bold" x-text="filtered.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="totalPages"></span>
|
||||
</button>
|
||||
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<?php echo $this->include('phlebo/dialog_sample'); ?>
|
||||
<?php echo $this->include('phlebo/dialog_unval'); ?>
|
||||
<?php echo $this->include('phlebo/dialog_preview'); ?>
|
||||
|
||||
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
|
||||
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
|
||||
<?= $this->include('shared/dialog_unval'); ?>
|
||||
<?= $this->include('shared/dialog_preview'); ?>
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section('script') ?>
|
||||
<?= $this->section('script'); ?>
|
||||
<script type="module">
|
||||
import Alpine from '<?= base_url("js/app.js"); ?>';
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("dashboard", () => ({
|
||||
// dashboard
|
||||
today: "",
|
||||
filter: { date1: "", date2: "" },
|
||||
list: [],
|
||||
isLoading: false,
|
||||
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
|
||||
statusColor: {
|
||||
Pend: 'bg-white text-black font-bold',
|
||||
PartColl: 'bg-orange-300 text-white font-bold',
|
||||
Coll: 'bg-orange-500 text-white font-bold',
|
||||
PartRecv: 'bg-blue-200 text-black font-bold',
|
||||
Recv: 'bg-blue-500 text-white font-bold',
|
||||
Inc: 'bg-yellow-500 text-white font-bold',
|
||||
Fin: 'bg-green-500 text-white font-bold',
|
||||
},
|
||||
filterTable: "",
|
||||
filterKey: 'Total',
|
||||
filterKey: 'Total',
|
||||
statusMap: {
|
||||
Total: [],
|
||||
Pend: ['Pend'],
|
||||
Coll: ['Coll', 'PartColl'],
|
||||
Recv: ['Recv', 'PartRecv'],
|
||||
Inc: ['Inc'],
|
||||
Fin: ['Fin'],
|
||||
},
|
||||
|
||||
// Sorting & Pagination
|
||||
sortCol: 'REQDATE',
|
||||
sortAsc: false,
|
||||
currentPage: 1,
|
||||
pageSize: 30,
|
||||
|
||||
sort(col) {
|
||||
if (this.sortCol === col) {
|
||||
this.sortAsc = !this.sortAsc;
|
||||
} else {
|
||||
this.sortCol = col;
|
||||
this.sortAsc = true;
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) this.currentPage++;
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) this.currentPage--;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.filtered.length / this.pageSize) || 1;
|
||||
},
|
||||
|
||||
get sorted() {
|
||||
return this.filtered.slice().sort((a, b) => {
|
||||
let modifier = this.sortAsc ? 1 : -1;
|
||||
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
|
||||
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
get paginated() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.sorted.slice(start, end);
|
||||
},
|
||||
|
||||
init() {
|
||||
this.today = new Date().toISOString().slice(0, 10);
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.$watch('filterTable', () => {
|
||||
this.currentPage = 1;
|
||||
});
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
fetchList() {
|
||||
this.isLoading = true;
|
||||
this.list = [];
|
||||
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
|
||||
let param = new URLSearchParams(this.filter).toString();
|
||||
for (let k in this.counters) { this.counters[k] = 0; }
|
||||
fetch(`${BASEURL}/api/requests?${param}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(res => res.json()).then(data => {
|
||||
this.list = data.data ?? [];
|
||||
this.filterKey = 'Total';
|
||||
this.list.forEach(item => {
|
||||
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
|
||||
else {
|
||||
if (item.STATS == 'PartColl') { this.counters.Coll++; }
|
||||
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
|
||||
this.counters.Total++;
|
||||
}
|
||||
});
|
||||
this.list.sort((a, b) => {
|
||||
let codeA = statusOrder[a.STATS] ?? 0;
|
||||
let codeB = statusOrder[b.STATS] ?? 0;
|
||||
return codeA - codeB;
|
||||
});
|
||||
}).finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
isValidated(item) {
|
||||
return item.ISVAL == 1 && item.ISPENDING != 1;
|
||||
},
|
||||
get filtered() {
|
||||
// Reset pagination when filter changes (implied by this getter being accessed if dependencies change)
|
||||
// However, side-effects in getters are tricky.
|
||||
// Better to just let the user navigate back, or watch variables.
|
||||
// For now, let's keep it pure.
|
||||
let filteredList = this.list;
|
||||
if (this.filterKey === 'Validated') {
|
||||
filteredList = filteredList.filter(item => this.isValidated(item));
|
||||
} else {
|
||||
const validStatuses = this.statusMap[this.filterKey];
|
||||
if (validStatuses.length > 0) {
|
||||
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
|
||||
}
|
||||
}
|
||||
if (this.filterTable) {
|
||||
const searchTerm = this.filterTable.toLowerCase();
|
||||
filteredList = filteredList.filter(item =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm)
|
||||
)
|
||||
);
|
||||
}
|
||||
return filteredList;
|
||||
},
|
||||
get validatedCount() {
|
||||
return this.list.filter(r => this.isValidated(r)).length;
|
||||
},
|
||||
|
||||
/*
|
||||
sample dialog
|
||||
*/
|
||||
item: '',
|
||||
isDialogSampleOpen: false,
|
||||
isSampleLoading: false,
|
||||
|
||||
openSampleDialog(accessnumber) {
|
||||
this.isDialogSampleOpen = true;
|
||||
this.fetchItem(accessnumber)
|
||||
},
|
||||
|
||||
closeSampleDialog() {
|
||||
this.isDialogSampleOpen = false;
|
||||
},
|
||||
|
||||
fetchItem(accessnumber) {
|
||||
this.isSampleLoading = true;
|
||||
this.item = [];
|
||||
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
|
||||
.then(res => res.json()).then(data => {
|
||||
this.item = data.data ?? {};
|
||||
if (!Array.isArray(this.item.samples)) this.item.samples = [];
|
||||
}).finally(() => {
|
||||
this.isSampleLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
collect(sampcode, accessnumber) {
|
||||
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
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) {
|
||||
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
preview dialog
|
||||
*/
|
||||
isDialogPreviewOpen: false,
|
||||
reviewed: false,
|
||||
previewItem: null,
|
||||
openPreviewDialog(accessnumber, type, item) {
|
||||
this.previewAccessnumber = accessnumber;
|
||||
this.previewItem = item;
|
||||
this.previewType = type;
|
||||
this.isDialogPreviewOpen = true;
|
||||
this.reviewed = false;
|
||||
},
|
||||
closePreviewDialog() {
|
||||
this.isDialogPreviewOpen = false;
|
||||
this.previewItem = null;
|
||||
},
|
||||
setPreviewType(type) {
|
||||
this.previewType = type;
|
||||
},
|
||||
getPreviewUrl() {
|
||||
let base = 'http://glenlis/spooler_db/main_dev.php';
|
||||
let url = `${base}?acc=${this.previewAccessnumber}`;
|
||||
if (this.previewType === 'ind') url += '&lang=ID';
|
||||
if (this.previewType === 'eng') url += '&lang=EN';
|
||||
if (this.previewType === 'pdf') url += '&output=pdf';
|
||||
|
||||
// Keep fallback for local dev if needed, but the above is the expected logic
|
||||
// return "http://localhost/application.html";
|
||||
return url;
|
||||
},
|
||||
validate(accessnumber, userid) {
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}` })
|
||||
}).then(response => {
|
||||
this.closePreviewDialog();
|
||||
this.fetchList();
|
||||
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
unvalidate dialog
|
||||
*/
|
||||
isDialogUnvalOpen: false,
|
||||
unvalReason: '',
|
||||
unvalAccessnumber: null,
|
||||
openUnvalDialog(accessnumber) {
|
||||
this.unvalReason = '';
|
||||
this.isDialogUnvalOpen = true;
|
||||
this.unvalAccessnumber = accessnumber;
|
||||
},
|
||||
unvalidate(accessnumber, userid) {
|
||||
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
|
||||
}).then(response => {
|
||||
this.closeUnvalDialog();
|
||||
this.fetchList();
|
||||
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
|
||||
});
|
||||
},
|
||||
closeUnvalDialog() {
|
||||
this.isDialogUnvalOpen = false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
<?= $this->include('shared/script_dashboard'); ?>
|
||||
Alpine.start();
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
@ -1,74 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="corporate">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CMOD</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
font-size: 0.71rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 0.2rem 1rem;
|
||||
min-height: 0rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
font-size: 0.71rem !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-base-200 min-h-screen" x-data="main">
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar bg-base-100 shadow-md px-6 z-20">
|
||||
<div class='flex-1'>
|
||||
<a class='text-xl text-primary font-bold tracking-wide flex items-center gap-2 ml-2'>
|
||||
<i class="fa fa-cube"></i> CMOD <span class="text-base-content/40 font-light text-sm hidden sm:inline-block">|
|
||||
Phlebotomist Dashboard</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right hidden sm:block leading-tight">
|
||||
<div class="text-sm font-bold opacity-70">Hi, <?= session('userid'); ?></div>
|
||||
<div class="text-xs opacity-50"><?= session()->get('userrole') ?></div>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost avatar placeholder px-2">
|
||||
<span class="text-xl"><i class="fa fa-bars"></i></span>
|
||||
</div>
|
||||
<ul tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300">
|
||||
<li><a href="<?= base_url('logout') ?>" class="text-error hover:bg-error/10"><i
|
||||
class="fa fa-sign-out-alt mr-2"></i> Logout</a></li>
|
||||
<li><a @click.prevent="openDialogSetPassword()"><i class="fa fa-key mr-2"></i> Change Password</a></li>
|
||||
<div class="divider my-1"></div>
|
||||
<li><a href="<?= base_url('phlebo') ?>"><i class="fa fa-chart-bar mr-2"></i> Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page Content -->
|
||||
<?= $this->renderSection('content'); ?>
|
||||
<?= $this->include('phlebo/dialog_setPassword'); ?>
|
||||
|
||||
<footer class='bg-base-100 p-1 mt-auto'>© <?= date('Y'); ?> - 5Panda</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.BASEURL = "<?= base_url(); ?>";
|
||||
</script>
|
||||
<?= $this->renderSection('script'); ?>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
74
app/Views/shared/dashboard_config.php
Normal file
74
app/Views/shared/dashboard_config.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
/**
|
||||
* Dashboard Role Configuration
|
||||
*
|
||||
* Defines role-specific settings for dashboard views to eliminate code duplication.
|
||||
*
|
||||
* Configuration Schema:
|
||||
* - title: Dashboard title displayed in navbar
|
||||
* - columns: Column visibility settings
|
||||
* - lab: Show "No Lab" column (SP_ACCESSNUMBER)
|
||||
* - resto: Show "ResTo" column (ODR_CRESULT_TO)
|
||||
* - register: Show "No Register" column (HOSTORDERNUMBER)
|
||||
* - sampleDialog: Sample dialog behavior
|
||||
* - commentEditable: Allow editing the comment textarea
|
||||
* - showCollectButtons: Show Collect/Un-Coll/Un-Recv buttons
|
||||
* - menuItems: Navigation menu items
|
||||
*/
|
||||
|
||||
return [
|
||||
'admin' => [
|
||||
'title' => 'Admin Dashboard',
|
||||
'sampleDialog' => [
|
||||
'commentEditable' => true,
|
||||
'showCollectButtons' => true,
|
||||
],
|
||||
'menuItems' => [
|
||||
['label' => 'Dashboard', 'href' => 'admin', 'icon' => 'chart-bar'],
|
||||
['label' => 'Validate', 'href' => 'admin/validate', 'icon' => 'check-circle'],
|
||||
],
|
||||
],
|
||||
'cs' => [
|
||||
'title' => 'CS Dashboard',
|
||||
'sampleDialog' => [
|
||||
'commentEditable' => false,
|
||||
'showCollectButtons' => false,
|
||||
],
|
||||
'menuItems' => [
|
||||
['label' => 'Dashboard', 'href' => 'cs', 'icon' => 'chart-bar'],
|
||||
],
|
||||
],
|
||||
'lab' => [
|
||||
'title' => 'Lab Analyst Dashboard',
|
||||
'sampleDialog' => [
|
||||
'commentEditable' => true,
|
||||
'showCollectButtons' => true,
|
||||
],
|
||||
'menuItems' => [
|
||||
['label' => 'Dashboard', 'href' => 'lab', 'icon' => 'chart-bar'],
|
||||
['label' => 'Validate', 'href' => 'lab/validate', 'icon' => 'check-circle'],
|
||||
],
|
||||
],
|
||||
'phlebo' => [
|
||||
'title' => 'Phlebotomist Dashboard',
|
||||
'sampleDialog' => [
|
||||
'commentEditable' => false,
|
||||
'showCollectButtons' => false,
|
||||
],
|
||||
'menuItems' => [
|
||||
['label' => 'Dashboard', 'href' => 'phlebo', 'icon' => 'chart-bar'],
|
||||
],
|
||||
],
|
||||
'superuser' => [
|
||||
'title' => 'Superuser Dashboard',
|
||||
'sampleDialog' => [
|
||||
'commentEditable' => true,
|
||||
'showCollectButtons' => true,
|
||||
],
|
||||
'menuItems' => [
|
||||
['label' => 'Dashboard', 'href' => 'superuser', 'icon' => 'chart-bar'],
|
||||
['label' => 'Validate', 'href' => 'superuser/validate', 'icon' => 'check-circle'],
|
||||
['label' => 'Users', 'href' => 'superuser/users', 'icon' => 'users'],
|
||||
],
|
||||
],
|
||||
];
|
||||
260
app/Views/shared/dashboard_table.php
Normal file
260
app/Views/shared/dashboard_table.php
Normal file
@ -0,0 +1,260 @@
|
||||
<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 & Filters -->
|
||||
<div class="p-4 border-b border-base-200 bg-base-50">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
|
||||
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Status Filters -->
|
||||
<div class="join shadow-sm bg-base-100 rounded-lg">
|
||||
<button @click="filterKey = 'Total'"
|
||||
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Pend'"
|
||||
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Coll'"
|
||||
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Recv'"
|
||||
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'" class="btn btn-sm join-item">
|
||||
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Inc'"
|
||||
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'" class="btn btn-sm join-item">
|
||||
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Fin'"
|
||||
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Validated'"
|
||||
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Date Filter -->
|
||||
<div
|
||||
class="flex flex-col md:flex-row gap-3 items-end 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='fetchList()'><i class='fa fa-search'></i> Search</button>
|
||||
<button class="btn btn-sm btn-neutral" @click='reset()'><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>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<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:8%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:3%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:4%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="i in 5" :key="i">
|
||||
<tr>
|
||||
<td colspan="11">
|
||||
<div class="skeleton h-4 w-full"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<template x-if="!isLoading && !list.length">
|
||||
<div class="text-center py-10">
|
||||
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
|
||||
<p>No records found</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!isLoading && list.length">
|
||||
<table class="table table-xs table-zebra w-full">
|
||||
<thead class="bg-base-100 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th style='width:8%;' @click="sort('REQDATE')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Order Datetime
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;' @click="sort('Name')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Patient Name
|
||||
<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:7%;' @click="sort('SP_ACCESSNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Lab
|
||||
<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:8%;' @click="sort('HOSTORDERNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Register
|
||||
<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:8%;' @click="sort('REFF')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Reff
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:8%;' @click="sort('DOC')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Doctor
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;'>Tests</th>
|
||||
<th style='width:3%;'>ResTo</th>
|
||||
<th style='width:5%;'>Val</th>
|
||||
<th style='width:5%;'>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
|
||||
<tr class="hover:bg-base-300">
|
||||
<td x-text="req.REQDATE"></td>
|
||||
<td x-text="req.Name"></td>
|
||||
<td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.REFF"></td>
|
||||
<td x-text="req.DOC"></td>
|
||||
<td x-text="req.TESTS"></td>
|
||||
<td x-text="req.ODR_CRESULT_TO"></td>
|
||||
<td>
|
||||
<div class='flex gap-1 items-center'>
|
||||
<div class='w-15'>
|
||||
<p>1: <span x-text="req.VAL1USER"></span></p>
|
||||
<p>2: <span x-text="req.VAL2USER"></span></p>
|
||||
</div>
|
||||
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
|
||||
<div class='text-center'>
|
||||
<template
|
||||
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
|
||||
<button class="btn btn-xs btn-outline btn-secondary"
|
||||
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
|
||||
class="fa-solid fa-rotate-right"></i></button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="req.STATS !== 'PartColl' && req.STATS !== 'Coll' && req.STATS !== 'Pend'">
|
||||
<button class="btn btn-xs btn-outline btn-primary"
|
||||
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Control -->
|
||||
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
|
||||
x-show="!isLoading && list.length > 0">
|
||||
<div class="text-xs text-base-content/60">
|
||||
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
|
||||
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
|
||||
<span class="font-bold" x-text="filtered.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="totalPages"></span>
|
||||
</button>
|
||||
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
202
app/Views/shared/dashboard_validate.php
Normal file
202
app/Views/shared/dashboard_validate.php
Normal file
@ -0,0 +1,202 @@
|
||||
<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 & Filters -->
|
||||
<div class="p-4 border-b border-base-200 bg-base-50">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
|
||||
<i class="fa fa-check-circle text-primary"></i> Pending Validation
|
||||
</h2>
|
||||
</div>
|
||||
<div class="badge badge-lg badge-primary">
|
||||
<span x-text="unvalidatedCount"></span> requests
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Filter -->
|
||||
<div class="flex flex-col md:flex-row gap-3 items-end 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">
|
||||
<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: 12%;">
|
||||
<div class="skeleton h-4 w-24"></div>
|
||||
</th>
|
||||
<th style="width: 18%;">
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style="width: 10%;">
|
||||
<div class="skeleton h-4 w-20"></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: 18%;">
|
||||
<div class="skeleton h-4 w-full"></div>
|
||||
</th>
|
||||
<th style="width: 10%;">
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="i in 5" :key="i">
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<div class="skeleton h-4 w-full"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template x-if="!isLoading && !unvalidatedList.length">
|
||||
<div class="text-center py-10">
|
||||
<i class="fa fa-check-circle text-4xl mb-2 opacity-50"></i>
|
||||
<p>All requests have been validated!</p>
|
||||
</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">
|
||||
<tr>
|
||||
<th style="width: 12%;" @click="sort('REQDATE')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Order Datetime
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style="width: 18%;" @click="sort('Name')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Patient Name
|
||||
<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: 10%;" @click="sort('SP_ACCESSNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Lab
|
||||
<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: 12%;" @click="sort('HOSTORDERNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Register
|
||||
<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%;" @click="sort('REFF')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Reff
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style="width: 10%;" @click="sort('DOC')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Doctor
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style="width: 18%;">Tests</th>
|
||||
<th style="width: 10%;">ResTo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="req in unvalidatedPaginated" :key="req.SP_ACCESSNUMBER">
|
||||
<tr class="hover:bg-base-300 cursor-pointer"
|
||||
@click="openValDialog(req.SP_ACCESSNUMBER)"
|
||||
tabindex="0"
|
||||
@keydown.enter="openValDialog(req.SP_ACCESSNUMBER)"
|
||||
@keydown.escape="closeValDialog()">
|
||||
<td x-text="req.REQDATE"></td>
|
||||
<td x-text="req.Name"></td>
|
||||
<td x-text="req.SP_ACCESSNUMBER" class="font-bold"></td>
|
||||
<td x-text="req.HOSTORDERNUMBER" class="font-bold"></td>
|
||||
<td x-text="req.REFF"></td>
|
||||
<td x-text="req.DOC"></td>
|
||||
<td x-text="req.TESTNAMES || req.TESTS"></td>
|
||||
<td x-text="req.ODR_CRESULT_TO"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Control -->
|
||||
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
|
||||
x-show="!isLoading && unvalidatedList.length > 0">
|
||||
<div class="text-xs text-base-content/60">
|
||||
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'); ?>
|
||||
100
app/Views/shared/dialog_sample.php
Normal file
100
app/Views/shared/dialog_sample.php
Normal file
@ -0,0 +1,100 @@
|
||||
<dialog class="modal" :open="isDialogSampleOpen">
|
||||
<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>
|
||||
|
||||
<template x-if="isSampleLoading">
|
||||
<div class="text-center py-10">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="mt-2">Loading data...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!isSampleLoading">
|
||||
<div>
|
||||
<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>
|
||||
<th>Sample Code</th>
|
||||
<th>Sample Name</th>
|
||||
<th class='text-center'>Collected</th>
|
||||
<th class='text-center'>Received</th>
|
||||
<th>Action</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td></td> <td>All</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>
|
||||
<?php if ($config['sampleDialog']['showCollectButtons'] ?? true): ?>
|
||||
<button class="btn btn-sm btn-success px-2 py-1" onclick=""><h6 class="p-0 m-0">Coll.</h6></button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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">
|
||||
<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.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; ?>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</dialog>
|
||||
60
app/Views/shared/dialog_val.php
Normal file
60
app/Views/shared/dialog_val.php
Normal file
@ -0,0 +1,60 @@
|
||||
<dialog class="modal" :open="isDialogValOpen" @keydown.escape="closeValDialog()">
|
||||
<template x-if="valItem">
|
||||
<div class="modal-box w-2/3 max-w-5xl" x-trap.noreturn="isDialogValOpen">
|
||||
<!-- Progress indicator -->
|
||||
<div class="text-sm text-base-content/60 mb-2">
|
||||
<span x-text="currentIndex + 1"></span> / <span x-text="unvalidatedFiltered.length"></span>
|
||||
</div>
|
||||
|
||||
<!-- Request info header -->
|
||||
<div class="bg-base-200 p-3 rounded mb-3">
|
||||
<div class="grid grid-cols-4 gap-2 text-sm">
|
||||
<div>Access#: <span x-text="valItem?.SP_ACCESSNUMBER" class="font-mono font-bold"></span></div>
|
||||
<div>Patient: <span x-text="valItem?.PATNAME || valItem?.Name"></span></div>
|
||||
<div>MRN: <span x-text="valItem?.PATNUMBER?.substring(14) || valItem?.PATNUMBER"></span></div>
|
||||
<div>Tests: <span x-text="(valItem?.TESTS || valItem?.TESTNAMES || '').substring(0,40) + '...'"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h3 class="font-bold text-lg">Validate Request</h3>
|
||||
<button class="btn btn-sm btn-ghost" @click="closeValDialog()" aria-label="Close">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mb-2 flex gap-2">
|
||||
<button id="validate-btn" x-ref="validateBtn" class="btn btn-sm btn-success"
|
||||
@click="validate(valAccessnumber, '<?=session('userid');?>')"
|
||||
:disabled="isValidating"
|
||||
@keydown.enter.prevent="validate(valAccessnumber, '<?=session('userid');?>')"
|
||||
@keydown.tab="focusNext($event)">
|
||||
<i class="fa fa-check"></i> Validate (Enter)
|
||||
</button>
|
||||
<button class="btn btn-sm btn-neutral" @click="skipToNext()" @keydown.tab="focusPrev($event)">
|
||||
<i class="fa fa-arrow-right"></i> Skip (N)
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost" @click="closeValDialog()" @keydown.tab="focusPrev($event)">
|
||||
Close (Esc)
|
||||
</button>
|
||||
</p>
|
||||
<iframe id="result-iframe" x-ref="resultIframe" src="<?=base_url('dummypage');?>" width="100%" height="500px"
|
||||
class="border border-base-300 rounded"></iframe>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<template x-if="isValidating">
|
||||
<div class="absolute inset-0 bg-base-100/80 flex items-center justify-center z-10 rounded-box">
|
||||
<span class="loading loading-spinner loading-lg text-success"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</dialog>
|
||||
|
||||
<!-- Toast notification -->
|
||||
<div x-show="toast.show" x-transition
|
||||
class="alert fixed bottom-4 right-4 z-50"
|
||||
:class="toast.type === 'error' ? 'alert-error' : 'alert-success'">
|
||||
<i :class="toast.type === 'error' ? 'fa fa-times-circle' : 'fa fa-check-circle'"></i>
|
||||
<span x-text="toast.message"></span>
|
||||
</div>
|
||||
@ -5,10 +5,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CMOD</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js"></script>
|
||||
<link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
|
||||
<script src="<?= base_url('css/tailwind.min.js'); ?>"></script>
|
||||
<script src="<?= base_url('js/alpine-focus.min.js'); ?>"></script>
|
||||
<link href="<?= base_url('css/themes.min.css'); ?>" rel="stylesheet" type="text/css" />
|
||||
<script src="<?= base_url('js/fontawesome.min.js'); ?>"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@ -34,7 +35,7 @@
|
||||
<div class='flex-1'>
|
||||
<a class='text-xl text-primary font-bold tracking-wide flex items-center gap-2 ml-2'>
|
||||
<i class="fa fa-cube"></i> CMOD <span class="text-base-content/40 font-light text-sm hidden sm:inline-block">|
|
||||
Lab Analyst Dashboard</span>
|
||||
<?= esc($roleConfig['title'] ?? 'Dashboard') ?></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@ -52,7 +53,10 @@
|
||||
class="fa fa-sign-out-alt mr-2"></i> Logout</a></li>
|
||||
<li><a @click.prevent="openDialogSetPassword()"><i class="fa fa-key mr-2"></i> Change Password</a></li>
|
||||
<div class="divider my-1"></div>
|
||||
<li><a href="<?= base_url('lab') ?>"><i class="fa fa-chart-bar mr-2"></i> Dashboard</a></li>
|
||||
<?php foreach ($roleConfig['menuItems'] ?? [] as $item): ?>
|
||||
<li><a href="<?= base_url($item['href']) ?>"><i class="fa fa-<?= $item['icon'] ?> mr-2"></i>
|
||||
<?= $item['label'] ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -60,7 +64,7 @@
|
||||
|
||||
<!-- Page Content -->
|
||||
<?= $this->renderSection('content'); ?>
|
||||
<?= $this->include('lab/dialog_setPassword'); ?>
|
||||
<?= $this->include('shared/dialog_setPassword'); ?>
|
||||
|
||||
<footer class='bg-base-100 p-1 mt-auto'>© <?= date('Y'); ?> - 5Panda</footer>
|
||||
</div>
|
||||
287
app/Views/shared/script_dashboard.php
Normal file
287
app/Views/shared/script_dashboard.php
Normal file
@ -0,0 +1,287 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("dashboard", () => ({
|
||||
// dashboard
|
||||
today: "",
|
||||
filter: { date1: "", date2: "" },
|
||||
list: [],
|
||||
isLoading: false,
|
||||
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
|
||||
statusColor: {
|
||||
Pend: 'bg-white text-black font-bold',
|
||||
PartColl: 'bg-orange-300 text-white font-bold',
|
||||
Coll: 'bg-orange-500 text-white font-bold',
|
||||
PartRecv: 'bg-blue-200 text-black font-bold',
|
||||
Recv: 'bg-blue-500 text-white font-bold',
|
||||
Inc: 'bg-yellow-500 text-white font-bold',
|
||||
Fin: 'bg-green-500 text-white font-bold',
|
||||
},
|
||||
filterTable: "",
|
||||
filterKey: 'Total',
|
||||
statusMap: {
|
||||
Total: [],
|
||||
Pend: ['Pend'],
|
||||
Coll: ['Coll', 'PartColl'],
|
||||
Recv: ['Recv', 'PartRecv'],
|
||||
Inc: ['Inc'],
|
||||
Fin: ['Fin'],
|
||||
},
|
||||
|
||||
// Sorting & Pagination
|
||||
sortCol: 'REQDATE',
|
||||
sortAsc: false,
|
||||
currentPage: 1,
|
||||
pageSize: 30,
|
||||
|
||||
sort(col) {
|
||||
if (this.sortCol === col) {
|
||||
this.sortAsc = !this.sortAsc;
|
||||
} else {
|
||||
this.sortCol = col;
|
||||
this.sortAsc = true;
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) this.currentPage++;
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) this.currentPage--;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.filtered.length / this.pageSize) || 1;
|
||||
},
|
||||
|
||||
get sorted() {
|
||||
return this.filtered.slice().sort((a, b) => {
|
||||
let modifier = this.sortAsc ? 1 : -1;
|
||||
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
|
||||
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
get paginated() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.sorted.slice(start, end);
|
||||
},
|
||||
|
||||
init() {
|
||||
this.today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Check if running on development workstation (localhost)
|
||||
const isDev = window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1' ||
|
||||
window.location.hostname.includes('.test');
|
||||
|
||||
if (isDev) {
|
||||
// Development: specific date range for test data
|
||||
this.filter.date1 = '2025-01-02';
|
||||
this.filter.date2 = '2025-01-03';
|
||||
} else {
|
||||
// Production: default to today
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
}
|
||||
|
||||
this.$watch('filterTable', () => {
|
||||
this.currentPage = 1;
|
||||
});
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
fetchList() {
|
||||
this.isLoading = true;
|
||||
this.list = [];
|
||||
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
|
||||
let param = new URLSearchParams(this.filter).toString();
|
||||
for (let k in this.counters) { this.counters[k] = 0; }
|
||||
fetch(`${BASEURL}/api/requests?${param}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(res => res.json()).then(data => {
|
||||
this.list = data.data ?? [];
|
||||
this.filterKey = 'Total';
|
||||
this.list.forEach(item => {
|
||||
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
|
||||
else {
|
||||
if (item.STATS == 'PartColl') { this.counters.Coll++; }
|
||||
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
|
||||
this.counters.Total++;
|
||||
}
|
||||
});
|
||||
this.list.sort((a, b) => {
|
||||
let codeA = statusOrder[a.STATS] ?? 0;
|
||||
let codeB = statusOrder[b.STATS] ?? 0;
|
||||
return codeA - codeB;
|
||||
});
|
||||
}).finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
isValidated(item) {
|
||||
return item.ISVAL == 1 && item.ISPENDING != 1;
|
||||
},
|
||||
get filtered() {
|
||||
let filteredList = this.list;
|
||||
if (this.filterKey === 'Validated') {
|
||||
filteredList = filteredList.filter(item => this.isValidated(item));
|
||||
} else {
|
||||
const validStatuses = this.statusMap[this.filterKey];
|
||||
if (validStatuses.length > 0) {
|
||||
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
|
||||
}
|
||||
}
|
||||
if (this.filterTable) {
|
||||
const searchTerm = this.filterTable.toLowerCase();
|
||||
filteredList = filteredList.filter(item =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm)
|
||||
)
|
||||
);
|
||||
}
|
||||
return filteredList;
|
||||
},
|
||||
get validatedCount() {
|
||||
return this.list.filter(r => this.isValidated(r)).length;
|
||||
},
|
||||
|
||||
/*
|
||||
sample dialog
|
||||
*/
|
||||
item: '',
|
||||
isDialogSampleOpen: false,
|
||||
isSampleLoading: false,
|
||||
|
||||
openSampleDialog(accessnumber) {
|
||||
this.isDialogSampleOpen = true;
|
||||
this.fetchItem(accessnumber)
|
||||
},
|
||||
|
||||
closeSampleDialog() {
|
||||
this.isDialogSampleOpen = false;
|
||||
},
|
||||
|
||||
fetchItem(accessnumber) {
|
||||
this.isSampleLoading = true;
|
||||
this.item = [];
|
||||
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
|
||||
.then(res => res.json()).then(data => {
|
||||
this.item = data.data ?? {};
|
||||
if (!Array.isArray(this.item.samples)) this.item.samples = [];
|
||||
}).finally(() => {
|
||||
this.isSampleLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
collect(sampcode, accessnumber) {
|
||||
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
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) {
|
||||
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
preview dialog
|
||||
*/
|
||||
isDialogPreviewOpen: false,
|
||||
reviewed: false,
|
||||
previewItem: null,
|
||||
previewAccessnumber: null,
|
||||
previewType: 'preview',
|
||||
openPreviewDialog(accessnumber, type, item) {
|
||||
this.previewAccessnumber = accessnumber;
|
||||
this.previewItem = item;
|
||||
this.previewType = type;
|
||||
this.isDialogPreviewOpen = true;
|
||||
this.reviewed = false;
|
||||
},
|
||||
closePreviewDialog() {
|
||||
this.isDialogPreviewOpen = false;
|
||||
this.previewItem = null;
|
||||
},
|
||||
setPreviewType(type) {
|
||||
this.previewType = type;
|
||||
},
|
||||
getPreviewUrl() {
|
||||
let base = 'http://glenlis/spooler_db/main_dev.php';
|
||||
let url = `${base}?acc=${this.previewAccessnumber}`;
|
||||
if (this.previewType === 'ind') url += '&lang=ID';
|
||||
if (this.previewType === 'eng') url += '&lang=EN';
|
||||
if (this.previewType === 'pdf') url += '&output=pdf';
|
||||
return url;
|
||||
},
|
||||
validate(accessnumber, userid) {
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}` })
|
||||
}).then(response => {
|
||||
this.closePreviewDialog();
|
||||
this.fetchList();
|
||||
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
unvalidate dialog
|
||||
*/
|
||||
isDialogUnvalOpen: false,
|
||||
unvalReason: '',
|
||||
unvalAccessnumber: null,
|
||||
openUnvalDialog(accessnumber) {
|
||||
this.unvalReason = '';
|
||||
this.isDialogUnvalOpen = true;
|
||||
this.unvalAccessnumber = accessnumber;
|
||||
},
|
||||
unvalidate(accessnumber, userid) {
|
||||
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
|
||||
}).then(response => {
|
||||
this.closeUnvalDialog();
|
||||
this.fetchList();
|
||||
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
|
||||
});
|
||||
},
|
||||
closeUnvalDialog() {
|
||||
this.isDialogUnvalOpen = false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
224
app/Views/shared/script_validate.php
Normal file
224
app/Views/shared/script_validate.php
Normal file
@ -0,0 +1,224 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("validatePage", () => ({
|
||||
// validate page specific
|
||||
unvalidatedList: [],
|
||||
isLoading: false,
|
||||
isValidating: false,
|
||||
|
||||
// Date filter - missing in original code!
|
||||
filter: { date1: "", date2: "" },
|
||||
filterTable: "",
|
||||
|
||||
// Sorting & Pagination (shared with dashboard)
|
||||
sortCol: 'REQDATE',
|
||||
sortAsc: false,
|
||||
currentPage: 1,
|
||||
pageSize: 30,
|
||||
|
||||
sort(col) {
|
||||
if (this.sortCol === col) {
|
||||
this.sortAsc = !this.sortAsc;
|
||||
} else {
|
||||
this.sortCol = col;
|
||||
this.sortAsc = true;
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.unvalidatedTotalPages) this.currentPage++;
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) this.currentPage--;
|
||||
},
|
||||
|
||||
get unvalidatedTotalPages() {
|
||||
return Math.ceil(this.unvalidatedFiltered.length / this.pageSize) || 1;
|
||||
},
|
||||
|
||||
get unvalidatedSorted() {
|
||||
return this.unvalidatedFiltered.slice().sort((a, b) => {
|
||||
let modifier = this.sortAsc ? 1 : -1;
|
||||
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
|
||||
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
get unvalidatedPaginated() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.unvalidatedSorted.slice(start, end);
|
||||
},
|
||||
|
||||
get unvalidatedFiltered() {
|
||||
if (!this.filterTable) return this.unvalidatedList;
|
||||
const searchTerm = this.filterTable.toLowerCase();
|
||||
return this.unvalidatedList.filter(item =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm)
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
get unvalidatedCount() {
|
||||
return this.unvalidatedList.length;
|
||||
},
|
||||
|
||||
init() {
|
||||
this.today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Check if running on development workstation (localhost)
|
||||
const isDev = window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1' ||
|
||||
window.location.hostname.includes('.test');
|
||||
|
||||
if (isDev) {
|
||||
// Development: specific date range for test data
|
||||
this.filter.date1 = '2025-01-02';
|
||||
this.filter.date2 = '2025-01-03';
|
||||
} else {
|
||||
// Production: default to today
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
}
|
||||
|
||||
this.$watch('filterTable', () => {
|
||||
this.currentPage = 1;
|
||||
});
|
||||
|
||||
// Auto-fetch on page load
|
||||
this.fetchUnvalidated();
|
||||
|
||||
// Keyboard shortcuts for dialog
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (this.isDialogValOpen) {
|
||||
// N key - skip to next
|
||||
if (e.key === 'n' || e.key === 'N') {
|
||||
if (!e.target.closest('input, textarea, button')) {
|
||||
e.preventDefault();
|
||||
this.skipToNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
fetchUnvalidated() {
|
||||
this.isLoading = true;
|
||||
this.unvalidatedList = [];
|
||||
let param = new URLSearchParams(this.filter).toString();
|
||||
fetch(`${BASEURL}/api/validate/unvalidated?${param}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(res => res.json()).then(data => {
|
||||
this.unvalidatedList = data.data ?? [];
|
||||
}).finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
resetUnvalidated() {
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.fetchUnvalidated();
|
||||
},
|
||||
|
||||
/*
|
||||
* validate dialog methods
|
||||
*/
|
||||
valAccessnumber: null,
|
||||
valItem: null,
|
||||
currentIndex: 0,
|
||||
isDialogValOpen: false,
|
||||
toast: { show: false, message: '', type: 'success' },
|
||||
|
||||
showToast(message, type = 'success') {
|
||||
this.toast = { show: true, message, type };
|
||||
setTimeout(() => {
|
||||
this.toast.show = false;
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
openValDialogByIndex(index) {
|
||||
const filtered = this.unvalidatedFiltered;
|
||||
if (index < 0 || index >= filtered.length) {
|
||||
this.showToast('No more requests', 'error');
|
||||
this.closeValDialog();
|
||||
return;
|
||||
}
|
||||
const item = filtered[index];
|
||||
this.currentIndex = index;
|
||||
this.valAccessnumber = item.SP_ACCESSNUMBER;
|
||||
this.valItem = item;
|
||||
this.isDialogValOpen = true;
|
||||
// Focus validate button after dialog renders - use setTimeout for reliability
|
||||
setTimeout(() => {
|
||||
const btn = document.getElementById('validate-btn');
|
||||
if (btn) btn.focus();
|
||||
}, 50);
|
||||
},
|
||||
|
||||
openValDialog(accessnumber) {
|
||||
// Find index by accessnumber
|
||||
const filtered = this.unvalidatedFiltered;
|
||||
const index = filtered.findIndex(item => item.SP_ACCESSNUMBER === accessnumber);
|
||||
if (index !== -1) {
|
||||
this.openValDialogByIndex(index);
|
||||
} else {
|
||||
this.openValDialogByIndex(0);
|
||||
}
|
||||
},
|
||||
|
||||
closeValDialog() {
|
||||
this.isDialogValOpen = false;
|
||||
this.valAccessnumber = null;
|
||||
this.valItem = null;
|
||||
},
|
||||
|
||||
skipToNext() {
|
||||
const nextIndex = (this.currentIndex + 1) % this.unvalidatedFiltered.length;
|
||||
this.closeValDialog();
|
||||
// Use setTimeout for reliable focus after dialog re-renders
|
||||
setTimeout(() => this.openValDialogByIndex(nextIndex), 50);
|
||||
},
|
||||
|
||||
validate(accessnumber, userid) {
|
||||
if (!confirm(`Validate request ${accessnumber}?`)) return;
|
||||
this.isValidating = true;
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}` })
|
||||
}).then(response => response.json()).then(data => {
|
||||
// Show toast
|
||||
this.showToast(`Validated: ${accessnumber}`);
|
||||
|
||||
// Remove validated item from local list
|
||||
this.unvalidatedList = this.unvalidatedList.filter(
|
||||
item => item.SP_ACCESSNUMBER !== accessnumber
|
||||
);
|
||||
|
||||
// Auto-advance to next request
|
||||
const filteredLength = this.unvalidatedFiltered.length;
|
||||
if (filteredLength > 0) {
|
||||
const nextIndex = Math.min(this.currentIndex, filteredLength - 1);
|
||||
this.closeValDialog();
|
||||
// Use setTimeout for reliable focus after dialog re-renders
|
||||
setTimeout(() => this.openValDialogByIndex(nextIndex), 50);
|
||||
} else {
|
||||
this.closeValDialog();
|
||||
this.showToast('All requests validated!');
|
||||
}
|
||||
|
||||
if (data.message && data.message.includes('already validate')) {
|
||||
alert(data.message);
|
||||
}
|
||||
}).catch(() => {
|
||||
this.showToast('Validation failed', 'error');
|
||||
}).finally(() => {
|
||||
this.isValidating = false;
|
||||
});
|
||||
},
|
||||
}));
|
||||
});
|
||||
@ -1,45 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogPreviewOpen">
|
||||
<template x-if="previewAccessnumber">
|
||||
<div class="modal-box w-11/12 max-w-7xl h-[90vh] flex flex-col p-0 overflow-hidden bg-base-100">
|
||||
<!-- Header -->
|
||||
<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-eye text-primary"></i>
|
||||
Preview
|
||||
<span class="badge badge-ghost text-xs" x-text="previewAccessnumber"></span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="join shadow-sm">
|
||||
<button @click="previewType = 'preview'" :class="previewType === 'preview' ? 'btn-active btn-neutral text-white' : 'btn-ghost'" class="btn btn-sm join-item">Preview</button>
|
||||
<button @click="setPreviewType('ind')" :class="previewType === 'ind' ? 'btn-active btn-neutral text-white' : 'btn-ghost'" class="btn btn-sm join-item">IND</button>
|
||||
<button @click="setPreviewType('eng')" :class="previewType === 'eng' ? 'btn-active btn-neutral text-white' : 'btn-ghost'" class="btn btn-sm join-item">ENG</button>
|
||||
<button @click="setPreviewType('pdf')" :class="previewType === 'pdf' ? 'btn-active btn-neutral text-white' : 'btn-ghost'" class="btn btn-sm join-item">PDF</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" @click="closePreviewDialog()">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 bg-base-300 relative p-1">
|
||||
<iframe id="preview-iframe" x-ref="previewIframe" :src="getPreviewUrl()" class="w-full h-full rounded shadow-sm bg-white"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-3 bg-base-200 border-t border-base-300 flex justify-end items-center gap-4">
|
||||
<label class="label cursor-pointer gap-2 mb-0">
|
||||
<input type="checkbox" x-model="reviewed" class="checkbox checkbox-sm checkbox-primary" />
|
||||
<span class="label-text text-sm">I have reviewed the results</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-ghost" @click="closePreviewDialog()">Cancel</button>
|
||||
<button id="validate-btn" x-ref="validateBtn" class="btn btn-sm btn-success"
|
||||
@click="validate(previewAccessnumber, '<?=session('userid');?>')" :disabled="!reviewed">
|
||||
<i class="fa fa-check mr-1"></i> Validate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</dialog>
|
||||
@ -1,99 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogSampleOpen">
|
||||
<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>
|
||||
|
||||
<template x-if="isSampleLoading">
|
||||
<div class="text-center py-10">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="mt-2">Loading data...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!isSampleLoading">
|
||||
<div>
|
||||
<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"></textarea>
|
||||
<button class="btn btn-sm btn-primary mt-2" @click="saveComment(item.accessnumber)">Save</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="table table-xs table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sample Code</th>
|
||||
<th>Sample Name</th>
|
||||
<th class='text-center'>Collected</th>
|
||||
<th class='text-center'>Received</th>
|
||||
<th>Action</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<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>
|
||||
<tr>
|
||||
<td></td> <td>All</td> <td></td> <td></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary px-2 py-1"><i class="fa-solid fa-print"></i></i></button>
|
||||
<button class="btn btn-sm btn-success px-2 py-1" onclick=""><h6 class="p-0 m-0">Coll.</h6></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>
|
||||
<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.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>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</dialog>
|
||||
@ -1,28 +0,0 @@
|
||||
<dialog class="modal" :open="isDialogSetPasswordOpen">
|
||||
<div class="modal-box w-96">
|
||||
<h3 class="font-bold text-lg mb-4">Change Password</h3>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">New Password</span>
|
||||
</label>
|
||||
<input type="password" x-model="password" class="input input-bordered w-full" placeholder="Enter new password" />
|
||||
</div>
|
||||
<div class="form-control w-full mt-3">
|
||||
<label class="label">
|
||||
<span class="label-text">Confirm Password</span>
|
||||
</label>
|
||||
<input type="password" x-model="confirm_password" class="input input-bordered w-full" placeholder="Confirm new password" />
|
||||
</div>
|
||||
<div x-show="error" class="alert alert-error mt-3 text-sm">
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" @click="closeDialogSetPassword()">Cancel</button>
|
||||
<button class="btn btn-primary" @click="savePassword('<?=session('userid'); ?>')" :disabled="isLoading">
|
||||
<span x-show="isLoading" class="loading loading-spinner loading-sm"></span>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop bg-black/30" @click="closeDialogSetPassword()"></div>
|
||||
</dialog>
|
||||
@ -1,571 +1,18 @@
|
||||
<?= $this->extend('superuser/main'); ?>
|
||||
<?= $this->extend('shared/layout_dashboard'); ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<?= $this->section('content'); ?>
|
||||
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
|
||||
<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 & Filters -->
|
||||
<div class="p-4 border-b border-base-200 bg-base-50">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
|
||||
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Status Filters -->
|
||||
<div class="join shadow-sm bg-base-100 rounded-lg">
|
||||
<button @click="filterKey = 'Total'"
|
||||
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Pend'"
|
||||
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Coll'"
|
||||
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Recv'"
|
||||
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Inc'"
|
||||
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Fin'"
|
||||
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
|
||||
</button>
|
||||
<button @click="filterKey = 'Validated'"
|
||||
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
|
||||
class="btn btn-sm join-item">
|
||||
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Date Filter -->
|
||||
<div
|
||||
class="flex flex-col md:flex-row gap-3 items-end 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='fetchList()'><i class='fa fa-search'></i> Search</button>
|
||||
<button class="btn btn-sm btn-neutral" @click='reset()'><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>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<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:7%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:7%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:8%;'>
|
||||
<div class="skeleton h-4 w-20"></div>
|
||||
</th>
|
||||
<th style='width:15%;'>
|
||||
<div class="skeleton h-4 w-32"></div>
|
||||
</th>
|
||||
<th style='width:3%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:5%;'>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</th>
|
||||
<th style='width:4%;'>
|
||||
<div class="skeleton h-4 w-12"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="i in 5" :key="i">
|
||||
<tr>
|
||||
<td colspan="11">
|
||||
<div class="skeleton h-4 w-full"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<template x-if="!isLoading && !list.length">
|
||||
<div class="text-center py-10">
|
||||
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
|
||||
<p>No records found</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!isLoading && list.length">
|
||||
<table class="table table-xs table-zebra w-full">
|
||||
<thead class="bg-base-100 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th style='width:7%;' @click="sort('REQDATE')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Order Datetime
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;' @click="sort('Name')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Patient Name
|
||||
<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:7%;' @click="sort('SP_ACCESSNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Lab
|
||||
<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:7%;' @click="sort('HOSTORDERNUMBER')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
No Register
|
||||
<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:8%;' @click="sort('REFF')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Reff
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:8%;' @click="sort('DOC')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Doctor
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th style='width:15%;'>Tests</th>
|
||||
<th style='width:3%;'>ResTo</th>
|
||||
<th style='width:5%;'>Val</th>
|
||||
<th style='width:5%;'>Result</th>
|
||||
<th style='width:4%;' @click="sort('STATS')"
|
||||
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
|
||||
<div class="flex items-center gap-1">
|
||||
Status
|
||||
<i class="fa text-xs"
|
||||
:class="sortCol === 'STATS' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody>
|
||||
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
|
||||
<tr class="hover:bg-base-300">
|
||||
<td x-text="req.REQDATE"></td>
|
||||
<td x-text="req.Name"></td>
|
||||
<td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
|
||||
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
<td x-text="req.REFF"></td>
|
||||
<td x-text="req.DOC"></td>
|
||||
<td x-text="req.TESTS"></td>
|
||||
<td x-text="req.ODR_CRESULT_TO"></td>
|
||||
<td>
|
||||
<div class='flex gap-1 items-center'>
|
||||
<div class='w-15'>
|
||||
<p>1: <span x-text="req.VAL1USER"></span></p>
|
||||
<p>2: <span x-text="req.VAL2USER"></span></p>
|
||||
</div>
|
||||
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
|
||||
<div class='text-center'>
|
||||
<template
|
||||
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
|
||||
<button class="btn btn-xs btn-outline btn-secondary"
|
||||
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
|
||||
class="fa-solid fa-rotate-right"></i></button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="req.STATS !== 'PartColl' && req.STATS !== 'Coll' && req.STATS !== 'Pend'">
|
||||
<button class="btn btn-xs btn-outline btn-primary"
|
||||
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
|
||||
</template>
|
||||
</td>
|
||||
<td x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="font-bold cursor-pointer"
|
||||
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Control -->
|
||||
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
|
||||
x-show="!isLoading && list.length > 0">
|
||||
<div class="text-xs text-base-content/60">
|
||||
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
|
||||
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
|
||||
<span class="font-bold" x-text="filtered.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="totalPages"></span>
|
||||
</button>
|
||||
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<?php echo $this->include('superuser/dialog_sample'); ?>
|
||||
<?php echo $this->include('superuser/dialog_unval'); ?>
|
||||
<?php echo $this->include('superuser/dialog_preview'); ?>
|
||||
|
||||
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
|
||||
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
|
||||
<?= $this->include('shared/dialog_unval'); ?>
|
||||
<?= $this->include('shared/dialog_preview'); ?>
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section('script') ?>
|
||||
<?= $this->section('script'); ?>
|
||||
<script type="module">
|
||||
import Alpine from '<?= base_url("js/app.js"); ?>';
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data("dashboard", () => ({
|
||||
// dashboard
|
||||
today: "",
|
||||
filter: { date1: "", date2: "" },
|
||||
list: [],
|
||||
isLoading: false,
|
||||
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
|
||||
statusColor: {
|
||||
Pend: 'bg-white text-black font-bold',
|
||||
PartColl: 'bg-orange-300 text-white font-bold',
|
||||
Coll: 'bg-orange-500 text-white font-bold',
|
||||
PartRecv: 'bg-blue-200 text-black font-bold',
|
||||
Recv: 'bg-blue-500 text-white font-bold',
|
||||
Inc: 'bg-yellow-500 text-white font-bold',
|
||||
Fin: 'bg-green-500 text-white font-bold',
|
||||
},
|
||||
filterTable: "",
|
||||
filterKey: 'Total',
|
||||
filterKey: 'Total',
|
||||
statusMap: {
|
||||
Total: [],
|
||||
Pend: ['Pend'],
|
||||
Coll: ['Coll', 'PartColl'],
|
||||
Recv: ['Recv', 'PartRecv'],
|
||||
Inc: ['Inc'],
|
||||
Fin: ['Fin'],
|
||||
},
|
||||
|
||||
// Sorting & Pagination
|
||||
sortCol: 'REQDATE',
|
||||
sortAsc: false,
|
||||
currentPage: 1,
|
||||
pageSize: 30,
|
||||
|
||||
sort(col) {
|
||||
if (this.sortCol === col) {
|
||||
this.sortAsc = !this.sortAsc;
|
||||
} else {
|
||||
this.sortCol = col;
|
||||
this.sortAsc = true;
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) this.currentPage++;
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) this.currentPage--;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.filtered.length / this.pageSize) || 1;
|
||||
},
|
||||
|
||||
get sorted() {
|
||||
return this.filtered.slice().sort((a, b) => {
|
||||
let modifier = this.sortAsc ? 1 : -1;
|
||||
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
|
||||
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
get paginated() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.sorted.slice(start, end);
|
||||
},
|
||||
|
||||
init() {
|
||||
this.today = new Date().toISOString().slice(0, 10);
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.$watch('filterTable', () => {
|
||||
this.currentPage = 1;
|
||||
});
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
fetchList() {
|
||||
this.isLoading = true;
|
||||
this.list = [];
|
||||
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
|
||||
let param = new URLSearchParams(this.filter).toString();
|
||||
for (let k in this.counters) { this.counters[k] = 0; }
|
||||
fetch(`${BASEURL}/api/requests?${param}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(res => res.json()).then(data => {
|
||||
this.list = data.data ?? [];
|
||||
this.filterKey = 'Total';
|
||||
this.list.forEach(item => {
|
||||
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
|
||||
else {
|
||||
if (item.STATS == 'PartColl') { this.counters.Coll++; }
|
||||
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
|
||||
this.counters.Total++;
|
||||
}
|
||||
});
|
||||
this.list.sort((a, b) => {
|
||||
let codeA = statusOrder[a.STATS] ?? 0;
|
||||
let codeB = statusOrder[b.STATS] ?? 0;
|
||||
return codeA - codeB;
|
||||
});
|
||||
}).finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.filter.date1 = this.today;
|
||||
this.filter.date2 = this.today;
|
||||
this.fetchList();
|
||||
},
|
||||
|
||||
isValidated(item) {
|
||||
return item.ISVAL == 1 && item.ISPENDING != 1;
|
||||
},
|
||||
get filtered() {
|
||||
// Reset pagination when filter changes (implied by this getter being accessed if dependencies change)
|
||||
// However, side-effects in getters are tricky.
|
||||
// Better to just let the user navigate back, or watch variables.
|
||||
// For now, let's keep it pure.
|
||||
let filteredList = this.list;
|
||||
if (this.filterKey === 'Validated') {
|
||||
filteredList = filteredList.filter(item => this.isValidated(item));
|
||||
} else {
|
||||
const validStatuses = this.statusMap[this.filterKey];
|
||||
if (validStatuses.length > 0) {
|
||||
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
|
||||
}
|
||||
}
|
||||
if (this.filterTable) {
|
||||
const searchTerm = this.filterTable.toLowerCase();
|
||||
filteredList = filteredList.filter(item =>
|
||||
Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchTerm)
|
||||
)
|
||||
);
|
||||
}
|
||||
return filteredList;
|
||||
},
|
||||
get validatedCount() {
|
||||
return this.list.filter(r => this.isValidated(r)).length;
|
||||
},
|
||||
|
||||
/*
|
||||
sample dialog
|
||||
*/
|
||||
item: '',
|
||||
isDialogSampleOpen: false,
|
||||
isSampleLoading: false,
|
||||
|
||||
openSampleDialog(accessnumber) {
|
||||
this.isDialogSampleOpen = true;
|
||||
this.fetchItem(accessnumber)
|
||||
},
|
||||
|
||||
closeSampleDialog() {
|
||||
this.isDialogSampleOpen = false;
|
||||
},
|
||||
|
||||
fetchItem(accessnumber) {
|
||||
this.isSampleLoading = true;
|
||||
this.item = [];
|
||||
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
|
||||
.then(res => res.json()).then(data => {
|
||||
this.item = data.data ?? {};
|
||||
if (!Array.isArray(this.item.samples)) this.item.samples = [];
|
||||
}).finally(() => {
|
||||
this.isSampleLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
collect(sampcode, accessnumber) {
|
||||
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
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) {
|
||||
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
|
||||
})
|
||||
.then(res => res.json()).then(data => {
|
||||
this.fetchItem(accessnumber);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
preview dialog
|
||||
*/
|
||||
isDialogPreviewOpen: false,
|
||||
reviewed: false,
|
||||
previewItem: null,
|
||||
openPreviewDialog(accessnumber, type, item) {
|
||||
this.previewAccessnumber = accessnumber;
|
||||
this.previewItem = item;
|
||||
this.previewType = type;
|
||||
this.isDialogPreviewOpen = true;
|
||||
this.reviewed = false;
|
||||
},
|
||||
closePreviewDialog() {
|
||||
this.isDialogPreviewOpen = false;
|
||||
this.previewItem = null;
|
||||
},
|
||||
setPreviewType(type) {
|
||||
this.previewType = type;
|
||||
},
|
||||
getPreviewUrl() {
|
||||
let base = 'http://glenlis/spooler_db/main_dev.php';
|
||||
let url = `${base}?acc=${this.previewAccessnumber}`;
|
||||
if (this.previewType === 'ind') url += '&lang=ID';
|
||||
if (this.previewType === 'eng') url += '&lang=EN';
|
||||
if (this.previewType === 'pdf') url += '&output=pdf';
|
||||
|
||||
// Keep fallback for local dev if needed, but the above is the expected logic
|
||||
// return "http://localhost/application.html";
|
||||
return url;
|
||||
},
|
||||
validate(accessnumber, userid) {
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}` })
|
||||
}).then(response => {
|
||||
this.closePreviewDialog();
|
||||
this.fetchList();
|
||||
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
unvalidate dialog
|
||||
*/
|
||||
isDialogUnvalOpen: false,
|
||||
unvalReason: '',
|
||||
unvalAccessnumber: null,
|
||||
openUnvalDialog(accessnumber) {
|
||||
this.unvalReason = '';
|
||||
this.isDialogUnvalOpen = true;
|
||||
this.unvalAccessnumber = accessnumber;
|
||||
},
|
||||
unvalidate(accessnumber, userid) {
|
||||
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
|
||||
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
|
||||
}).then(response => {
|
||||
this.closeUnvalDialog();
|
||||
this.fetchList();
|
||||
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
|
||||
});
|
||||
},
|
||||
closeUnvalDialog() {
|
||||
this.isDialogUnvalOpen = false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
<?= $this->include('shared/script_dashboard'); ?>
|
||||
Alpine.start();
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
@ -1,75 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="corporate">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CMOD</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
font-size: 0.71rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 0.2rem 1rem;
|
||||
min-height: 0rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
font-size: 0.71rem !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-base-200 min-h-screen" x-data="main">
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar bg-base-100 shadow-md px-6 z-20">
|
||||
<div class='flex-1'>
|
||||
<a class='text-xl text-primary font-bold tracking-wide flex items-center gap-2 ml-2'>
|
||||
<i class="fa fa-cube"></i> CMOD <span class="text-base-content/40 font-light text-sm hidden sm:inline-block">|
|
||||
Superuser Dashboard</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right hidden sm:block leading-tight">
|
||||
<div class="text-sm font-bold opacity-70">Hi, <?= session('userid'); ?></div>
|
||||
<div class="text-xs opacity-50"><?= session()->get('userrole') ?></div>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost avatar placeholder px-2">
|
||||
<span class="text-xl"><i class="fa fa-bars"></i></span>
|
||||
</div>
|
||||
<ul tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300">
|
||||
<li><a href="<?= base_url('logout') ?>" class="text-error hover:bg-error/10"><i
|
||||
class="fa fa-sign-out-alt mr-2"></i> Logout</a></li>
|
||||
<li><a @click.prevent="openDialogSetPassword()"><i class="fa fa-key mr-2"></i> Change Password</a></li>
|
||||
<div class="divider my-1"></div>
|
||||
<li><a href="<?= base_url('superuser') ?>"><i class="fa fa-chart-bar mr-2"></i> Dashboard</a></li>
|
||||
<li><a href="<?= base_url('superuser/users') ?>"><i class="fa fa-users mr-2"></i> Users</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page Content -->
|
||||
<?= $this->renderSection('content'); ?>
|
||||
<?= $this->include('superuser/dialog_setPassword'); ?>
|
||||
|
||||
<footer class='bg-base-100 p-1 mt-auto'>© <?= date('Y'); ?> - 5Panda</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.BASEURL = "<?= base_url(); ?>";
|
||||
</script>
|
||||
<?= $this->renderSection('script'); ?>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,4 +1,4 @@
|
||||
<?= $this->extend('superuser/main'); ?>
|
||||
<?= $this->extend('shared/layout_dashboard'); ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<div x-data="users" class="contents">
|
||||
@ -286,6 +286,24 @@
|
||||
}
|
||||
},
|
||||
|
||||
async deleteUser(userid) {
|
||||
if (!confirm(`Are you sure you want to delete user ${userid}?`)) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const res = await fetch(`${BASEURL}/api/users/${userid}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || 'Error deleting user');
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.fetchUsers();
|
||||
}
|
||||
},
|
||||
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
19
app/Views/superuser/validate.php
Normal file
19
app/Views/superuser/validate.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
$config = include __DIR__ . '/../shared/dashboard_config.php';
|
||||
$roleConfig = $config['superuser'];
|
||||
?>
|
||||
<?= $this->extend('shared/layout_dashboard', ['roleConfig' => $roleConfig]); ?>
|
||||
|
||||
<?= $this->section('content'); ?>
|
||||
<main class="p-4 flex-1 flex flex-col gap-2" x-data="validatePage">
|
||||
<?= $this->include('shared/dashboard_validate', ['config' => $roleConfig]); ?>
|
||||
</main>
|
||||
<?= $this->endSection(); ?>
|
||||
|
||||
<?= $this->section('script'); ?>
|
||||
<script type="module">
|
||||
import Alpine from '<?= base_url("js/app.js"); ?>';
|
||||
<?= $this->include('shared/script_validate'); ?>
|
||||
Alpine.start();
|
||||
</script>
|
||||
<?= $this->endSection(); ?>
|
||||
@ -1,25 +0,0 @@
|
||||
Notulensi Meeting Glen 16 Januari 2026
|
||||
|
||||
1. Mas rizqi menjadi penanggung jawab IT Glen
|
||||
2. glenlis cmod baru :
|
||||
a. Tampilan dashboard untuk pasien klinik harus punya dashboard sendiri
|
||||
b. Jangan tampilkan no lab pada dashboard pasien
|
||||
c. Sort table belum berfungsi optimal
|
||||
d. Baris tetap dibuat berwarna seperti sebelumnya pada tampilan dashboard
|
||||
e. Tombol preview memang tidak menampilkan apapun jika belum ada datanya
|
||||
f. Tombol Eng dan Ind belum berfungsi secara maksimal saat lihat laporan
|
||||
g. Yang generate PDF adalah LAB lalu yang bisa print/cetak ke PDF adalah CS
|
||||
h. Jika sudah di validasi 2 akun, maka bisa dilihat/print oleh CS
|
||||
i. Unvalidate hanya bisa dilakukan oleh dokter dan bu yani untuk pemeriksaan yang Mencapai incomplete
|
||||
j. Setelah di validasi tombol validasi harus hilang
|
||||
k. Dalam pdf harus ada keterangan Finish validasi dari user 1 atau 2 untuk tiap sample(?) dan tulisan "Printed By" diganti jadi ... user yg mevalidasi
|
||||
l. Uncollect dipakai dulu
|
||||
m. Minta ada history Detail Unvalidated
|
||||
n. Glen mau buat RME Sendiri
|
||||
|
||||
RME :
|
||||
o. Menu Rancangan RME glen (dashboard, patient, hasil lab, validation, unrecheived, report, sample collection, user management)
|
||||
p. Untuk penyatuan menu hasil dan validasi - Mengunggu Pihak Manajemen
|
||||
q. Ditambahkan menu baru pada RME glen yaitu unvalidate
|
||||
r. Ubah level Role user pada Users(ada di excel meeting) (superuser, admin, lab, phlebo, cs)
|
||||
s. print label untuk cs masih dipertanyakan enaknnya gimana
|
||||
1
public/css/daisyui.min.css
vendored
Normal file
1
public/css/daisyui.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
public/css/tailwind.min.js
vendored
Normal file
7
public/css/tailwind.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/css/themes.min.css
vendored
Normal file
1
public/css/themes.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
15
public/js/alpine-focus.min.js
vendored
Normal file
15
public/js/alpine-focus.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
public/js/fontawesome.min.js
vendored
Normal file
6
public/js/fontawesome.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user