Refactor: Consolidate duplicate dashboard views into shared components

- Created shared dashboard components in app/Views/shared/:
  - dashboard_config.php, dashboard_table.php, dashboard_validate.php
  - dialog_sample.php, dialog_val.php, script_dashboard.php, script_validate.php
  - layout_dashboard.php
- Removed duplicate views from role-specific directories (admin, cs, lab, phlebo, superuser)
- Consolidated 575-line duplicate index.php files into shared components
- Updated controllers to use new shared view structure
- Added ApiValidateController for validation endpoints
- Reduced code duplication across 5 role-based dashboards

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
mahdahar 2026-01-22 17:02:47 +07:00
parent 7ee5332edf
commit 33ccb976cc
66 changed files with 1747 additions and 4206 deletions

3
.gitignore vendored
View File

@ -125,4 +125,5 @@ _modules/*
/results/ /results/
/phpunit*.xml /phpunit*.xml
.venv/ .claude/
.serena/

View File

@ -1,50 +1,34 @@
# Project Checklist: Glen RME & Lab Management System # 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 - Restrict 'UnValidate' to Admin
- Prevent redundant validation actions - Restrict Print/Save-to-PDF to CS Role only (Lab can only preview, CS can print/save)
- [ ] **T-003:** Restrict Print/Save-to-PDF to CS Role only - Create 'Detail Unvalidated' History Log/View (Log unvalidation actions with timestamp, user ID, and reason)
- Lab can only preview, CS can print/save - Enhanced Patient Detail Logging (Track: Sample Collection Time, Sample Received Time, Print History)
- [X] **T-004:** Update User Role levels - Add Dedicated Print Button (Trigger browser/system print dialog)
- Standardize roles: Superuser, Admin, Lab, Phlebo, CS - Add Error Handling for Preview Button (Handle empty data gracefully)
- [X] **T-005:** Role-Based Dashboard Filtering - Ensure 'Uncollect' Feature Functional (Maintain Uncollect feature functionality)
- Filter by patient_status or service_type (Klinik+Lab vs Lab Only) - Backend Performance & Connectivity (Investigate intermittent connection issues with Server 253)
- [ ] **T-006:** Create Clinical Patients Dashboard - Update PDF Report Metadata (Replace 'Printed By' with validating user's name)
- 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
--- 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 Addition on dev :
- adding init-isDev on index.php to set default date on dev dashboard
- Check items as you complete them
- Refer to PROJECT_BACKLOG.md for detailed technical specifications

View File

@ -61,7 +61,7 @@ class Filters extends BaseFilters
'after' => [ 'after' => [
'pagecache', // Web Page Caching 'pagecache', // Web Page Caching
'performance', // Performance Metrics 'performance', // Performance Metrics
'toolbar', // Debug Toolbar #'toolbar', // Debug Toolbar
], ],
]; ];

View File

@ -39,6 +39,11 @@ $routes->group('api', function ($routes) {
$routes->delete('validate/(:any)', 'RequestsController::unval/$1'); $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 // Samples
$routes->group('samples', function ($routes) { $routes->group('samples', function ($routes) {
// Collect & Show - All Roles // Collect & Show - All Roles
@ -62,15 +67,18 @@ $routes->group('api', function ($routes) {
$routes->group('superuser', ['filter' => 'role:0'], function ($routes) { $routes->group('superuser', ['filter' => 'role:0'], function ($routes) {
$routes->get('', 'Pages\SuperuserController::index'); $routes->get('', 'Pages\SuperuserController::index');
$routes->get('users', 'Pages\SuperuserController::users'); $routes->get('users', 'Pages\SuperuserController::users');
$routes->get('validate', 'Pages\SuperuserController::validatePage');
}); });
$routes->group('admin', ['filter' => 'role:1'], function ($routes) { $routes->group('admin', ['filter' => 'role:1'], function ($routes) {
$routes->get('', 'Pages\AdminController::index'); $routes->get('', 'Pages\AdminController::index');
$routes->get('users', 'Pages\AdminController::users'); $routes->get('users', 'Pages\AdminController::users');
$routes->get('validate', 'Pages\AdminController::validationPage');
}); });
$routes->group('lab', ['filter' => 'role:2'], function ($routes) { $routes->group('lab', ['filter' => 'role:2'], function ($routes) {
$routes->get('', 'Pages\LabController::index'); $routes->get('', 'Pages\LabController::index');
$routes->get('validate', 'Pages\LabController::validationPage');
}); });
$routes->group('phlebo', ['filter' => 'role:3'], function ($routes) { $routes->group('phlebo', ['filter' => 'role:3'], function ($routes) {

View 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]);
}
}

View File

@ -11,11 +11,18 @@ class AdminController extends BaseController {
} }
public function index() { 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() { 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']]);
} }
} }

View File

@ -11,7 +11,8 @@ class CsController extends BaseController {
} }
public function index() { public function index() {
return view('cs/index'); $config = require APPPATH . 'Views/shared/dashboard_config.php';
return view('cs/index', ['roleConfig' => $config['cs']]);
} }
} }

View File

@ -11,7 +11,13 @@ class LabController extends BaseController {
} }
public function index() { 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']]);
} }
} }

View File

@ -11,7 +11,8 @@ class PhlebotomistController extends BaseController {
} }
public function index() { public function index() {
return view('phlebo/index'); $config = require APPPATH . 'Views/shared/dashboard_config.php';
return view('phlebo/index', ['roleConfig' => $config['phlebo']]);
} }
} }

View File

@ -4,18 +4,30 @@ namespace App\Controllers\Pages;
use App\Controllers\BaseController; use App\Controllers\BaseController;
class SuperuserController extends BaseController { class SuperuserController extends BaseController
{
public function __construct() { public function __construct()
{
helper(['url', 'form', 'text']); helper(['url', 'form', 'text']);
} }
public function index() { public function index()
return view('superuser/index'); {
$config = require APPPATH . 'Views/shared/dashboard_config.php';
return view('superuser/index', ['roleConfig' => $config['superuser']]);
} }
public function users() { public function users()
return view('superuser/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']]);
} }
} }

View File

@ -12,10 +12,20 @@ class RequestsController extends BaseController
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$date1 = $this->request->getGet('date1'); $date1 = $this->request->getGet('date1');
$date2 = $this->request->getGet('date2'); $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 $sql = "SELECT * from GDC_CMOD.dbo.V_DASHBOARD_DEV where
COLLECTIONDATE between '$date1 00:00' and '$date2 23:59' COLLECTIONDATE between '$date1 00:00' and '$date2 23:59'
and ODR_DDATE between '$date1 00:00' and '$date2 23:59'"; and ODR_DDATE between '$date1 00:00' and '$date2 23:59'";
}
$rows = $db->query($sql)->getResultArray(); $rows = $db->query($sql)->getResultArray();
foreach ($rows as &$row) { foreach ($rows as &$row) {
$row['COLLECTIONDATE'] = date('Y-m-d H:i', strtotime($row['COLLECTIONDATE'])); $row['COLLECTIONDATE'] = date('Y-m-d H:i', strtotime($row['COLLECTIONDATE']));

View File

@ -29,10 +29,18 @@ class UsersController extends BaseController
public function create() public function create()
{ {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
$userid = $input['userid']; if (!$input) {
$userroleid = $input['userroleid']; return $this->fail('Invalid JSON input');
$password = $input['password']; }
$password_2 = $input['password_2'];
$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) { if ($password != $password_2) {
return $this->response->setJSON(['message' => 'Password not the same']); return $this->response->setJSON(['message' => 'Password not the same']);
@ -70,11 +78,19 @@ class UsersController extends BaseController
public function update($id = null) public function update($id = null)
{ {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
$userid = $input['userid']; if (!$input) {
$username = $input['username']; return $this->fail('Invalid JSON input');
$userroleid = $input['userroleid']; }
$password = $input['password'];
$password_2 = $input['password_2']; $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 != '') {
if ($password != $password_2) { if ($password != $password_2) {

View File

@ -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>

View File

@ -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>

View File

@ -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"> <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"> <?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
<div class="card-body p-0 h-full flex flex-col"> <?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?>
<!-- Header & Filters --> <?= $this->include('shared/dialog_preview'); ?>
<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'); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script') ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_dashboard'); ?>
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;
},
}));
});
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -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'>&copy; <?= date('Y'); ?> - 5Panda</footer>
</div>
<script>
window.BASEURL = "<?= base_url(); ?>";
</script>
<?= $this->renderSection('script'); ?>
</body>
</html>

View 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(); ?>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"> <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"> <?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
<div class="card-body p-0 h-full flex flex-col"> <?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?>
<!-- Header & Filters --> <?= $this->include('shared/dialog_preview'); ?>
<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'); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script') ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_dashboard'); ?>
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;
},
}));
});
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -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'>&copy; <?= date('Y'); ?> - 5Panda</footer>
</div>
<script>
window.BASEURL = "<?= base_url(); ?>";
</script>
<?= $this->renderSection('script'); ?>
</body>
</html>

128
app/Views/dummy_page.php Normal file
View 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">&#128064;</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>&#128172; Did You Know?</h3>
<p id="fact">Loading random science fact...</p>
</div>
<div class="shrug">&#128633; &#129472; &#129469;</div>
<div class="buttons">
<a href="javascript:history.back()" class="btn">Go Back</a>
<a href="/gdc_cmod/" class="btn">Home</a>
</div>
</div>
<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>

View File

@ -1,62 +1,38 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="corporate">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Not Found!</title> <title>Not Found</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.9.6/lottie.min.js"></script> <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> <style>
.error-container { body {
display: flex; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
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;
} }
</style> </style>
</head> </head>
<body> <body class="bg-base-200 min-h-screen flex items-center justify-center p-4">
<div class="error-container"> <div class="card bg-base-100 shadow-xl w-full max-w-md text-center">
<div class="lottie-animation"></div> <div class="card-body items-center">
<div class="error-content"> <div class="text-base-content/20 text-8xl mb-2">
<h1>404</h1> <i class="fa fa-ghost"></i>
<p>Oops! Halaman yang anda cari tidak tersedia.</p> </div>
<a href="/" class="btn btn-primary">Kembali</a> <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>
</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> </body>
</html> </html>

View File

@ -1,73 +1,34 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="corporate">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unauthorized!</title> <title>Unauthorized</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <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> <style>
.error-page { body {
min-height: 100vh; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
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;
} }
</style> </style>
</head> </head>
<body> <body class="bg-base-200 min-h-screen flex items-center justify-center p-4">
<div class="error-page d-flex align-items-center justify-content-center"> <div class="card bg-base-100 shadow-xl w-full max-w-md text-center">
<div class="error-container text-center p-4"> <div class="card-body items-center">
<h1 class="error-code mb-0">401</h1> <div class="text-error/80 text-8xl mb-2">
<h2 class="display-6 error-message mb-3">Akses Ditolak✋</h2> <i class="fa fa-lock"></i>
<p class="lead error-message mb-5">Anda tidak punya izin untuk mengakses halaman ini.</p> </div>
<div class="d-flex justify-content-center gap-3"> <h1 class="text-4xl font-bold text-base-content mb-1">401</h1>
<a href="/" class="btn btn-glass px-4 py-2">Kembali</a> <h2 class="text-xl font-semibold mb-2">Access Denied</h2>
<!-- <a href="#" class="btn btn-glass px-4 py-2">Report Problem</a> --> <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> </div>
</div> </div>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"> <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"> <?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
<div class="card-body p-0 h-full flex flex-col"> <?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?>
<!-- Header & Filters --> <?= $this->include('shared/dialog_preview'); ?>
<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'); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script') ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_dashboard'); ?>
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;
},
}));
});
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View 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(); ?>

View File

@ -1,14 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="corporate"> <html lang="en" data-theme="corporate">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - CMOD</title> <title>Login - CMOD</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" /> <link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <script src="<?= base_url('css/tailwind.min.js'); ?>"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" /> <link href="<?= base_url('css/themes.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js"></script> <script src="<?= base_url('js/fontawesome.min.js'); ?>"></script>
</head> </head>
<body class="min-h-screen flex items-center justify-center bg-base-200"> <body class="min-h-screen flex items-center justify-center bg-base-200">
<div class="w-full max-w-sm mx-auto"> <div class="w-full max-w-sm mx-auto">
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
@ -45,4 +47,5 @@
<div class="text-center mt-6 text-xs text-base-content/40">&copy; 2025 - 5Panda. All rights reserved.</div> <div class="text-center mt-6 text-xs text-base-content/40">&copy; 2025 - 5Panda. All rights reserved.</div>
</div> </div>
</body> </body>
</html> </html>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"> <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"> <?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
<div class="card-body p-0 h-full flex flex-col"> <?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?>
<!-- Header & Filters --> <?= $this->include('shared/dialog_preview'); ?>
<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'); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script') ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_dashboard'); ?>
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;
},
}));
});
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -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'>&copy; <?= date('Y'); ?> - 5Panda</footer>
</div>
<script>
window.BASEURL = "<?= base_url(); ?>";
</script>
<?= $this->renderSection('script'); ?>
</body>
</html>

View 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'],
],
],
];

View 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>

View 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'); ?>

View 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>

View 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>

View File

@ -5,10 +5,11 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CMOD</title> <title>CMOD</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" /> <link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <script src="<?= base_url('css/tailwind.min.js'); ?>"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" /> <script src="<?= base_url('js/alpine-focus.min.js'); ?>"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.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> <style>
body { body {
margin: 0; margin: 0;
@ -34,7 +35,7 @@
<div class='flex-1'> <div class='flex-1'>
<a class='text-xl text-primary font-bold tracking-wide flex items-center gap-2 ml-2'> <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">| <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> </a>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@ -52,7 +53,10 @@
class="fa fa-sign-out-alt mr-2"></i> Logout</a></li> 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> <li><a @click.prevent="openDialogSetPassword()"><i class="fa fa-key mr-2"></i> Change Password</a></li>
<div class="divider my-1"></div> <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> </ul>
</div> </div>
</div> </div>
@ -60,7 +64,7 @@
<!-- Page Content --> <!-- Page Content -->
<?= $this->renderSection('content'); ?> <?= $this->renderSection('content'); ?>
<?= $this->include('lab/dialog_setPassword'); ?> <?= $this->include('shared/dialog_setPassword'); ?>
<footer class='bg-base-100 p-1 mt-auto'>&copy; <?= date('Y'); ?> - 5Panda</footer> <footer class='bg-base-100 p-1 mt-auto'>&copy; <?= date('Y'); ?> - 5Panda</footer>
</div> </div>

View 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;
},
}));
});

View 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;
});
},
}));
});

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"> <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"> <?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
<div class="card-body p-0 h-full flex flex-col"> <?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?>
<!-- Header & Filters --> <?= $this->include('shared/dialog_preview'); ?>
<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'); ?>
</main> </main>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>
<?= $this->section('script') ?> <?= $this->section('script'); ?>
<script type="module"> <script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>'; import Alpine from '<?= base_url("js/app.js"); ?>';
<?= $this->include('shared/script_dashboard'); ?>
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;
},
}));
});
Alpine.start(); Alpine.start();
</script> </script>
<?= $this->endSection(); ?> <?= $this->endSection(); ?>

View File

@ -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'>&copy; <?= date('Y'); ?> - 5Panda</footer>
</div>
<script>
window.BASEURL = "<?= base_url(); ?>";
</script>
<?= $this->renderSection('script'); ?>
</body>
</html>

View File

@ -1,4 +1,4 @@
<?= $this->extend('superuser/main'); ?> <?= $this->extend('shared/layout_dashboard'); ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>
<div x-data="users" class="contents"> <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();
}
},
})); }));
}); });

View 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(); ?>

View File

@ -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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long