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/
/phpunit*.xml
.venv/
.claude/
.serena/

View File

@ -1,50 +1,34 @@
# Project Checklist: Glen RME & Lab Management System
**Last Updated:** January 21, 2026
**Last Updated:** January 22, 2026
---
Pending:
- [ ] **T-002:** Hide/Disable 'Validation' button after 2nd validation
- Prevent redundant validation actions
- [ ] **T-003:** Restrict Print/Save-to-PDF to CS Role only
- Lab can only preview, CS can print/save
- [X] **T-004:** Update User Role levels
- Standardize roles: Superuser, Admin, Lab, Phlebo, CS
- [X] **T-005:** Role-Based Dashboard Filtering
- Filter by patient_status or service_type (Klinik+Lab vs Lab Only)
- [ ] **T-006:** Create Clinical Patients Dashboard
- Hide "No Lab" column for clinical workflows
- [X] **T-007:** Fix Table Sorting
- Enable sorting by "No Register" and "Patient Name"
- [X] **T-008:** Fix Language Toggle (ID/EN)
- Toggle lab result preview between Indonesian and English
- [X] **T-009:** Apply Row Color-Coding
- Color-code "No Register" column (Yellow/Blue/Green)
- [X] **T-011:** Initialize RME Sidebar Menu
- Create menu items: Dashboard, Patient, Hasil Lab, Validation, Unreceived, Report, Sample Collection, User Management, Unvalidate
- [ ] **T-012:** Create 'Detail Unvalidated' History Log/View
- Log unvalidation actions with timestamp, user ID, and reason
- [ ] **T-013:** Enhanced Patient Detail Logging
- Track: Sample Collection Time, Sample Received Time, Print History
- [ ] **T-014:** Add Dedicated Print Button
- Trigger browser/system print dialog
- [ ] **T-015:** Add Error Handling for Preview Button
- Handle empty data gracefully
- [ ] **T-016:** Ensure 'Uncollect' Feature Functional
- Maintain Uncollect feature functionality
- [ ] **T-017:** Backend Performance & Connectivity
- Investigate intermittent connection issues with Server 253
- Plan SSD upgrade for database server
- Verify API integration: GDC_cmod, GDC_CS2, Report2
- [X] **T-018: Delayed** Dashboard Performance
- When getting data more than 100 rows, it load too slow.
- [ ] **T-010:** Update PDF Report Metadata
- Replace 'Printed By' with validating user's name
- Add 'Finish Validation' status per sample
- Restrict 'UnValidate' to Admin
- Restrict Print/Save-to-PDF to CS Role only (Lab can only preview, CS can print/save)
- Create 'Detail Unvalidated' History Log/View (Log unvalidation actions with timestamp, user ID, and reason)
- Enhanced Patient Detail Logging (Track: Sample Collection Time, Sample Received Time, Print History)
- Add Dedicated Print Button (Trigger browser/system print dialog)
- Add Error Handling for Preview Button (Handle empty data gracefully)
- Ensure 'Uncollect' Feature Functional (Maintain Uncollect feature functionality)
- Backend Performance & Connectivity (Investigate intermittent connection issues with Server 253)
- Update PDF Report Metadata (Replace 'Printed By' with validating user's name)
---
Completed:
- 01 : Update User Role levels (Standardize roles: Superuser, Admin, Lab, Phlebo, CS)
- 02 : Role-Based Dashboard Filtering (Filter by patient_status or service_type)
- 03 : Fix Table Sorting (Enable sorting by "No Register" and "Patient Name")
- 04 : Fix Language Toggle (Toggle lab result preview between Indonesian and English)
- 05 : Apply Row Color-Coding (Color-code "No Register" column)
- 06 : Initialize RME Sidebar Menu (Create menu items)
- 07 : Dashboard Performance (When getting data more than 100 rows, it load too slow)
- 08 : Dashboard for Lab -> no test with only number, remove request with empty test
- 09 : Dashboard for Others -> complete
- 10 : Refactor same views/*role* to views/shared
- 11 : Move all CDN to local
- 12 : Remove 'status' field on dashboard
- 13 : Restrict 'Validate' to Lab, Admin, Superuser
- 14 : Hide/Disable 'Validation' button after 2nd validation (Prevent redundant validation actions)
## Legend
- Check items as you complete them
- Refer to PROJECT_BACKLOG.md for detailed technical specifications
Addition on dev :
- adding init-isDev on index.php to set default date on dev dashboard

View File

@ -61,7 +61,7 @@ class Filters extends BaseFilters
'after' => [
'pagecache', // Web Page Caching
'performance', // Performance Metrics
'toolbar', // Debug Toolbar
#'toolbar', // Debug Toolbar
],
];
@ -77,7 +77,7 @@ class Filters extends BaseFilters
public array $globals = [
'before' => [
// 'honeypot',
// 'csrf',
//'csrf',
// 'invalidchars',
],
'after' => [

View File

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

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() {
return view('admin/index');
$config = require APPPATH . 'Views/shared/dashboard_config.php';
return view('admin/index', ['roleConfig' => $config['admin']]);
}
public function users() {
return view('admin/users');
$config = require APPPATH . 'Views/shared/dashboard_config.php';
return view('admin/users', ['roleConfig' => $config['admin']]);
}
public function validationPage() {
$config = require APPPATH . 'Views/shared/dashboard_config.php';
return view('admin/validate', ['roleConfig' => $config['admin']]);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -12,10 +12,20 @@ class RequestsController extends BaseController
$db = \Config\Database::connect();
$date1 = $this->request->getGet('date1');
$date2 = $this->request->getGet('date2');
$userroleid = session()->get('userroleid');
// Only allow Lab role (role 2)
if ($userroleid == 2) {
$sql = "SELECT * from GDC_CMOD.dbo.V_DASHBOARD_DEV where
COLLECTIONDATE between '$date1 00:00' and '$date2 23:59'
and ODR_DDATE between '$date1 00:00' and '$date2 23:59'
and (TESTS IS NOT NULL AND TESTS like '%[A-Za-z]%')";
} else {
$sql = "SELECT * from GDC_CMOD.dbo.V_DASHBOARD_DEV where
COLLECTIONDATE between '$date1 00:00' and '$date2 23:59'
and ODR_DDATE between '$date1 00:00' and '$date2 23:59'";
}
$rows = $db->query($sql)->getResultArray();
foreach ($rows as &$row) {
$row['COLLECTIONDATE'] = date('Y-m-d H:i', strtotime($row['COLLECTIONDATE']));

View File

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

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">
<div class="card bg-base-100 shadow-xl h-full border border-base-200 overflow-hidden">
<div class="card-body p-0 h-full flex flex-col">
<!-- Header & Filters -->
<div class="p-4 border-b border-base-200 bg-base-50">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
<div class="flex-1">
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
</h2>
</div>
<!-- Status Filters -->
<div class="join shadow-sm bg-base-100 rounded-lg">
<button @click="filterKey = 'Total'"
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
</button>
<button @click="filterKey = 'Pend'"
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
</button>
<button @click="filterKey = 'Coll'"
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
</button>
<button @click="filterKey = 'Recv'"
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
</button>
<button @click="filterKey = 'Inc'"
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
</button>
<button @click="filterKey = 'Fin'"
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
</button>
<button @click="filterKey = 'Validated'"
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
</button>
</div>
</div>
<!-- Search & Date Filter -->
<div
class="flex flex-col md:flex-row gap-3 items-end bg-base-100 p-3 rounded-lg border border-base-200 shadow-sm">
<div class="form-control">
<label class="label text-xs font-bold py-1 text-base-content/60">Date Range</label>
<div class="join">
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date1" />
<span class="join-item btn btn-sm btn-ghost no-animation bg-base-200 font-normal px-2">-</span>
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date2" />
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-primary" @click='fetchList()'><i class='fa fa-search'></i> Search</button>
<button class="btn btn-sm btn-neutral" @click='reset()'><i class='fa fa-sync-alt'></i> Reset</button>
</div>
<span class="flex-1"></span>
<div class="form-control w-full md:w-auto">
<label class='input input-sm input-bordered'>
<i class="fa fa-filter"></i>
<input type="text" placeholder="Type to filter..." x-model="filterTable" />
</label>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 pb-4">
<template x-if="isLoading">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;'>
<div class="skeleton h-4 w-20"></div>
</th>
<th style='width:15%;'>
<div class="skeleton h-4 w-32"></div>
</th>
<th style='width:7%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:7%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:8%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:8%;'>
<div class="skeleton h-4 w-20"></div>
</th>
<th style='width:15%;'>
<div class="skeleton h-4 w-32"></div>
</th>
<th style='width:3%;'>
<div class="skeleton h-4 w-12"></div>
</th>
<th style='width:5%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:5%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:4%;'>
<div class="skeleton h-4 w-12"></div>
</th>
</tr>
</thead>
<tbody>
<template x-for="i in 5" :key="i">
<tr>
<td colspan="11">
<div class="skeleton h-4 w-full"></div>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="!isLoading && !list.length">
<div class="text-center py-10">
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
<p>No records found</p>
</div>
</template>
<template x-if="!isLoading && list.length">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;' @click="sort('REQDATE')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Order Datetime
<i class="fa text-xs"
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:15%;' @click="sort('Name')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Patient Name
<i class="fa text-xs"
:class="sortCol === 'Name' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:7%;' @click="sort('SP_ACCESSNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
No Lab
<i class="fa text-xs"
:class="sortCol === 'SP_ACCESSNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:7%;' @click="sort('HOSTORDERNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
No Register
<i class="fa text-xs"
:class="sortCol === 'HOSTORDERNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:8%;' @click="sort('REFF')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Reff
<i class="fa text-xs"
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:8%;' @click="sort('DOC')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Doctor
<i class="fa text-xs"
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:15%;'>Tests</th>
<th style='width:3%;'>ResTo</th>
<th style='width:5%;'>Val</th>
<th style='width:5%;'>Result</th>
<th style='width:4%;' @click="sort('STATS')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Status
<i class="fa text-xs"
:class="sortCol === 'STATS' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
</tr>
</thead>
<tbody>
<tbody>
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
<tr class="hover:bg-base-300">
<td x-text="req.REQDATE"></td>
<td x-text="req.Name"></td>
<td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
<td x-text="req.REFF"></td>
<td x-text="req.DOC"></td>
<td x-text="req.TESTS"></td>
<td x-text="req.ODR_CRESULT_TO"></td>
<td>
<div class='flex gap-1 items-center'>
<div class='w-15'>
<p>1: <span x-text="req.VAL1USER"></span></p>
<p>2: <span x-text="req.VAL2USER"></span></p>
</div>
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
<div class='text-center'>
<template
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
<button class="btn btn-xs btn-outline btn-secondary"
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
class="fa-solid fa-rotate-right"></i></button>
</template>
</div>
</template>
</div>
</td>
<td>
<template x-if="req.STATS !== 'PartColl' && req.STATS !== 'Coll' && req.STATS !== 'Pend'">
<button class="btn btn-xs btn-outline btn-primary"
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
</template>
</td>
<td x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="font-bold cursor-pointer"
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Pagination Control -->
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
x-show="!isLoading && list.length > 0">
<div class="text-xs text-base-content/60">
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
<span class="font-bold" x-text="filtered.length"></span> entries
</div>
<div class="join">
<button class="join-item btn btn-sm" @click="prevPage()" :disabled="currentPage === 1">
<i class="fa fa-chevron-left"></i>
</button>
<button class="join-item btn btn-sm no-animation bg-base-100 cursor-default">
Page <span x-text="currentPage"></span> / <span x-text="totalPages"></span>
</button>
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
</div>
<?php echo $this->include('admin/dialog_sample'); ?>
<?php echo $this->include('admin/dialog_unval'); ?>
<?php echo $this->include('admin/dialog_preview'); ?>
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?>
<?= $this->include('shared/dialog_preview'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section('script') ?>
<?= $this->section('script'); ?>
<script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>';
document.addEventListener('alpine:init', () => {
Alpine.data("dashboard", () => ({
// dashboard
today: "",
filter: { date1: "", date2: "" },
list: [],
isLoading: false,
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
statusColor: {
Pend: 'bg-white text-black font-bold',
PartColl: 'bg-orange-300 text-white font-bold',
Coll: 'bg-orange-500 text-white font-bold',
PartRecv: 'bg-blue-200 text-black font-bold',
Recv: 'bg-blue-500 text-white font-bold',
Inc: 'bg-yellow-500 text-white font-bold',
Fin: 'bg-green-500 text-white font-bold',
},
filterTable: "",
filterKey: 'Total',
filterKey: 'Total',
statusMap: {
Total: [],
Pend: ['Pend'],
Coll: ['Coll', 'PartColl'],
Recv: ['Recv', 'PartRecv'],
Inc: ['Inc'],
Fin: ['Fin'],
},
// Sorting & Pagination
sortCol: 'REQDATE',
sortAsc: false,
currentPage: 1,
pageSize: 30,
sort(col) {
if (this.sortCol === col) {
this.sortAsc = !this.sortAsc;
} else {
this.sortCol = col;
this.sortAsc = true;
}
},
nextPage() {
if (this.currentPage < this.totalPages) this.currentPage++;
},
prevPage() {
if (this.currentPage > 1) this.currentPage--;
},
get totalPages() {
return Math.ceil(this.filtered.length / this.pageSize) || 1;
},
get sorted() {
return this.filtered.slice().sort((a, b) => {
let modifier = this.sortAsc ? 1 : -1;
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
return 0;
});
},
get paginated() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.sorted.slice(start, end);
},
init() {
this.today = new Date().toISOString().slice(0, 10);
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.$watch('filterTable', () => {
this.currentPage = 1;
});
this.fetchList();
},
fetchList() {
this.isLoading = true;
this.list = [];
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
let param = new URLSearchParams(this.filter).toString();
for (let k in this.counters) { this.counters[k] = 0; }
fetch(`${BASEURL}/api/requests?${param}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}).then(res => res.json()).then(data => {
this.list = data.data ?? [];
this.filterKey = 'Total';
this.list.forEach(item => {
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
else {
if (item.STATS == 'PartColl') { this.counters.Coll++; }
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
this.counters.Total++;
}
});
this.list.sort((a, b) => {
let codeA = statusOrder[a.STATS] ?? 0;
let codeB = statusOrder[b.STATS] ?? 0;
return codeA - codeB;
});
}).finally(() => {
this.isLoading = false;
});
},
reset() {
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.fetchList();
},
isValidated(item) {
return item.ISVAL == 1 && item.ISPENDING != 1;
},
get filtered() {
// Reset pagination when filter changes (implied by this getter being accessed if dependencies change)
// However, side-effects in getters are tricky.
// Better to just let the user navigate back, or watch variables.
// For now, let's keep it pure.
let filteredList = this.list;
if (this.filterKey === 'Validated') {
filteredList = filteredList.filter(item => this.isValidated(item));
} else {
const validStatuses = this.statusMap[this.filterKey];
if (validStatuses.length > 0) {
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
}
}
if (this.filterTable) {
const searchTerm = this.filterTable.toLowerCase();
filteredList = filteredList.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
return filteredList;
},
get validatedCount() {
return this.list.filter(r => this.isValidated(r)).length;
},
/*
sample dialog
*/
item: '',
isDialogSampleOpen: false,
isSampleLoading: false,
openSampleDialog(accessnumber) {
this.isDialogSampleOpen = true;
this.fetchItem(accessnumber)
},
closeSampleDialog() {
this.isDialogSampleOpen = false;
},
fetchItem(accessnumber) {
this.isSampleLoading = true;
this.item = [];
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
.then(res => res.json()).then(data => {
this.item = data.data ?? {};
if (!Array.isArray(this.item.samples)) this.item.samples = [];
}).finally(() => {
this.isSampleLoading = false;
});
},
collect(sampcode, accessnumber) {
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
uncollect(sampcode, accessnumber) {
if (!confirm(`Uncollect sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
unreceive(sampcode, accessnumber) {
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
/*
preview dialog
*/
isDialogPreviewOpen: false,
reviewed: false,
previewItem: null,
openPreviewDialog(accessnumber, type, item) {
this.previewAccessnumber = accessnumber;
this.previewItem = item;
this.previewType = type;
this.isDialogPreviewOpen = true;
this.reviewed = false;
},
closePreviewDialog() {
this.isDialogPreviewOpen = false;
this.previewItem = null;
},
setPreviewType(type) {
this.previewType = type;
},
getPreviewUrl() {
let base = 'http://glenlis/spooler_db/main_dev.php';
let url = `${base}?acc=${this.previewAccessnumber}`;
if (this.previewType === 'ind') url += '&lang=ID';
if (this.previewType === 'eng') url += '&lang=EN';
if (this.previewType === 'pdf') url += '&output=pdf';
// Keep fallback for local dev if needed, but the above is the expected logic
// return "http://localhost/application.html";
return url;
},
validate(accessnumber, userid) {
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}` })
}).then(response => {
this.closePreviewDialog();
this.fetchList();
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
});
},
/*
unvalidate dialog
*/
isDialogUnvalOpen: false,
unvalReason: '',
unvalAccessnumber: null,
openUnvalDialog(accessnumber) {
this.unvalReason = '';
this.isDialogUnvalOpen = true;
this.unvalAccessnumber = accessnumber;
},
unvalidate(accessnumber, userid) {
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
}).then(response => {
this.closeUnvalDialog();
this.fetchList();
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
});
},
closeUnvalDialog() {
this.isDialogUnvalOpen = false;
},
}));
});
<?= $this->include('shared/script_dashboard'); ?>
Alpine.start();
</script>
<?= $this->endSection(); ?>

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">
<div class="card bg-base-100 shadow-xl h-full border border-base-200 overflow-hidden">
<div class="card-body p-0 h-full flex flex-col">
<!-- Header & Filters -->
<div class="p-4 border-b border-base-200 bg-base-50">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
<div class="flex-1">
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
</h2>
</div>
<!-- Status Filters -->
<div class="join shadow-sm bg-base-100 rounded-lg">
<button @click="filterKey = 'Total'"
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
</button>
<button @click="filterKey = 'Pend'"
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
</button>
<button @click="filterKey = 'Coll'"
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
</button>
<button @click="filterKey = 'Recv'"
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
</button>
<button @click="filterKey = 'Inc'"
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
</button>
<button @click="filterKey = 'Fin'"
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
</button>
<button @click="filterKey = 'Validated'"
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
</button>
</div>
</div>
<!-- Search & Date Filter -->
<div
class="flex flex-col md:flex-row gap-3 items-end bg-base-100 p-3 rounded-lg border border-base-200 shadow-sm">
<div class="form-control">
<label class="label text-xs font-bold py-1 text-base-content/60">Date Range</label>
<div class="join">
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date1" />
<span class="join-item btn btn-sm btn-ghost no-animation bg-base-200 font-normal px-2">-</span>
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date2" />
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-primary" @click='fetchList()'><i class='fa fa-search'></i> Search</button>
<button class="btn btn-sm btn-neutral" @click='reset()'><i class='fa fa-sync-alt'></i> Reset</button>
</div>
<span class="flex-1"></span>
<div class="form-control w-full md:w-auto">
<label class='input input-sm input-bordered'>
<i class="fa fa-filter"></i>
<input type="text" placeholder="Type to filter..." x-model="filterTable" />
</label>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 pb-4">
<template x-if="isLoading">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;'>
<div class="skeleton h-4 w-20"></div>
</th>
<th style='width:15%;'>
<div class="skeleton h-4 w-32"></div>
</th>
<th style='width:7%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:7%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:8%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:8%;'>
<div class="skeleton h-4 w-20"></div>
</th>
<th style='width:15%;'>
<div class="skeleton h-4 w-32"></div>
</th>
<th style='width:3%;'>
<div class="skeleton h-4 w-12"></div>
</th>
<th style='width:5%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:5%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:4%;'>
<div class="skeleton h-4 w-12"></div>
</th>
</tr>
</thead>
<tbody>
<template x-for="i in 5" :key="i">
<tr>
<td colspan="11">
<div class="skeleton h-4 w-full"></div>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="!isLoading && !list.length">
<div class="text-center py-10">
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
<p>No records found</p>
</div>
</template>
<template x-if="!isLoading && list.length">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;' @click="sort('REQDATE')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Order Datetime
<i class="fa text-xs"
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:15%;' @click="sort('Name')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Patient Name
<i class="fa text-xs"
:class="sortCol === 'Name' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<!-- <th style='width:7%;' @click="sort('SP_ACCESSNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
No Lab
<i class="fa text-xs"
:class="sortCol === 'SP_ACCESSNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th> -->
<th style='width:7%;' @click="sort('HOSTORDERNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
No Register
<i class="fa text-xs"
:class="sortCol === 'HOSTORDERNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:8%;' @click="sort('REFF')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Reff
<i class="fa text-xs"
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:8%;' @click="sort('DOC')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Doctor
<i class="fa text-xs"
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:15%;'>Tests</th>
<th style='width:3%;'>ResTo</th>
<th style='width:5%;'>Val</th>
<th style='width:5%;'>Result</th>
<th style='width:4%;' @click="sort('STATS')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Status
<i class="fa text-xs"
:class="sortCol === 'STATS' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
</tr>
</thead>
<tbody>
<tbody>
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
<tr class="hover:bg-base-300">
<td x-text="req.REQDATE"></td>
<td x-text="req.Name"></td>
<!-- <td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td> -->
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
<td x-text="req.REFF"></td>
<td x-text="req.DOC"></td>
<td x-text="req.TESTS"></td>
<td x-text="req.ODR_CRESULT_TO"></td>
<td>
<div class='flex gap-1 items-center'>
<div class='w-15'>
<p>1: <span x-text="req.VAL1USER"></span></p>
<p>2: <span x-text="req.VAL2USER"></span></p>
</div>
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
<div class='text-center'>
<template
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
<button class="btn btn-xs btn-outline btn-secondary"
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
class="fa-solid fa-rotate-right"></i></button>
</template>
</div>
</template>
</div>
</td>
<td>
<template x-if="req.VAL1USER && req.VAL2USER">
<button class="btn btn-xs btn-outline btn-primary"
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
</template>
</td>
<td x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="font-bold cursor-pointer"
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Pagination Control -->
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
x-show="!isLoading && list.length > 0">
<div class="text-xs text-base-content/60">
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
<span class="font-bold" x-text="filtered.length"></span> entries
</div>
<div class="join">
<button class="join-item btn btn-sm" @click="prevPage()" :disabled="currentPage === 1">
<i class="fa fa-chevron-left"></i>
</button>
<button class="join-item btn btn-sm no-animation bg-base-100 cursor-default">
Page <span x-text="currentPage"></span> / <span x-text="totalPages"></span>
</button>
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
</div>
<?php echo $this->include('cs/dialog_sample'); ?>
<?php echo $this->include('cs/dialog_unval'); ?>
<?php echo $this->include('cs/dialog_preview'); ?>
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?>
<?= $this->include('shared/dialog_preview'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section('script') ?>
<?= $this->section('script'); ?>
<script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>';
document.addEventListener('alpine:init', () => {
Alpine.data("dashboard", () => ({
// dashboard
today: "",
filter: { date1: "", date2: "" },
list: [],
isLoading: false,
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
statusColor: {
Pend: 'bg-white text-black font-bold',
PartColl: 'bg-orange-300 text-white font-bold',
Coll: 'bg-orange-500 text-white font-bold',
PartRecv: 'bg-blue-200 text-black font-bold',
Recv: 'bg-blue-500 text-white font-bold',
Inc: 'bg-yellow-500 text-white font-bold',
Fin: 'bg-green-500 text-white font-bold',
},
filterTable: "",
filterKey: 'Total',
filterKey: 'Total',
statusMap: {
Total: [],
Pend: ['Pend'],
Coll: ['Coll', 'PartColl'],
Recv: ['Recv', 'PartRecv'],
Inc: ['Inc'],
Fin: ['Fin'],
},
// Sorting & Pagination
sortCol: 'REQDATE',
sortAsc: false,
currentPage: 1,
pageSize: 30,
sort(col) {
if (this.sortCol === col) {
this.sortAsc = !this.sortAsc;
} else {
this.sortCol = col;
this.sortAsc = true;
}
},
nextPage() {
if (this.currentPage < this.totalPages) this.currentPage++;
},
prevPage() {
if (this.currentPage > 1) this.currentPage--;
},
get totalPages() {
return Math.ceil(this.filtered.length / this.pageSize) || 1;
},
get sorted() {
return this.filtered.slice().sort((a, b) => {
let modifier = this.sortAsc ? 1 : -1;
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
return 0;
});
},
get paginated() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.sorted.slice(start, end);
},
init() {
this.today = new Date().toISOString().slice(0, 10);
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.$watch('filterTable', () => {
this.currentPage = 1;
});
this.fetchList();
},
fetchList() {
this.isLoading = true;
this.list = [];
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
let param = new URLSearchParams(this.filter).toString();
for (let k in this.counters) { this.counters[k] = 0; }
fetch(`${BASEURL}/api/requests?${param}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}).then(res => res.json()).then(data => {
this.list = data.data ?? [];
this.filterKey = 'Total';
this.list.forEach(item => {
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
else {
if (item.STATS == 'PartColl') { this.counters.Coll++; }
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
this.counters.Total++;
}
});
this.list.sort((a, b) => {
let codeA = statusOrder[a.STATS] ?? 0;
let codeB = statusOrder[b.STATS] ?? 0;
return codeA - codeB;
});
}).finally(() => {
this.isLoading = false;
});
},
reset() {
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.fetchList();
},
isValidated(item) {
return item.ISVAL == 1 && item.ISPENDING != 1;
},
get filtered() {
// Reset pagination when filter changes (implied by this getter being accessed if dependencies change)
// However, side-effects in getters are tricky.
// Better to just let the user navigate back, or watch variables.
// For now, let's keep it pure.
let filteredList = this.list;
if (this.filterKey === 'Validated') {
filteredList = filteredList.filter(item => this.isValidated(item));
} else {
const validStatuses = this.statusMap[this.filterKey];
if (validStatuses.length > 0) {
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
}
}
if (this.filterTable) {
const searchTerm = this.filterTable.toLowerCase();
filteredList = filteredList.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
return filteredList;
},
get validatedCount() {
return this.list.filter(r => this.isValidated(r)).length;
},
/*
sample dialog
*/
item: '',
isDialogSampleOpen: false,
isSampleLoading: false,
openSampleDialog(accessnumber) {
this.isDialogSampleOpen = true;
this.fetchItem(accessnumber)
},
closeSampleDialog() {
this.isDialogSampleOpen = false;
},
fetchItem(accessnumber) {
this.isSampleLoading = true;
this.item = [];
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
.then(res => res.json()).then(data => {
this.item = data.data ?? {};
if (!Array.isArray(this.item.samples)) this.item.samples = [];
}).finally(() => {
this.isSampleLoading = false;
});
},
collect(sampcode, accessnumber) {
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
uncollect(sampcode, accessnumber) {
if (!confirm(`Uncollect sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
unreceive(sampcode, accessnumber) {
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
/*
preview dialog
*/
isDialogPreviewOpen: false,
reviewed: false,
previewItem: null,
openPreviewDialog(accessnumber, type, item) {
this.previewAccessnumber = accessnumber;
this.previewItem = item;
this.previewType = type;
this.isDialogPreviewOpen = true;
this.reviewed = false;
},
closePreviewDialog() {
this.isDialogPreviewOpen = false;
this.previewItem = null;
},
setPreviewType(type) {
this.previewType = type;
},
getPreviewUrl() {
let base = 'http://glenlis/spooler_db/main_dev.php';
let url = `${base}?acc=${this.previewAccessnumber}`;
if (this.previewType === 'ind') url += '&lang=ID';
if (this.previewType === 'eng') url += '&lang=EN';
if (this.previewType === 'pdf') url += '&output=pdf';
// Keep fallback for local dev if needed, but the above is the expected logic
// return "http://localhost/application.html";
return url;
},
validate(accessnumber, userid) {
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}` })
}).then(response => {
this.closePreviewDialog();
this.fetchList();
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
});
},
/*
unvalidate dialog
*/
isDialogUnvalOpen: false,
unvalReason: '',
unvalAccessnumber: null,
openUnvalDialog(accessnumber) {
this.unvalReason = '';
this.isDialogUnvalOpen = true;
this.unvalAccessnumber = accessnumber;
},
unvalidate(accessnumber, userid) {
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
}).then(response => {
this.closeUnvalDialog();
this.fetchList();
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
});
},
closeUnvalDialog() {
this.isDialogUnvalOpen = false;
},
}));
});
<?= $this->include('shared/script_dashboard'); ?>
Alpine.start();
</script>
<?= $this->endSection(); ?>

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>
<html lang="en">
<html lang="en" data-theme="corporate">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Not Found!</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.9.6/lottie.min.js"></script>
<title>Not Found</title>
<link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="<?= base_url('js/tailwind.min.js'); ?>"></script>
<link href="<?= base_url('css/themes.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="<?= base_url('js/fontawesome.min.js'); ?>"></script>
<style>
.error-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f8f9fa;
}
.error-content {
text-align: center;
}
.error-content h1 {
font-size: 6rem;
font-weight: bold;
margin-bottom: 1rem;
}
.error-content p {
font-size: 1.5rem;
margin-bottom: 2rem;
}
.lottie-animation {
max-width: 400px;
margin-bottom: 2rem;
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
</style>
</head>
<body>
<div class="error-container">
<div class="lottie-animation"></div>
<div class="error-content">
<h1>404</h1>
<p>Oops! Halaman yang anda cari tidak tersedia.</p>
<a href="/" class="btn btn-primary">Kembali</a>
<body class="bg-base-200 min-h-screen flex items-center justify-center p-4">
<div class="card bg-base-100 shadow-xl w-full max-w-md text-center">
<div class="card-body items-center">
<div class="text-base-content/20 text-8xl mb-2">
<i class="fa fa-ghost"></i>
</div>
<h1 class="text-4xl font-bold text-base-content mb-1">404</h1>
<h2 class="text-xl font-semibold mb-2">Page Not Found</h2>
<p class="text-base-content/70 mb-6">Oops! The page you are looking for does not exist or has been moved.
</p>
<div class="card-actions">
<a href="<?= base_url() ?>" class="btn btn-primary btn-sm">
<i class="fa fa-arrow-left mr-2"></i> Back to Dashboard
</a>
</div>
</div>
</div>
<script>
const animation = lottie.loadAnimation({
container: document.querySelector('.lottie-animation'),
renderer: 'svg',
loop: true,
autoplay: true,
path: 'https://lottie.host/d987597c-7676-4424-8817-7fca6dc1a33e/BVrFXsaeui.json'
});
</script>
</body>
</html>

View File

@ -1,73 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="corporate">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unauthorized!</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<title>Unauthorized</title>
<link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="<?= base_url('js/tailwind.min.js'); ?>"></script>
<link href="<?= base_url('css/themes.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="<?= base_url('js/fontawesome.min.js'); ?>"></script>
<style>
.error-page {
min-height: 100vh;
background: linear-gradient(45deg, #0d6efd 0%, #0dcaf0 100%);
}
.error-container {
max-width: 600px;
}
.error-code {
font-size: 12rem;
font-weight: 900;
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0.5));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: pulse 2s infinite;
}
.error-message {
color: rgba(255, 255, 255, 0.9);
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.btn-glass {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
transition: all 0.3s ease;
}
.btn-glass:hover {
background: rgba(255, 255, 255, 0.3);
color: white;
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
</style>
</head>
<body>
<div class="error-page d-flex align-items-center justify-content-center">
<div class="error-container text-center p-4">
<h1 class="error-code mb-0">401</h1>
<h2 class="display-6 error-message mb-3">Akses Ditolak✋</h2>
<p class="lead error-message mb-5">Anda tidak punya izin untuk mengakses halaman ini.</p>
<div class="d-flex justify-content-center gap-3">
<a href="/" class="btn btn-glass px-4 py-2">Kembali</a>
<!-- <a href="#" class="btn btn-glass px-4 py-2">Report Problem</a> -->
<body class="bg-base-200 min-h-screen flex items-center justify-center p-4">
<div class="card bg-base-100 shadow-xl w-full max-w-md text-center">
<div class="card-body items-center">
<div class="text-error/80 text-8xl mb-2">
<i class="fa fa-lock"></i>
</div>
<h1 class="text-4xl font-bold text-base-content mb-1">401</h1>
<h2 class="text-xl font-semibold mb-2">Access Denied</h2>
<p class="text-base-content/70 mb-6">You do not have permission to access this page.</p>
<div class="card-actions">
<a href="<?= base_url() ?>" class="btn btn-primary btn-sm">
<i class="fa fa-home mr-2"></i> Go Home
</a>
</div>
</div>
</div>

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">
<div class="card bg-base-100 shadow-xl h-full border border-base-200 overflow-hidden">
<div class="card-body p-0 h-full flex flex-col">
<!-- Header & Filters -->
<div class="p-4 border-b border-base-200 bg-base-50">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
<div class="flex-1">
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
</h2>
</div>
<!-- Status Filters -->
<div class="join shadow-sm bg-base-100 rounded-lg">
<button @click="filterKey = 'Total'"
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
</button>
<button @click="filterKey = 'Pend'"
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
</button>
<button @click="filterKey = 'Coll'"
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
</button>
<button @click="filterKey = 'Recv'"
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
</button>
<button @click="filterKey = 'Inc'"
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
</button>
<button @click="filterKey = 'Fin'"
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
</button>
<button @click="filterKey = 'Validated'"
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
</button>
</div>
</div>
<!-- Search & Date Filter -->
<div
class="flex flex-col md:flex-row gap-3 items-end bg-base-100 p-3 rounded-lg border border-base-200 shadow-sm">
<div class="form-control">
<label class="label text-xs font-bold py-1 text-base-content/60">Date Range</label>
<div class="join">
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date1" />
<span class="join-item btn btn-sm btn-ghost no-animation bg-base-200 font-normal px-2">-</span>
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date2" />
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-primary" @click='fetchList()'><i class='fa fa-search'></i> Search</button>
<button class="btn btn-sm btn-neutral" @click='reset()'><i class='fa fa-sync-alt'></i> Reset</button>
</div>
<span class="flex-1"></span>
<div class="form-control w-full md:w-auto">
<label class='input input-sm input-bordered'>
<i class="fa fa-filter"></i>
<input type="text" placeholder="Type to filter..." x-model="filterTable" />
</label>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 pb-4">
<template x-if="isLoading">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;'>
<div class="skeleton h-4 w-20"></div>
</th>
<th style='width:15%;'>
<div class="skeleton h-4 w-32"></div>
</th>
<th style='width:7%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:7%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:8%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:8%;'>
<div class="skeleton h-4 w-20"></div>
</th>
<th style='width:15%;'>
<div class="skeleton h-4 w-32"></div>
</th>
<th style='width:3%;'>
<div class="skeleton h-4 w-12"></div>
</th>
<th style='width:5%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:5%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:4%;'>
<div class="skeleton h-4 w-12"></div>
</th>
</tr>
</thead>
<tbody>
<template x-for="i in 5" :key="i">
<tr>
<td colspan="11">
<div class="skeleton h-4 w-full"></div>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="!isLoading && !list.length">
<div class="text-center py-10">
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
<p>No records found</p>
</div>
</template>
<template x-if="!isLoading && list.length">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;' @click="sort('REQDATE')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Order Datetime
<i class="fa text-xs"
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:15%;' @click="sort('Name')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Patient Name
<i class="fa text-xs"
:class="sortCol === 'Name' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:7%;' @click="sort('SP_ACCESSNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
No Lab
<i class="fa text-xs"
:class="sortCol === 'SP_ACCESSNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:7%;' @click="sort('HOSTORDERNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
No Register
<i class="fa text-xs"
:class="sortCol === 'HOSTORDERNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:8%;' @click="sort('REFF')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Reff
<i class="fa text-xs"
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:8%;' @click="sort('DOC')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Doctor
<i class="fa text-xs"
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:15%;'>Tests</th>
<th style='width:3%;'>ResTo</th>
<th style='width:5%;'>Val</th>
<th style='width:5%;'>Result</th>
<th style='width:4%;' @click="sort('STATS')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Status
<i class="fa text-xs"
:class="sortCol === 'STATS' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
</tr>
</thead>
<tbody>
<tbody>
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
<tr class="hover:bg-base-300">
<td x-text="req.REQDATE"></td>
<td x-text="req.Name"></td>
<td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
<td x-text="req.REFF"></td>
<td x-text="req.DOC"></td>
<td x-text="req.TESTS"></td>
<td x-text="req.ODR_CRESULT_TO"></td>
<td>
<div class='flex gap-1 items-center'>
<div class='w-15'>
<p>1: <span x-text="req.VAL1USER"></span></p>
<p>2: <span x-text="req.VAL2USER"></span></p>
</div>
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
<div class='text-center'>
<template
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
<button class="btn btn-xs btn-outline btn-secondary"
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
class="fa-solid fa-rotate-right"></i></button>
</template>
</div>
</template>
</div>
</td>
<td>
<template x-if="req.STATS !== 'PartColl' && req.STATS !== 'Coll' && req.STATS !== 'Pend'">
<button class="btn btn-xs btn-outline btn-primary"
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
</template>
</td>
<td x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="font-bold cursor-pointer"
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Pagination Control -->
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
x-show="!isLoading && list.length > 0">
<div class="text-xs text-base-content/60">
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
<span class="font-bold" x-text="filtered.length"></span> entries
</div>
<div class="join">
<button class="join-item btn btn-sm" @click="prevPage()" :disabled="currentPage === 1">
<i class="fa fa-chevron-left"></i>
</button>
<button class="join-item btn btn-sm no-animation bg-base-100 cursor-default">
Page <span x-text="currentPage"></span> / <span x-text="totalPages"></span>
</button>
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
</div>
<?php echo $this->include('lab/dialog_sample'); ?>
<?php echo $this->include('lab/dialog_unval'); ?>
<?php echo $this->include('lab/dialog_preview'); ?>
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?>
<?= $this->include('shared/dialog_preview'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section('script') ?>
<?= $this->section('script'); ?>
<script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>';
document.addEventListener('alpine:init', () => {
Alpine.data("dashboard", () => ({
// dashboard
today: "",
filter: { date1: "", date2: "" },
list: [],
isLoading: false,
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
statusColor: {
Pend: 'bg-white text-black font-bold',
PartColl: 'bg-orange-300 text-white font-bold',
Coll: 'bg-orange-500 text-white font-bold',
PartRecv: 'bg-blue-200 text-black font-bold',
Recv: 'bg-blue-500 text-white font-bold',
Inc: 'bg-yellow-500 text-white font-bold',
Fin: 'bg-green-500 text-white font-bold',
},
filterTable: "",
filterKey: 'Total',
filterKey: 'Total',
statusMap: {
Total: [],
Pend: ['Pend'],
Coll: ['Coll', 'PartColl'],
Recv: ['Recv', 'PartRecv'],
Inc: ['Inc'],
Fin: ['Fin'],
},
// Sorting & Pagination
sortCol: 'REQDATE',
sortAsc: false,
currentPage: 1,
pageSize: 30,
sort(col) {
if (this.sortCol === col) {
this.sortAsc = !this.sortAsc;
} else {
this.sortCol = col;
this.sortAsc = true;
}
},
nextPage() {
if (this.currentPage < this.totalPages) this.currentPage++;
},
prevPage() {
if (this.currentPage > 1) this.currentPage--;
},
get totalPages() {
return Math.ceil(this.filtered.length / this.pageSize) || 1;
},
get sorted() {
return this.filtered.slice().sort((a, b) => {
let modifier = this.sortAsc ? 1 : -1;
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
return 0;
});
},
get paginated() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.sorted.slice(start, end);
},
init() {
this.today = new Date().toISOString().slice(0, 10);
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.$watch('filterTable', () => {
this.currentPage = 1;
});
this.fetchList();
},
fetchList() {
this.isLoading = true;
this.list = [];
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
let param = new URLSearchParams(this.filter).toString();
for (let k in this.counters) { this.counters[k] = 0; }
fetch(`${BASEURL}/api/requests?${param}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}).then(res => res.json()).then(data => {
this.list = data.data ?? [];
this.filterKey = 'Total';
this.list.forEach(item => {
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
else {
if (item.STATS == 'PartColl') { this.counters.Coll++; }
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
this.counters.Total++;
}
});
this.list.sort((a, b) => {
let codeA = statusOrder[a.STATS] ?? 0;
let codeB = statusOrder[b.STATS] ?? 0;
return codeA - codeB;
});
}).finally(() => {
this.isLoading = false;
});
},
reset() {
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.fetchList();
},
isValidated(item) {
return item.ISVAL == 1 && item.ISPENDING != 1;
},
get filtered() {
// Reset pagination when filter changes (implied by this getter being accessed if dependencies change)
// However, side-effects in getters are tricky.
// Better to just let the user navigate back, or watch variables.
// For now, let's keep it pure.
let filteredList = this.list;
if (this.filterKey === 'Validated') {
filteredList = filteredList.filter(item => this.isValidated(item));
} else {
const validStatuses = this.statusMap[this.filterKey];
if (validStatuses.length > 0) {
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
}
}
if (this.filterTable) {
const searchTerm = this.filterTable.toLowerCase();
filteredList = filteredList.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
return filteredList;
},
get validatedCount() {
return this.list.filter(r => this.isValidated(r)).length;
},
/*
sample dialog
*/
item: '',
isDialogSampleOpen: false,
isSampleLoading: false,
openSampleDialog(accessnumber) {
this.isDialogSampleOpen = true;
this.fetchItem(accessnumber)
},
closeSampleDialog() {
this.isDialogSampleOpen = false;
},
fetchItem(accessnumber) {
this.isSampleLoading = true;
this.item = [];
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
.then(res => res.json()).then(data => {
this.item = data.data ?? {};
if (!Array.isArray(this.item.samples)) this.item.samples = [];
}).finally(() => {
this.isSampleLoading = false;
});
},
collect(sampcode, accessnumber) {
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
uncollect(sampcode, accessnumber) {
if (!confirm(`Uncollect sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
unreceive(sampcode, accessnumber) {
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
/*
preview dialog
*/
isDialogPreviewOpen: false,
reviewed: false,
previewItem: null,
openPreviewDialog(accessnumber, type, item) {
this.previewAccessnumber = accessnumber;
this.previewItem = item;
this.previewType = type;
this.isDialogPreviewOpen = true;
this.reviewed = false;
},
closePreviewDialog() {
this.isDialogPreviewOpen = false;
this.previewItem = null;
},
setPreviewType(type) {
this.previewType = type;
},
getPreviewUrl() {
let base = 'http://glenlis/spooler_db/main_dev.php';
let url = `${base}?acc=${this.previewAccessnumber}`;
if (this.previewType === 'ind') url += '&lang=ID';
if (this.previewType === 'eng') url += '&lang=EN';
if (this.previewType === 'pdf') url += '&output=pdf';
// Keep fallback for local dev if needed, but the above is the expected logic
// return "http://localhost/application.html";
return url;
},
validate(accessnumber, userid) {
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}` })
}).then(response => {
this.closePreviewDialog();
this.fetchList();
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
});
},
/*
unvalidate dialog
*/
isDialogUnvalOpen: false,
unvalReason: '',
unvalAccessnumber: null,
openUnvalDialog(accessnumber) {
this.unvalReason = '';
this.isDialogUnvalOpen = true;
this.unvalAccessnumber = accessnumber;
},
unvalidate(accessnumber, userid) {
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
}).then(response => {
this.closeUnvalDialog();
this.fetchList();
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
});
},
closeUnvalDialog() {
this.isDialogUnvalOpen = false;
},
}));
});
<?= $this->include('shared/script_dashboard'); ?>
Alpine.start();
</script>
<?= $this->endSection(); ?>

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>
<html lang="en" data-theme="corporate">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - CMOD</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js"></script>
<link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="<?= base_url('css/tailwind.min.js'); ?>"></script>
<link href="<?= base_url('css/themes.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="<?= base_url('js/fontawesome.min.js'); ?>"></script>
</head>
<body class="min-h-screen flex items-center justify-center bg-base-200">
<div class="w-full max-w-sm mx-auto">
<div class="card bg-base-100 shadow-xl">
@ -20,7 +22,7 @@
</div>
<h2 class="card-title text-2xl font-semibold text-base-content mb-1">CMOD</h2>
<p class="mb-6 text-sm text-base-content/70">Sign in to continue</p>
<form method="post" action="<?=base_url('login')?>" class="w-full flex flex-col gap-4">
<form method="post" action="<?= base_url('login') ?>" class="w-full flex flex-col gap-4">
<div class="form-control">
<label class="input input-bordered flex items-center gap-2 w-full">
<i class="fa fa-user text-base-content/50"></i>
@ -45,4 +47,5 @@
<div class="text-center mt-6 text-xs text-base-content/40">&copy; 2025 - 5Panda. All rights reserved.</div>
</div>
</body>
</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">
<div class="card bg-base-100 shadow-xl h-full border border-base-200 overflow-hidden">
<div class="card-body p-0 h-full flex flex-col">
<!-- Header & Filters -->
<div class="p-4 border-b border-base-200 bg-base-50">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
<div class="flex-1">
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
</h2>
</div>
<!-- Status Filters -->
<div class="join shadow-sm bg-base-100 rounded-lg">
<button @click="filterKey = 'Total'"
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
</button>
<button @click="filterKey = 'Pend'"
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
</button>
<button @click="filterKey = 'Coll'"
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
</button>
<button @click="filterKey = 'Recv'"
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
</button>
<button @click="filterKey = 'Inc'"
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
</button>
<button @click="filterKey = 'Fin'"
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
</button>
<button @click="filterKey = 'Validated'"
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
</button>
</div>
</div>
<!-- Search & Date Filter -->
<div
class="flex flex-col md:flex-row gap-3 items-end bg-base-100 p-3 rounded-lg border border-base-200 shadow-sm">
<div class="form-control">
<label class="label text-xs font-bold py-1 text-base-content/60">Date Range</label>
<div class="join">
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date1" />
<span class="join-item btn btn-sm btn-ghost no-animation bg-base-200 font-normal px-2">-</span>
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date2" />
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-primary" @click='fetchList()'><i class='fa fa-search'></i> Search</button>
<button class="btn btn-sm btn-neutral" @click='reset()'><i class='fa fa-sync-alt'></i> Reset</button>
</div>
<span class="flex-1"></span>
<div class="form-control w-full md:w-auto">
<label class='input input-sm input-bordered'>
<i class="fa fa-filter"></i>
<input type="text" placeholder="Type to filter..." x-model="filterTable" />
</label>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 pb-4">
<template x-if="isLoading">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;'>
<div class="skeleton h-4 w-20"></div>
</th>
<th style='width:15%;'>
<div class="skeleton h-4 w-32"></div>
</th>
<th style='width:7%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:7%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:8%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:8%;'>
<div class="skeleton h-4 w-20"></div>
</th>
<th style='width:15%;'>
<div class="skeleton h-4 w-32"></div>
</th>
<th style='width:3%;'>
<div class="skeleton h-4 w-12"></div>
</th>
<th style='width:5%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:5%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:4%;'>
<div class="skeleton h-4 w-12"></div>
</th>
</tr>
</thead>
<tbody>
<template x-for="i in 5" :key="i">
<tr>
<td colspan="11">
<div class="skeleton h-4 w-full"></div>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="!isLoading && !list.length">
<div class="text-center py-10">
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
<p>No records found</p>
</div>
</template>
<template x-if="!isLoading && list.length">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;' @click="sort('REQDATE')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Order Datetime
<i class="fa text-xs"
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:15%;' @click="sort('Name')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Patient Name
<i class="fa text-xs"
:class="sortCol === 'Name' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:7%;' @click="sort('SP_ACCESSNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
No Lab
<i class="fa text-xs"
:class="sortCol === 'SP_ACCESSNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:7%;' @click="sort('HOSTORDERNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
No Register
<i class="fa text-xs"
:class="sortCol === 'HOSTORDERNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:8%;' @click="sort('REFF')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Reff
<i class="fa text-xs"
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:8%;' @click="sort('DOC')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Doctor
<i class="fa text-xs"
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:15%;'>Tests</th>
<th style='width:3%;'>ResTo</th>
<th style='width:5%;'>Val</th>
<!-- <th style='width:5%;'>Result</th> -->
<th style='width:4%;' @click="sort('STATS')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Status
<i class="fa text-xs"
:class="sortCol === 'STATS' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
</tr>
</thead>
<tbody>
<tbody>
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
<tr class="hover:bg-base-300">
<td x-text="req.REQDATE"></td>
<td x-text="req.Name"></td>
<td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
<td x-text="req.REFF"></td>
<td x-text="req.DOC"></td>
<td x-text="req.TESTS"></td>
<td x-text="req.ODR_CRESULT_TO"></td>
<td>
<div class='flex gap-1 items-center'>
<div class='w-15'>
<p>1: <span x-text="req.VAL1USER"></span></p>
<p>2: <span x-text="req.VAL2USER"></span></p>
</div>
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
<div class='text-center'>
<template
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
<button class="btn btn-xs btn-outline btn-secondary"
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
class="fa-solid fa-rotate-right"></i></button>
</template>
</div>
</template>
</div>
</td>
<!-- <td>
<template x-if="req.STATS !== 'PartColl' && req.STATS !== 'Coll' && req.STATS !== 'Pend'">
<button class="btn btn-xs btn-outline btn-primary"
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
</template>
</td> -->
<td x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="font-bold cursor-pointer"
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Pagination Control -->
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
x-show="!isLoading && list.length > 0">
<div class="text-xs text-base-content/60">
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
<span class="font-bold" x-text="filtered.length"></span> entries
</div>
<div class="join">
<button class="join-item btn btn-sm" @click="prevPage()" :disabled="currentPage === 1">
<i class="fa fa-chevron-left"></i>
</button>
<button class="join-item btn btn-sm no-animation bg-base-100 cursor-default">
Page <span x-text="currentPage"></span> / <span x-text="totalPages"></span>
</button>
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
</div>
<?php echo $this->include('phlebo/dialog_sample'); ?>
<?php echo $this->include('phlebo/dialog_unval'); ?>
<?php echo $this->include('phlebo/dialog_preview'); ?>
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?>
<?= $this->include('shared/dialog_preview'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section('script') ?>
<?= $this->section('script'); ?>
<script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>';
document.addEventListener('alpine:init', () => {
Alpine.data("dashboard", () => ({
// dashboard
today: "",
filter: { date1: "", date2: "" },
list: [],
isLoading: false,
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
statusColor: {
Pend: 'bg-white text-black font-bold',
PartColl: 'bg-orange-300 text-white font-bold',
Coll: 'bg-orange-500 text-white font-bold',
PartRecv: 'bg-blue-200 text-black font-bold',
Recv: 'bg-blue-500 text-white font-bold',
Inc: 'bg-yellow-500 text-white font-bold',
Fin: 'bg-green-500 text-white font-bold',
},
filterTable: "",
filterKey: 'Total',
filterKey: 'Total',
statusMap: {
Total: [],
Pend: ['Pend'],
Coll: ['Coll', 'PartColl'],
Recv: ['Recv', 'PartRecv'],
Inc: ['Inc'],
Fin: ['Fin'],
},
// Sorting & Pagination
sortCol: 'REQDATE',
sortAsc: false,
currentPage: 1,
pageSize: 30,
sort(col) {
if (this.sortCol === col) {
this.sortAsc = !this.sortAsc;
} else {
this.sortCol = col;
this.sortAsc = true;
}
},
nextPage() {
if (this.currentPage < this.totalPages) this.currentPage++;
},
prevPage() {
if (this.currentPage > 1) this.currentPage--;
},
get totalPages() {
return Math.ceil(this.filtered.length / this.pageSize) || 1;
},
get sorted() {
return this.filtered.slice().sort((a, b) => {
let modifier = this.sortAsc ? 1 : -1;
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
return 0;
});
},
get paginated() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.sorted.slice(start, end);
},
init() {
this.today = new Date().toISOString().slice(0, 10);
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.$watch('filterTable', () => {
this.currentPage = 1;
});
this.fetchList();
},
fetchList() {
this.isLoading = true;
this.list = [];
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
let param = new URLSearchParams(this.filter).toString();
for (let k in this.counters) { this.counters[k] = 0; }
fetch(`${BASEURL}/api/requests?${param}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}).then(res => res.json()).then(data => {
this.list = data.data ?? [];
this.filterKey = 'Total';
this.list.forEach(item => {
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
else {
if (item.STATS == 'PartColl') { this.counters.Coll++; }
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
this.counters.Total++;
}
});
this.list.sort((a, b) => {
let codeA = statusOrder[a.STATS] ?? 0;
let codeB = statusOrder[b.STATS] ?? 0;
return codeA - codeB;
});
}).finally(() => {
this.isLoading = false;
});
},
reset() {
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.fetchList();
},
isValidated(item) {
return item.ISVAL == 1 && item.ISPENDING != 1;
},
get filtered() {
// Reset pagination when filter changes (implied by this getter being accessed if dependencies change)
// However, side-effects in getters are tricky.
// Better to just let the user navigate back, or watch variables.
// For now, let's keep it pure.
let filteredList = this.list;
if (this.filterKey === 'Validated') {
filteredList = filteredList.filter(item => this.isValidated(item));
} else {
const validStatuses = this.statusMap[this.filterKey];
if (validStatuses.length > 0) {
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
}
}
if (this.filterTable) {
const searchTerm = this.filterTable.toLowerCase();
filteredList = filteredList.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
return filteredList;
},
get validatedCount() {
return this.list.filter(r => this.isValidated(r)).length;
},
/*
sample dialog
*/
item: '',
isDialogSampleOpen: false,
isSampleLoading: false,
openSampleDialog(accessnumber) {
this.isDialogSampleOpen = true;
this.fetchItem(accessnumber)
},
closeSampleDialog() {
this.isDialogSampleOpen = false;
},
fetchItem(accessnumber) {
this.isSampleLoading = true;
this.item = [];
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
.then(res => res.json()).then(data => {
this.item = data.data ?? {};
if (!Array.isArray(this.item.samples)) this.item.samples = [];
}).finally(() => {
this.isSampleLoading = false;
});
},
collect(sampcode, accessnumber) {
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
uncollect(sampcode, accessnumber) {
if (!confirm(`Uncollect sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
unreceive(sampcode, accessnumber) {
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
/*
preview dialog
*/
isDialogPreviewOpen: false,
reviewed: false,
previewItem: null,
openPreviewDialog(accessnumber, type, item) {
this.previewAccessnumber = accessnumber;
this.previewItem = item;
this.previewType = type;
this.isDialogPreviewOpen = true;
this.reviewed = false;
},
closePreviewDialog() {
this.isDialogPreviewOpen = false;
this.previewItem = null;
},
setPreviewType(type) {
this.previewType = type;
},
getPreviewUrl() {
let base = 'http://glenlis/spooler_db/main_dev.php';
let url = `${base}?acc=${this.previewAccessnumber}`;
if (this.previewType === 'ind') url += '&lang=ID';
if (this.previewType === 'eng') url += '&lang=EN';
if (this.previewType === 'pdf') url += '&output=pdf';
// Keep fallback for local dev if needed, but the above is the expected logic
// return "http://localhost/application.html";
return url;
},
validate(accessnumber, userid) {
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}` })
}).then(response => {
this.closePreviewDialog();
this.fetchList();
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
});
},
/*
unvalidate dialog
*/
isDialogUnvalOpen: false,
unvalReason: '',
unvalAccessnumber: null,
openUnvalDialog(accessnumber) {
this.unvalReason = '';
this.isDialogUnvalOpen = true;
this.unvalAccessnumber = accessnumber;
},
unvalidate(accessnumber, userid) {
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
}).then(response => {
this.closeUnvalDialog();
this.fetchList();
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
});
},
closeUnvalDialog() {
this.isDialogUnvalOpen = false;
},
}));
});
<?= $this->include('shared/script_dashboard'); ?>
Alpine.start();
</script>
<?= $this->endSection(); ?>

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 name="viewport" content="width=device-width, initial-scale=1.0">
<title>CMOD</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/js/all.min.js"></script>
<link href="<?= base_url('css/daisyui.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="<?= base_url('css/tailwind.min.js'); ?>"></script>
<script src="<?= base_url('js/alpine-focus.min.js'); ?>"></script>
<link href="<?= base_url('css/themes.min.css'); ?>" rel="stylesheet" type="text/css" />
<script src="<?= base_url('js/fontawesome.min.js'); ?>"></script>
<style>
body {
margin: 0;
@ -34,7 +35,7 @@
<div class='flex-1'>
<a class='text-xl text-primary font-bold tracking-wide flex items-center gap-2 ml-2'>
<i class="fa fa-cube"></i> CMOD <span class="text-base-content/40 font-light text-sm hidden sm:inline-block">|
Lab Analyst Dashboard</span>
<?= esc($roleConfig['title'] ?? 'Dashboard') ?></span>
</a>
</div>
<div class="flex gap-2">
@ -52,7 +53,10 @@
class="fa fa-sign-out-alt mr-2"></i> Logout</a></li>
<li><a @click.prevent="openDialogSetPassword()"><i class="fa fa-key mr-2"></i> Change Password</a></li>
<div class="divider my-1"></div>
<li><a href="<?= base_url('lab') ?>"><i class="fa fa-chart-bar mr-2"></i> Dashboard</a></li>
<?php foreach ($roleConfig['menuItems'] ?? [] as $item): ?>
<li><a href="<?= base_url($item['href']) ?>"><i class="fa fa-<?= $item['icon'] ?> mr-2"></i>
<?= $item['label'] ?></a></li>
<?php endforeach; ?>
</ul>
</div>
</div>
@ -60,7 +64,7 @@
<!-- Page Content -->
<?= $this->renderSection('content'); ?>
<?= $this->include('lab/dialog_setPassword'); ?>
<?= $this->include('shared/dialog_setPassword'); ?>
<footer class='bg-base-100 p-1 mt-auto'>&copy; <?= date('Y'); ?> - 5Panda</footer>
</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">
<div class="card bg-base-100 shadow-xl h-full border border-base-200 overflow-hidden">
<div class="card-body p-0 h-full flex flex-col">
<!-- Header & Filters -->
<div class="p-4 border-b border-base-200 bg-base-50">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
<div class="flex-1">
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
</h2>
</div>
<!-- Status Filters -->
<div class="join shadow-sm bg-base-100 rounded-lg">
<button @click="filterKey = 'Total'"
:class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
</button>
<button @click="filterKey = 'Pend'"
:class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
</button>
<button @click="filterKey = 'Coll'"
:class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
</button>
<button @click="filterKey = 'Recv'"
:class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
</button>
<button @click="filterKey = 'Inc'"
:class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
</button>
<button @click="filterKey = 'Fin'"
:class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
</button>
<button @click="filterKey = 'Validated'"
:class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
</button>
</div>
</div>
<!-- Search & Date Filter -->
<div
class="flex flex-col md:flex-row gap-3 items-end bg-base-100 p-3 rounded-lg border border-base-200 shadow-sm">
<div class="form-control">
<label class="label text-xs font-bold py-1 text-base-content/60">Date Range</label>
<div class="join">
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date1" />
<span class="join-item btn btn-sm btn-ghost no-animation bg-base-200 font-normal px-2">-</span>
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date2" />
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-primary" @click='fetchList()'><i class='fa fa-search'></i> Search</button>
<button class="btn btn-sm btn-neutral" @click='reset()'><i class='fa fa-sync-alt'></i> Reset</button>
</div>
<span class="flex-1"></span>
<div class="form-control w-full md:w-auto">
<label class='input input-sm input-bordered'>
<i class="fa fa-filter"></i>
<input type="text" placeholder="Type to filter..." x-model="filterTable" />
</label>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 pb-4">
<template x-if="isLoading">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;'>
<div class="skeleton h-4 w-20"></div>
</th>
<th style='width:15%;'>
<div class="skeleton h-4 w-32"></div>
</th>
<th style='width:7%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:7%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:8%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:8%;'>
<div class="skeleton h-4 w-20"></div>
</th>
<th style='width:15%;'>
<div class="skeleton h-4 w-32"></div>
</th>
<th style='width:3%;'>
<div class="skeleton h-4 w-12"></div>
</th>
<th style='width:5%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:5%;'>
<div class="skeleton h-4 w-16"></div>
</th>
<th style='width:4%;'>
<div class="skeleton h-4 w-12"></div>
</th>
</tr>
</thead>
<tbody>
<template x-for="i in 5" :key="i">
<tr>
<td colspan="11">
<div class="skeleton h-4 w-full"></div>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="!isLoading && !list.length">
<div class="text-center py-10">
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
<p>No records found</p>
</div>
</template>
<template x-if="!isLoading && list.length">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;' @click="sort('REQDATE')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Order Datetime
<i class="fa text-xs"
:class="sortCol === 'REQDATE' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:15%;' @click="sort('Name')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Patient Name
<i class="fa text-xs"
:class="sortCol === 'Name' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:7%;' @click="sort('SP_ACCESSNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
No Lab
<i class="fa text-xs"
:class="sortCol === 'SP_ACCESSNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:7%;' @click="sort('HOSTORDERNUMBER')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
No Register
<i class="fa text-xs"
:class="sortCol === 'HOSTORDERNUMBER' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:8%;' @click="sort('REFF')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Reff
<i class="fa text-xs"
:class="sortCol === 'REFF' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:8%;' @click="sort('DOC')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Doctor
<i class="fa text-xs"
:class="sortCol === 'DOC' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
<th style='width:15%;'>Tests</th>
<th style='width:3%;'>ResTo</th>
<th style='width:5%;'>Val</th>
<th style='width:5%;'>Result</th>
<th style='width:4%;' @click="sort('STATS')"
class="cursor-pointer hover:bg-base-200 transition-colors select-none">
<div class="flex items-center gap-1">
Status
<i class="fa text-xs"
:class="sortCol === 'STATS' ? (sortAsc ? 'fa-sort-up' : 'fa-sort-down') : 'fa-sort opacity-20'"></i>
</div>
</th>
</tr>
</thead>
<tbody>
<tbody>
<template x-for="req in paginated" :key="req.SP_ACCESSNUMBER">
<tr class="hover:bg-base-300">
<td x-text="req.REQDATE"></td>
<td x-text="req.Name"></td>
<td x-text="req.SP_ACCESSNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
<td x-text="req.HOSTORDERNUMBER" class="font-bold cursor-pointer" :class="statusColor[req.STATS]"
@click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
<td x-text="req.REFF"></td>
<td x-text="req.DOC"></td>
<td x-text="req.TESTS"></td>
<td x-text="req.ODR_CRESULT_TO"></td>
<td>
<div class='flex gap-1 items-center'>
<div class='w-15'>
<p>1: <span x-text="req.VAL1USER"></span></p>
<p>2: <span x-text="req.VAL2USER"></span></p>
</div>
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
<div class='text-center'>
<template
x-if="req.VAL1USER == '<?= session('userid'); ?>' || req.VAL2USER == '<?= session('userid'); ?>'">
<button class="btn btn-xs btn-outline btn-secondary"
@click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i
class="fa-solid fa-rotate-right"></i></button>
</template>
</div>
</template>
</div>
</td>
<td>
<template x-if="req.STATS !== 'PartColl' && req.STATS !== 'Coll' && req.STATS !== 'Pend'">
<button class="btn btn-xs btn-outline btn-primary"
@click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview', req)">Preview</button>
</template>
</td>
<td x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="font-bold cursor-pointer"
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Pagination Control -->
<div class="p-2 border-t border-base-200 bg-base-50 flex justify-between items-center"
x-show="!isLoading && list.length > 0">
<div class="text-xs text-base-content/60">
Showing <span class="font-bold" x-text="((currentPage - 1) * pageSize) + 1"></span> to
<span class="font-bold" x-text="Math.min(currentPage * pageSize, filtered.length)"></span> of
<span class="font-bold" x-text="filtered.length"></span> entries
</div>
<div class="join">
<button class="join-item btn btn-sm" @click="prevPage()" :disabled="currentPage === 1">
<i class="fa fa-chevron-left"></i>
</button>
<button class="join-item btn btn-sm no-animation bg-base-100 cursor-default">
Page <span x-text="currentPage"></span> / <span x-text="totalPages"></span>
</button>
<button class="join-item btn btn-sm" @click="nextPage()" :disabled="currentPage === totalPages">
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
</div>
<?php echo $this->include('superuser/dialog_sample'); ?>
<?php echo $this->include('superuser/dialog_unval'); ?>
<?php echo $this->include('superuser/dialog_preview'); ?>
<?= $this->include('shared/dashboard_table', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_sample', ['config' => $roleConfig]); ?>
<?= $this->include('shared/dialog_unval'); ?>
<?= $this->include('shared/dialog_preview'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section('script') ?>
<?= $this->section('script'); ?>
<script type="module">
import Alpine from '<?= base_url("js/app.js"); ?>';
document.addEventListener('alpine:init', () => {
Alpine.data("dashboard", () => ({
// dashboard
today: "",
filter: { date1: "", date2: "" },
list: [],
isLoading: false,
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
statusColor: {
Pend: 'bg-white text-black font-bold',
PartColl: 'bg-orange-300 text-white font-bold',
Coll: 'bg-orange-500 text-white font-bold',
PartRecv: 'bg-blue-200 text-black font-bold',
Recv: 'bg-blue-500 text-white font-bold',
Inc: 'bg-yellow-500 text-white font-bold',
Fin: 'bg-green-500 text-white font-bold',
},
filterTable: "",
filterKey: 'Total',
filterKey: 'Total',
statusMap: {
Total: [],
Pend: ['Pend'],
Coll: ['Coll', 'PartColl'],
Recv: ['Recv', 'PartRecv'],
Inc: ['Inc'],
Fin: ['Fin'],
},
// Sorting & Pagination
sortCol: 'REQDATE',
sortAsc: false,
currentPage: 1,
pageSize: 30,
sort(col) {
if (this.sortCol === col) {
this.sortAsc = !this.sortAsc;
} else {
this.sortCol = col;
this.sortAsc = true;
}
},
nextPage() {
if (this.currentPage < this.totalPages) this.currentPage++;
},
prevPage() {
if (this.currentPage > 1) this.currentPage--;
},
get totalPages() {
return Math.ceil(this.filtered.length / this.pageSize) || 1;
},
get sorted() {
return this.filtered.slice().sort((a, b) => {
let modifier = this.sortAsc ? 1 : -1;
if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier;
if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier;
return 0;
});
},
get paginated() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.sorted.slice(start, end);
},
init() {
this.today = new Date().toISOString().slice(0, 10);
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.$watch('filterTable', () => {
this.currentPage = 1;
});
this.fetchList();
},
fetchList() {
this.isLoading = true;
this.list = [];
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
let param = new URLSearchParams(this.filter).toString();
for (let k in this.counters) { this.counters[k] = 0; }
fetch(`${BASEURL}/api/requests?${param}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}).then(res => res.json()).then(data => {
this.list = data.data ?? [];
this.filterKey = 'Total';
this.list.forEach(item => {
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
else {
if (item.STATS == 'PartColl') { this.counters.Coll++; }
else if (item.STATS == 'PartRecv') { this.counters.Recv++; }
this.counters.Total++;
}
});
this.list.sort((a, b) => {
let codeA = statusOrder[a.STATS] ?? 0;
let codeB = statusOrder[b.STATS] ?? 0;
return codeA - codeB;
});
}).finally(() => {
this.isLoading = false;
});
},
reset() {
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.fetchList();
},
isValidated(item) {
return item.ISVAL == 1 && item.ISPENDING != 1;
},
get filtered() {
// Reset pagination when filter changes (implied by this getter being accessed if dependencies change)
// However, side-effects in getters are tricky.
// Better to just let the user navigate back, or watch variables.
// For now, let's keep it pure.
let filteredList = this.list;
if (this.filterKey === 'Validated') {
filteredList = filteredList.filter(item => this.isValidated(item));
} else {
const validStatuses = this.statusMap[this.filterKey];
if (validStatuses.length > 0) {
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
}
}
if (this.filterTable) {
const searchTerm = this.filterTable.toLowerCase();
filteredList = filteredList.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
return filteredList;
},
get validatedCount() {
return this.list.filter(r => this.isValidated(r)).length;
},
/*
sample dialog
*/
item: '',
isDialogSampleOpen: false,
isSampleLoading: false,
openSampleDialog(accessnumber) {
this.isDialogSampleOpen = true;
this.fetchItem(accessnumber)
},
closeSampleDialog() {
this.isDialogSampleOpen = false;
},
fetchItem(accessnumber) {
this.isSampleLoading = true;
this.item = [];
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } })
.then(res => res.json()).then(data => {
this.item = data.data ?? {};
if (!Array.isArray(this.item.samples)) this.item.samples = [];
}).finally(() => {
this.isSampleLoading = false;
});
},
collect(sampcode, accessnumber) {
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
uncollect(sampcode, accessnumber) {
if (!confirm(`Uncollect sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
unreceive(sampcode, accessnumber) {
if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
/*
preview dialog
*/
isDialogPreviewOpen: false,
reviewed: false,
previewItem: null,
openPreviewDialog(accessnumber, type, item) {
this.previewAccessnumber = accessnumber;
this.previewItem = item;
this.previewType = type;
this.isDialogPreviewOpen = true;
this.reviewed = false;
},
closePreviewDialog() {
this.isDialogPreviewOpen = false;
this.previewItem = null;
},
setPreviewType(type) {
this.previewType = type;
},
getPreviewUrl() {
let base = 'http://glenlis/spooler_db/main_dev.php';
let url = `${base}?acc=${this.previewAccessnumber}`;
if (this.previewType === 'ind') url += '&lang=ID';
if (this.previewType === 'eng') url += '&lang=EN';
if (this.previewType === 'pdf') url += '&output=pdf';
// Keep fallback for local dev if needed, but the above is the expected logic
// return "http://localhost/application.html";
return url;
},
validate(accessnumber, userid) {
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}` })
}).then(response => {
this.closePreviewDialog();
this.fetchList();
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
});
},
/*
unvalidate dialog
*/
isDialogUnvalOpen: false,
unvalReason: '',
unvalAccessnumber: null,
openUnvalDialog(accessnumber) {
this.unvalReason = '';
this.isDialogUnvalOpen = true;
this.unvalAccessnumber = accessnumber;
},
unvalidate(accessnumber, userid) {
if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; }
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() })
}).then(response => {
this.closeUnvalDialog();
this.fetchList();
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
});
},
closeUnvalDialog() {
this.isDialogUnvalOpen = false;
},
}));
});
<?= $this->include('shared/script_dashboard'); ?>
Alpine.start();
</script>
<?= $this->endSection(); ?>

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