adding v2 frontend
This commit is contained in:
parent
eb305d8567
commit
061af6e6d7
27
.agent/workflows/agent.md
Normal file
27
.agent/workflows/agent.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
description: Rules and guidelines for the AI agent working on this project
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Guidelines
|
||||||
|
|
||||||
|
## Backend Update Rules
|
||||||
|
|
||||||
|
> **IMPORTANT**: On every backend update (controllers, models, routes, database changes), you MUST update `README.md` accordingly.
|
||||||
|
|
||||||
|
### Documentation Updates Required For:
|
||||||
|
1. **New API endpoints** - Add to API documentation
|
||||||
|
2. **Database schema changes** - Update schema docs
|
||||||
|
3. **New features** - Document in appropriate section
|
||||||
|
4. **Configuration changes** - Update technical stack or setup instructions
|
||||||
|
|
||||||
|
### V2 Frontend Updates
|
||||||
|
|
||||||
|
For any changes to the `/v2` hidden frontend, update the dedicated documentation at:
|
||||||
|
- `app/Views/v2/README.md`
|
||||||
|
|
||||||
|
This keeps V2 changes separate from the main README to avoid exposing the hidden UI to the team.
|
||||||
|
|
||||||
|
## Workflow Preferences
|
||||||
|
- Use 2-space indentation for all code
|
||||||
|
- Follow existing code patterns and naming conventions
|
||||||
|
- Always test changes before committing
|
||||||
@ -33,6 +33,7 @@ The system is currently undergoing a strategic **Architectural Redesign** to con
|
|||||||
| **Framework** | CodeIgniter 4 |
|
| **Framework** | CodeIgniter 4 |
|
||||||
| **Security** | JWT (JSON Web Tokens) Authorization |
|
| **Security** | JWT (JSON Web Tokens) Authorization |
|
||||||
| **Database** | MySQL (Optimized Schema Migration in progress) |
|
| **Database** | MySQL (Optimized Schema Migration in progress) |
|
||||||
|
| **Dev Tools** | Internal utilities available at `/v2` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ class App extends BaseConfig
|
|||||||
*
|
*
|
||||||
* E.g., http://example.com/
|
* E.g., http://example.com/
|
||||||
*/
|
*/
|
||||||
public string $baseURL = 'http://localhost:8080/';
|
public string $baseURL = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
|
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
|
||||||
@ -40,7 +40,8 @@ class App extends BaseConfig
|
|||||||
* something else. If you have configured your web server to remove this file
|
* something else. If you have configured your web server to remove this file
|
||||||
* from your site URIs, set this variable to an empty string.
|
* from your site URIs, set this variable to an empty string.
|
||||||
*/
|
*/
|
||||||
public string $indexPage = 'index.php';
|
#public string $indexPage = 'index.php';
|
||||||
|
public string $indexPage = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --------------------------------------------------------------------------
|
* --------------------------------------------------------------------------
|
||||||
|
|||||||
@ -53,7 +53,7 @@ class Filters extends BaseFilters
|
|||||||
*/
|
*/
|
||||||
public array $required = [
|
public array $required = [
|
||||||
'before' => [
|
'before' => [
|
||||||
'forcehttps', // Force Global Secure Requests
|
// 'forcehttps', // Force Global Secure Requests - disabled for localhost
|
||||||
'pagecache', // Web Page Caching
|
'pagecache', // Web Page Caching
|
||||||
],
|
],
|
||||||
'after' => [
|
'after' => [
|
||||||
|
|||||||
@ -6,12 +6,12 @@ use CodeIgniter\Router\RouteCollection;
|
|||||||
* @var RouteCollection $routes
|
* @var RouteCollection $routes
|
||||||
*/
|
*/
|
||||||
$routes->options('(:any)', function() { return ''; });
|
$routes->options('(:any)', function() { return ''; });
|
||||||
$routes->get('/', 'Home::index');
|
$routes->get('/', 'Pages\V2Page::index');
|
||||||
|
|
||||||
// Frontend Pages
|
// Frontend Pages
|
||||||
$routes->get('/login', 'Pages\AuthPage::login');
|
$routes->get('/login', 'Pages\V2Page::login');
|
||||||
$routes->get('/logout', 'Pages\AuthPage::logout');
|
$routes->get('/logout', 'Pages\AuthPage::logout');
|
||||||
$routes->get('/dashboard', 'Pages\DashboardPage::index');
|
$routes->get('/dashboard', 'Pages\V2Page::index');
|
||||||
|
|
||||||
// Faker
|
// Faker
|
||||||
$routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1');
|
$routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1');
|
||||||
@ -81,13 +81,13 @@ $routes->patch('/api/medicalspecialty', 'Contact\MedicalSpecialty::update');
|
|||||||
|
|
||||||
$routes->get('/api/valueset', 'ValueSet\ValueSet::index');
|
$routes->get('/api/valueset', 'ValueSet\ValueSet::index');
|
||||||
$routes->get('/api/valueset/(:num)', 'ValueSet\ValueSet::show/$1');
|
$routes->get('/api/valueset/(:num)', 'ValueSet\ValueSet::show/$1');
|
||||||
$routes->get('/api/valueset/valuesetdef/(:num)', 'ValueSet\ValueSet::showByValueSetDef/$1');
|
$routes->get('/api/valueset/valuesetdef/(:segment)', 'ValueSet\ValueSet::showByValueSetDef/$1');
|
||||||
$routes->post('/api/valueset', 'ValueSet\ValueSet::create');
|
$routes->post('/api/valueset', 'ValueSet\ValueSet::create');
|
||||||
$routes->patch('/api/valueset', 'ValueSet\ValueSet::update');
|
$routes->patch('/api/valueset', 'ValueSet\ValueSet::update');
|
||||||
$routes->delete('/api/valueset', 'ValueSet\ValueSet::delete');
|
$routes->delete('/api/valueset', 'ValueSet\ValueSet::delete');
|
||||||
|
|
||||||
$routes->get('/api/valuesetdef/', 'ValueSet\ValueSetDef::index');
|
$routes->get('/api/valuesetdef/', 'ValueSet\ValueSetDef::index');
|
||||||
$routes->get('/api/valuesetdef/(:num)', 'ValueSet\ValueSetDef::show/$1');
|
$routes->get('/api/valuesetdef/(:segment)', 'ValueSet\ValueSetDef::show/$1');
|
||||||
$routes->post('/api/valuesetdef', 'ValueSet\ValueSetDef::create');
|
$routes->post('/api/valuesetdef', 'ValueSet\ValueSetDef::create');
|
||||||
$routes->patch('/api/valuesetdef', 'ValueSet\ValueSetDef::update');
|
$routes->patch('/api/valuesetdef', 'ValueSet\ValueSetDef::update');
|
||||||
$routes->delete('/api/valuesetdef', 'ValueSet\ValueSetDef::delete');
|
$routes->delete('/api/valuesetdef', 'ValueSet\ValueSetDef::delete');
|
||||||
@ -172,4 +172,31 @@ $routes->get('/api/zones', 'Zones::index');
|
|||||||
$routes->get('/api/zones/synchronize', 'Zones::synchronize');
|
$routes->get('/api/zones/synchronize', 'Zones::synchronize');
|
||||||
$routes->get('/api/zones/provinces', 'Zones::getProvinces');
|
$routes->get('/api/zones/provinces', 'Zones::getProvinces');
|
||||||
$routes->get('/api/zones/cities', 'Zones::getCities');
|
$routes->get('/api/zones/cities', 'Zones::getCities');
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// V2 Hidden Frontend
|
||||||
|
$routes->group('v2', function($routes) {
|
||||||
|
$routes->get('login', 'Pages\V2Page::login');
|
||||||
|
$routes->get('/', 'Pages\V2Page::index');
|
||||||
|
$routes->get('dashboard', 'Pages\V2Page::index');
|
||||||
|
$routes->get('api-tester', 'Pages\V2Page::apiTester');
|
||||||
|
$routes->get('db-browser', 'Pages\V2Page::dbBrowser');
|
||||||
|
$routes->get('logs', 'Pages\V2Page::logs');
|
||||||
|
$routes->get('jwt-decoder', 'Pages\V2Page::jwtDecoder');
|
||||||
|
|
||||||
|
// Patient
|
||||||
|
$routes->get('patients', 'Pages\V2Page::patients');
|
||||||
|
$routes->get('patients/create', 'Pages\V2Page::patientCreate');
|
||||||
|
$routes->get('patients/edit/(:num)', 'Pages\V2Page::patientEdit/$1');
|
||||||
|
$routes->get('patients/(:num)', 'Pages\V2Page::patientView/$1');
|
||||||
|
|
||||||
|
// System
|
||||||
|
$routes->get('organization/(:segment)', 'Pages\V2Page::organization/$1');
|
||||||
|
$routes->get('organization', 'Pages\V2Page::organization'); // Default redirect or view
|
||||||
|
$routes->get('valuesets', 'Pages\V2Page::valuesets');
|
||||||
|
|
||||||
|
// V2 API endpoints
|
||||||
|
$routes->get('api/tables', 'Pages\V2Page::getTables');
|
||||||
|
$routes->get('api/table/(:any)', 'Pages\V2Page::getTableData/$1');
|
||||||
|
$routes->get('api/logs', 'Pages\V2Page::getLogs');
|
||||||
|
});
|
||||||
@ -112,14 +112,15 @@ class Auth extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kirim Respon ke HttpOnly yg akan disimpan di browser dan tidak akan dapat diakses oleh siapapun
|
// Kirim Respon ke HttpOnly yg akan disimpan di browser dan tidak akan dapat diakses oleh siapapun
|
||||||
|
$isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
|
||||||
$this->response->setCookie([
|
$this->response->setCookie([
|
||||||
'name' => 'token', // nama token
|
'name' => 'token', // nama token
|
||||||
'value' => $jwt, // value dari jwt yg sudah di hash
|
'value' => $jwt, // value dari jwt yg sudah di hash
|
||||||
'expire' => 864000, // 10 hari
|
'expire' => 864000, // 10 hari
|
||||||
'path' => '/', // valid untuk semua path
|
'path' => '/', // valid untuk semua path
|
||||||
'secure' => true, // set true kalau sudah HTTPS
|
'secure' => $isSecure, // true for HTTPS, false for HTTP (localhost)
|
||||||
'httponly' => true, // dipakai agar cookie berikut tidak dapat diakses oleh javascript
|
'httponly' => true, // dipakai agar cookie berikut tidak dapat diakses oleh javascript
|
||||||
'samesite' => Cookie::SAMESITE_NONE
|
'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Response tanpa token di body
|
// Response tanpa token di body
|
||||||
@ -133,14 +134,15 @@ class Auth extends Controller {
|
|||||||
// ok
|
// ok
|
||||||
public function logout() {
|
public function logout() {
|
||||||
// Definisikan ini pada cookies browser, harus sama dengan cookies login
|
// Definisikan ini pada cookies browser, harus sama dengan cookies login
|
||||||
|
$isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
|
||||||
return $this->response->setCookie([
|
return $this->response->setCookie([
|
||||||
'name' => 'token',
|
'name' => 'token',
|
||||||
'value' => '',
|
'value' => '',
|
||||||
'expire' => time() - 3600,
|
'expire' => time() - 3600,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'secure' => true,
|
'secure' => $isSecure,
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
'samesite' => Cookie::SAMESITE_NONE
|
'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX
|
||||||
|
|
||||||
])->setJSON([
|
])->setJSON([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
|
|||||||
@ -34,10 +34,20 @@ class AuthPage extends Controller
|
|||||||
*/
|
*/
|
||||||
public function logout()
|
public function logout()
|
||||||
{
|
{
|
||||||
// Delete the token cookie
|
// Determine secure status matching Auth controller logic
|
||||||
$response = service('response');
|
$isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
|
||||||
$response->deleteCookie('token');
|
|
||||||
|
// Manually expire the cookie with matching attributes to ensure deletion
|
||||||
|
$this->response->setCookie([
|
||||||
|
'name' => 'token',
|
||||||
|
'value' => '',
|
||||||
|
'expire' => time() - 3600,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => $isSecure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => $isSecure ? 'None' : 'Lax'
|
||||||
|
]);
|
||||||
|
|
||||||
return redirect()->to('/login');
|
return redirect()->to('/login')->withCookies();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
345
app/Controllers/Pages/V2Page.php
Normal file
345
app/Controllers/Pages/V2Page.php
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Pages;
|
||||||
|
|
||||||
|
use CodeIgniter\Controller;
|
||||||
|
use Firebase\JWT\JWT;
|
||||||
|
use Firebase\JWT\Key;
|
||||||
|
use Firebase\JWT\ExpiredException;
|
||||||
|
use Config\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 Page Controller
|
||||||
|
* Hidden frontend for backend development
|
||||||
|
*/
|
||||||
|
class V2Page extends Controller
|
||||||
|
{
|
||||||
|
protected $user = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check JWT authentication
|
||||||
|
*/
|
||||||
|
protected function checkAuth()
|
||||||
|
{
|
||||||
|
$token = $this->request->getCookie('token');
|
||||||
|
|
||||||
|
// Debug: Log cookie status
|
||||||
|
log_message('debug', 'V2Page checkAuth - token cookie: ' . ($token ? 'EXISTS (length: ' . strlen($token) . ')' : 'NOT FOUND'));
|
||||||
|
log_message('debug', 'V2Page checkAuth - all cookies: ' . json_encode($_COOKIE));
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use getenv() directly like Auth controller
|
||||||
|
$key = getenv('JWT_SECRET');
|
||||||
|
|
||||||
|
// Debug environment if key missing
|
||||||
|
if (empty($key)) {
|
||||||
|
log_message('error', 'V2Page checkAuth - JWT_SECRET missing. Env vars available: ' . implode(',', array_keys($_ENV)));
|
||||||
|
log_message('error', 'V2Page checkAuth - getenv(JWT_SECRET): ' . (getenv('JWT_SECRET') ? 'FOUND' : 'EMPTY'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->user = JWT::decode($token, new Key($key, 'HS256'));
|
||||||
|
return true;
|
||||||
|
} catch (ExpiredException $e) {
|
||||||
|
log_message('debug', 'V2Page checkAuth - token expired');
|
||||||
|
return false;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('debug', 'V2Page checkAuth - token error: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to V2 login if not authenticated
|
||||||
|
*/
|
||||||
|
protected function requireAuth()
|
||||||
|
{
|
||||||
|
if (!$this->checkAuth()) {
|
||||||
|
return redirect()->to(site_url('v2/login'));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 Login Page
|
||||||
|
*/
|
||||||
|
public function login()
|
||||||
|
{
|
||||||
|
// If already authenticated, redirect to dashboard
|
||||||
|
if ($this->checkAuth()) {
|
||||||
|
return redirect()->to(site_url('v2'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('v2/login', [
|
||||||
|
'title' => 'V2 Login'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 Dashboard
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
return view('v2/dashboard', [
|
||||||
|
'title' => 'V2 Dashboard',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'dashboard'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Tester
|
||||||
|
*/
|
||||||
|
public function apiTester()
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
return view('v2/api-tester', [
|
||||||
|
'title' => 'API Tester',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'api-tester'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database Browser
|
||||||
|
*/
|
||||||
|
public function dbBrowser()
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
return view('v2/db-browser', [
|
||||||
|
'title' => 'DB Browser',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'db-browser'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs Viewer
|
||||||
|
*/
|
||||||
|
public function logs()
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
return view('v2/logs', [
|
||||||
|
'title' => 'Logs',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'logs'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organization Management
|
||||||
|
*/
|
||||||
|
public function organization($type = 'account')
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
// Normalize type
|
||||||
|
$type = strtolower($type);
|
||||||
|
$validTypes = ['account', 'site', 'discipline', 'department', 'workstation'];
|
||||||
|
if (!in_array($type, $validTypes)) {
|
||||||
|
return redirect()->to(site_url('v2/organization/account'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('v2/organization', [
|
||||||
|
'title' => 'Organization: ' . ucfirst($type) . 's',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'organization',
|
||||||
|
'activePage' => 'organization-' . $type,
|
||||||
|
'type' => $type
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Sets Management
|
||||||
|
*/
|
||||||
|
public function valuesets()
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
return view('v2/valuesets', [
|
||||||
|
'title' => 'Value Sets',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'valuesets'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patient List
|
||||||
|
*/
|
||||||
|
public function patients()
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
return view('v2/patients', [
|
||||||
|
'title' => 'Patients',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'patients'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patient Create Form
|
||||||
|
*/
|
||||||
|
public function patientCreate()
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
return view('v2/patient-form', [
|
||||||
|
'title' => 'New Patient',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'patients'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patient Edit Form
|
||||||
|
*/
|
||||||
|
public function patientEdit($id)
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
// Load patient data
|
||||||
|
$patientModel = new \App\Models\Patient\PatientModel();
|
||||||
|
$patient = $patientModel->getPatient($id);
|
||||||
|
|
||||||
|
if (!$patient) {
|
||||||
|
return redirect()->to(site_url('v2/patients'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('v2/patient-form', [
|
||||||
|
'title' => 'Edit Patient',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'patients',
|
||||||
|
'patient' => $patient
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patient View/Detail
|
||||||
|
*/
|
||||||
|
public function patientView($id)
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
return view('v2/patient-view', [
|
||||||
|
'title' => 'Patient Details',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'patients',
|
||||||
|
'patientId' => $id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Decoder
|
||||||
|
*/
|
||||||
|
public function jwtDecoder()
|
||||||
|
{
|
||||||
|
if ($redirect = $this->requireAuth()) return $redirect;
|
||||||
|
|
||||||
|
$token = $this->request->getCookie('token');
|
||||||
|
$decoded = null;
|
||||||
|
$parts = null;
|
||||||
|
|
||||||
|
if ($token) {
|
||||||
|
$parts = explode('.', $token);
|
||||||
|
if (count($parts) === 3) {
|
||||||
|
$decoded = [
|
||||||
|
'header' => json_decode(base64_decode($parts[0]), true),
|
||||||
|
'payload' => json_decode(base64_decode($parts[1]), true),
|
||||||
|
'signature' => $parts[2]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('v2/jwt-decoder', [
|
||||||
|
'title' => 'JWT Decoder',
|
||||||
|
'user' => $this->user,
|
||||||
|
'currentPage' => 'jwt-decoder',
|
||||||
|
'token' => $token,
|
||||||
|
'decoded' => $decoded
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Get database tables
|
||||||
|
*/
|
||||||
|
public function getTables()
|
||||||
|
{
|
||||||
|
if (!$this->checkAuth()) {
|
||||||
|
return $this->response->setJSON(['error' => 'Unauthorized'])->setStatusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$tables = $db->listTables();
|
||||||
|
|
||||||
|
return $this->response->setJSON(['tables' => $tables]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Get table data
|
||||||
|
*/
|
||||||
|
public function getTableData($table)
|
||||||
|
{
|
||||||
|
if (!$this->checkAuth()) {
|
||||||
|
return $this->response->setJSON(['error' => 'Unauthorized'])->setStatusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
|
||||||
|
// Validate table exists
|
||||||
|
if (!$db->tableExists($table)) {
|
||||||
|
return $this->response->setJSON(['error' => 'Table not found'])->setStatusCode(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get table structure
|
||||||
|
$fields = $db->getFieldData($table);
|
||||||
|
|
||||||
|
// Get data (limit 100)
|
||||||
|
$builder = $db->table($table);
|
||||||
|
$data = $builder->limit(100)->get()->getResultArray();
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'table' => $table,
|
||||||
|
'fields' => $fields,
|
||||||
|
'data' => $data,
|
||||||
|
'count' => count($data)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Get logs
|
||||||
|
*/
|
||||||
|
public function getLogs()
|
||||||
|
{
|
||||||
|
if (!$this->checkAuth()) {
|
||||||
|
return $this->response->setJSON(['error' => 'Unauthorized'])->setStatusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logPath = WRITEPATH . 'logs/';
|
||||||
|
$logs = [];
|
||||||
|
|
||||||
|
if (is_dir($logPath)) {
|
||||||
|
$files = glob($logPath . 'log-*.log');
|
||||||
|
rsort($files); // Most recent first
|
||||||
|
|
||||||
|
foreach (array_slice($files, 0, 5) as $file) {
|
||||||
|
$logs[] = [
|
||||||
|
'name' => basename($file),
|
||||||
|
'size' => filesize($file),
|
||||||
|
'content' => file_get_contents($file)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->setJSON(['logs' => $logs]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,10 +9,12 @@ class DummySeeder extends Seeder {
|
|||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
// users
|
// users
|
||||||
|
// Password: 'password' for all users (bcrypt hash)
|
||||||
|
$passwordHash = password_hash('password', PASSWORD_BCRYPT);
|
||||||
$data = [
|
$data = [
|
||||||
['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'],
|
['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => $passwordHash],
|
||||||
['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => '$2y$12$KwPedIPb7K/0IR/8/FcwdOMG4eBNNAXSjXnbkB26SwjH4Nf7PaYBe'],
|
['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => $passwordHash],
|
||||||
['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'],
|
['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => $passwordHash],
|
||||||
];
|
];
|
||||||
$this->db->table('users')->insertBatch($data);
|
$this->db->table('users')->insertBatch($data);
|
||||||
|
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
|
|
||||||
<!-- SEO Meta -->
|
|
||||||
<title><?= $title ?? 'CLQMS' ?> - Clinical Laboratory QMS</title>
|
|
||||||
<meta name="description" content="<?= $description ?? 'CLQMS - Modern Clinical Laboratory Quality Management System' ?>">
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
||||||
|
|
||||||
<!-- Google Fonts - Inter -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Lucide Icons -->
|
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
|
|
||||||
<!-- App Styles -->
|
|
||||||
<link rel="stylesheet" href="/assets/css/app.css">
|
|
||||||
|
|
||||||
<!-- Page-specific styles -->
|
|
||||||
<?= $this->renderSection('styles') ?>
|
|
||||||
</head>
|
|
||||||
<body class="bg-pattern" x-data>
|
|
||||||
|
|
||||||
<!-- Floating Decorative Shapes -->
|
|
||||||
<div class="floating-shapes">
|
|
||||||
<div class="shape shape-1"></div>
|
|
||||||
<div class="shape shape-2"></div>
|
|
||||||
<div class="shape shape-3"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<?= $this->renderSection('content') ?>
|
|
||||||
|
|
||||||
<!-- Toast Notifications Container -->
|
|
||||||
<div
|
|
||||||
x-data
|
|
||||||
class="toast-container"
|
|
||||||
style="position: fixed; top: 1rem; right: 1rem; z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem;"
|
|
||||||
>
|
|
||||||
<template x-for="toast in $store.toast.messages" :key="toast.id">
|
|
||||||
<div
|
|
||||||
x-show="true"
|
|
||||||
x-transition:enter="transition ease-out duration-300"
|
|
||||||
x-transition:enter-start="opacity-0 transform translate-x-8"
|
|
||||||
x-transition:enter-end="opacity-100 transform translate-x-0"
|
|
||||||
x-transition:leave="transition ease-in duration-200"
|
|
||||||
x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0"
|
|
||||||
:class="{
|
|
||||||
'alert': true,
|
|
||||||
'alert-success': toast.type === 'success',
|
|
||||||
'alert-error': toast.type === 'error',
|
|
||||||
'alert-info': toast.type === 'info'
|
|
||||||
}"
|
|
||||||
style="min-width: 280px; cursor: pointer;"
|
|
||||||
@click="$store.toast.dismiss(toast.id)"
|
|
||||||
>
|
|
||||||
<span x-text="toast.message"></span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Alpine.js 3.x -->
|
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
||||||
|
|
||||||
<!-- App Scripts (loaded before Alpine) -->
|
|
||||||
<script src="/assets/js/app.js"></script>
|
|
||||||
|
|
||||||
<!-- Initialize Lucide Icons -->
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
lucide.createIcons();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Page-specific scripts -->
|
|
||||||
<?= $this->renderSection('scripts') ?>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
60
app/Views/layouts/v2-login.php
Normal file
60
app/Views/layouts/v2-login.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="clqms">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login - CLQMS</title>
|
||||||
|
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script src="<?= base_url('assets/js/lucide.min.js') ?>"></script>
|
||||||
|
|
||||||
|
<style type="text/tailwindcss">
|
||||||
|
[data-theme="clqms"] {
|
||||||
|
--color-base-100: oklch(98% 0.005 240);
|
||||||
|
--color-base-200: oklch(95% 0.01 240);
|
||||||
|
--color-base-300: oklch(90% 0.015 240);
|
||||||
|
--color-base-content: oklch(25% 0.02 240);
|
||||||
|
--color-primary: oklch(55% 0.2 175);
|
||||||
|
--color-primary-content: oklch(100% 0 0);
|
||||||
|
--color-secondary: oklch(60% 0.15 250);
|
||||||
|
--color-secondary-content: oklch(100% 0 0);
|
||||||
|
--color-accent: oklch(70% 0.2 140);
|
||||||
|
--color-neutral: oklch(30% 0.02 250);
|
||||||
|
--color-neutral-content: oklch(95% 0.01 250);
|
||||||
|
--color-info: oklch(65% 0.2 230);
|
||||||
|
--color-success: oklch(65% 0.2 145);
|
||||||
|
--color-warning: oklch(80% 0.18 85);
|
||||||
|
--color-error: oklch(60% 0.25 25);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Inter', sans-serif; }
|
||||||
|
.auth-bg {
|
||||||
|
background: linear-gradient(135deg, oklch(95% 0.03 175) 0%, oklch(97% 0.02 200) 50%, oklch(96% 0.03 250) 100%);
|
||||||
|
}
|
||||||
|
.pattern-overlay {
|
||||||
|
background-image: radial-gradient(circle at 1px 1px, rgba(45,212,191,0.06) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
}
|
||||||
|
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-20px)} }
|
||||||
|
.float-animation { animation: float 8s ease-in-out infinite; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen auth-bg flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
<div class="absolute inset-0 pattern-overlay"></div>
|
||||||
|
<div class="absolute top-20 left-20 w-72 h-72 bg-primary/15 rounded-full blur-3xl float-animation"></div>
|
||||||
|
<div class="absolute bottom-20 right-20 w-80 h-80 bg-secondary/15 rounded-full blur-3xl float-animation" style="animation-delay:-4s"></div>
|
||||||
|
|
||||||
|
<div class="relative z-10 w-full max-w-md">
|
||||||
|
<?= $this->renderSection('content') ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>document.addEventListener('DOMContentLoaded',()=>{if(window.lucide)lucide.createIcons()});</script>
|
||||||
|
<?= $this->renderSection('script') ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
381
app/Views/layouts/v2.php
Normal file
381
app/Views/layouts/v2.php
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="clqms">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= $title ?? 'V2' ?> - CLQMS</title>
|
||||||
|
<meta name="description" content="Clinical Laboratory Quality Management System - Your trusted partner for laboratory quality excellence.">
|
||||||
|
|
||||||
|
<!-- Tailwind 4 + DaisyUI 5 CDN -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
|
||||||
|
<!-- Google Fonts - Inter for modern typography -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Alpine.js -->
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Lucide Icons (local) -->
|
||||||
|
<script src="<?= base_url('assets/js/lucide.min.js') ?>"></script>
|
||||||
|
|
||||||
|
<style type="text/tailwindcss">
|
||||||
|
/* Custom CLQMS Medical Theme */
|
||||||
|
@theme {
|
||||||
|
/* Custom colors for medical/laboratory feel */
|
||||||
|
--color-medical-teal: oklch(65% 0.15 180);
|
||||||
|
--color-medical-dark: oklch(25% 0.03 240);
|
||||||
|
--color-medical-light: oklch(97% 0.01 180);
|
||||||
|
--color-medical-accent: oklch(70% 0.18 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CLQMS Theme Override */
|
||||||
|
[data-theme="clqms"] {
|
||||||
|
--color-base-100: oklch(98% 0.005 240);
|
||||||
|
--color-base-200: oklch(95% 0.01 240);
|
||||||
|
--color-base-300: oklch(90% 0.015 240);
|
||||||
|
--color-base-content: oklch(25% 0.02 240);
|
||||||
|
|
||||||
|
--color-primary: oklch(55% 0.2 175);
|
||||||
|
--color-primary-content: oklch(100% 0 0);
|
||||||
|
|
||||||
|
--color-secondary: oklch(60% 0.15 250);
|
||||||
|
--color-secondary-content: oklch(100% 0 0);
|
||||||
|
|
||||||
|
--color-accent: oklch(70% 0.2 140);
|
||||||
|
--color-accent-content: oklch(20% 0.05 140);
|
||||||
|
|
||||||
|
--color-neutral: oklch(30% 0.02 250);
|
||||||
|
--color-neutral-content: oklch(95% 0.01 250);
|
||||||
|
|
||||||
|
--color-info: oklch(65% 0.2 230);
|
||||||
|
--color-info-content: oklch(100% 0 0);
|
||||||
|
|
||||||
|
--color-success: oklch(65% 0.2 145);
|
||||||
|
--color-success-content: oklch(100% 0 0);
|
||||||
|
|
||||||
|
--color-warning: oklch(80% 0.18 85);
|
||||||
|
--color-warning-content: oklch(25% 0.05 85);
|
||||||
|
|
||||||
|
--color-error: oklch(60% 0.25 25);
|
||||||
|
--color-error-content: oklch(100% 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-soft {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fadeInUp 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shimmer {
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-soft {
|
||||||
|
animation: pulse-soft 2s infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Base typography */
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar gradient backdrop */
|
||||||
|
.sidebar-gradient {
|
||||||
|
background: linear-gradient(180deg,
|
||||||
|
oklch(30% 0.05 250) 0%,
|
||||||
|
oklch(25% 0.04 260) 50%,
|
||||||
|
oklch(22% 0.03 270) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass effect for header */
|
||||||
|
.glass-header {
|
||||||
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle pattern overlay */
|
||||||
|
.pattern-dots {
|
||||||
|
background-image: radial-gradient(circle, rgba(0,0,0,0.03) 1px, transparent 1px);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu item hover effects */
|
||||||
|
.menu-glow:hover {
|
||||||
|
box-shadow: 0 0 20px rgba(45, 212, 191, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(70% 0.05 240);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(60% 0.08 240);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<?= $this->renderSection('styles') ?>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-base-200 font-sans antialiased">
|
||||||
|
|
||||||
|
<div class="drawer lg:drawer-open">
|
||||||
|
<input id="v2-drawer" type="checkbox" class="drawer-toggle" />
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="drawer-content flex flex-col transition-all duration-300 ease-out">
|
||||||
|
|
||||||
|
<!-- Mobile Navbar -->
|
||||||
|
<div class="navbar bg-base-100/95 glass-header shadow-sm border-b border-base-200/50 lg:hidden sticky top-0 z-40">
|
||||||
|
<div class="flex-none">
|
||||||
|
<label for="v2-drawer" class="btn btn-square btn-ghost drawer-button">
|
||||||
|
<i data-lucide="menu" class="w-5 h-5"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 gap-3">
|
||||||
|
<img src="<?= base_url('assets/images/logo.png') ?>" alt="CLQMS Logo" class="w-8 h-8 rounded-lg">
|
||||||
|
<span class="font-bold text-lg bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"><?= $title ?? 'CLQMS' ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none">
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar ring-2 ring-primary/20">
|
||||||
|
<div class="w-9 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center">
|
||||||
|
<span class="text-white font-semibold text-sm"><?= substr(esc($user->username ?? 'U'), 0, 1) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop Header -->
|
||||||
|
<header class="bg-base-100/80 glass-header text-base-content shadow-sm px-8 py-4 hidden lg:flex justify-between items-center sticky top-0 z-30 border-b border-base-200/50">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-base-content tracking-tight"><?= $title ?? 'Dashboard' ?></h1>
|
||||||
|
<p class="text-xs text-base-content/50 flex items-center gap-1.5">
|
||||||
|
<span class="inline-block w-1.5 h-1.5 rounded-full bg-success animate-pulse"></span>
|
||||||
|
Clinical Laboratory Quality Management System
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- System Time -->
|
||||||
|
<div class="hidden md:flex flex-col items-end px-4 py-2 bg-base-200/50 rounded-xl">
|
||||||
|
<span class="text-[10px] uppercase tracking-wider text-base-content/50 font-medium">System Time</span>
|
||||||
|
<span class="font-mono text-sm font-semibold text-base-content"><?= date('H:i') ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
<div class="indicator">
|
||||||
|
<span class="indicator-item badge badge-error badge-xs animate-pulse">3</span>
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm hover:bg-error/10">
|
||||||
|
<i data-lucide="bell" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Search -->
|
||||||
|
<button class="btn btn-ghost btn-sm gap-2 hidden lg:inline-flex hover:bg-primary/10">
|
||||||
|
<i data-lucide="search" class="w-4 h-4"></i>
|
||||||
|
<span class="text-base-content/60">Search...</span>
|
||||||
|
<kbd class="kbd kbd-xs bg-base-200">⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<main class="flex-1 p-4 lg:p-8 pattern-dots">
|
||||||
|
<div class="animate-fade-in-up">
|
||||||
|
<?= $this->renderSection('content') ?>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-base-100/50 border-t border-base-200/50 px-8 py-4 hidden lg:block">
|
||||||
|
<div class="flex justify-between items-center text-xs text-base-content/40">
|
||||||
|
<span>© <?= date('Y') ?> CLQMS - Clinical Laboratory QMS</span>
|
||||||
|
<span>Version 2.0.0</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="drawer-side z-50">
|
||||||
|
<label for="v2-drawer" class="drawer-overlay"></label>
|
||||||
|
<aside class="sidebar-gradient text-neutral-content w-60 min-h-screen flex flex-col shadow-xl">
|
||||||
|
|
||||||
|
<!-- Logo Section -->
|
||||||
|
<div class="p-4 border-b border-white/5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img src="<?= base_url('assets/images/logo.png') ?>" alt="CLQMS" class="w-9 h-9 rounded-lg" onerror="this.style.display='none'">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-bold text-lg text-white">CLQMS</span>
|
||||||
|
<span class="text-[9px] uppercase tracking-wide text-white/50">Laboratory QMS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Menu -->
|
||||||
|
<nav class="flex-1 overflow-y-auto py-2">
|
||||||
|
<ul class="menu menu-sm px-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<a href="<?= site_url('v2') ?>" class="<?= ($currentPage ?? '') === 'dashboard' ? 'active bg-primary text-primary-content' : 'text-white/70 hover:text-white hover:bg-white/5' ?>">
|
||||||
|
<i data-lucide="layout-dashboard" class="w-4 h-4"></i>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="menu-title text-[10px] text-white/40 mt-3">System</li>
|
||||||
|
<li>
|
||||||
|
<details <?= strpos($activePage ?? '', 'organization') !== false ? 'open' : '' ?>>
|
||||||
|
<summary class="text-white/70 hover:text-white hover:bg-white/5">
|
||||||
|
<i data-lucide="building-2" class="w-4 h-4"></i>
|
||||||
|
Organization
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
<li><a href="<?= site_url('v2/organization/account') ?>" class="<?= ($activePage ?? '') === 'organization-account' ? 'active' : 'text-white/60 hover:text-white' ?>">Accounts</a></li>
|
||||||
|
<li><a href="<?= site_url('v2/organization/site') ?>" class="<?= ($activePage ?? '') === 'organization-site' ? 'active' : 'text-white/60 hover:text-white' ?>">Sites</a></li>
|
||||||
|
<li><a href="<?= site_url('v2/organization/discipline') ?>" class="<?= ($activePage ?? '') === 'organization-discipline' ? 'active' : 'text-white/60 hover:text-white' ?>">Disciplines</a></li>
|
||||||
|
<li><a href="<?= site_url('v2/organization/department') ?>" class="<?= ($activePage ?? '') === 'organization-department' ? 'active' : 'text-white/60 hover:text-white' ?>">Departments</a></li>
|
||||||
|
<li><a href="<?= site_url('v2/organization/workstation') ?>" class="<?= ($activePage ?? '') === 'organization-workstation' ? 'active' : 'text-white/60 hover:text-white' ?>">Workstations</a></li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="<?= site_url('v2/valuesets') ?>" class="<?= ($currentPage ?? '') === 'valuesets' ? 'active bg-primary text-primary-content' : 'text-white/70 hover:text-white hover:bg-white/5' ?>">
|
||||||
|
<i data-lucide="list-tree" class="w-4 h-4"></i>
|
||||||
|
Value Sets
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="menu-title text-[10px] text-white/40 mt-3">Clinical</li>
|
||||||
|
<li>
|
||||||
|
<a href="<?= site_url('v2/patients') ?>" class="<?= ($currentPage ?? '') === 'patients' ? 'active bg-primary text-primary-content' : 'text-white/70 hover:text-white hover:bg-white/5' ?>">
|
||||||
|
<i data-lucide="users" class="w-4 h-4"></i>
|
||||||
|
Patients
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="menu-title text-[10px] text-white/40 mt-3">Tools</li>
|
||||||
|
<li>
|
||||||
|
<details>
|
||||||
|
<summary class="text-white/70 hover:text-white hover:bg-white/5">
|
||||||
|
<i data-lucide="terminal" class="w-4 h-4"></i>
|
||||||
|
Dev Tools
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
<li><a href="<?= site_url('v2/api-tester') ?>" class="<?= ($currentPage ?? '') === 'api-tester' ? 'active' : 'text-white/60 hover:text-white' ?>">API Tester</a></li>
|
||||||
|
<li><a href="<?= site_url('v2/db-browser') ?>" class="<?= ($currentPage ?? '') === 'db-browser' ? 'active' : 'text-white/60 hover:text-white' ?>">DB Browser</a></li>
|
||||||
|
<li><a href="<?= site_url('v2/logs') ?>" class="<?= ($currentPage ?? '') === 'logs' ? 'active' : 'text-white/60 hover:text-white' ?>">Logs</a></li>
|
||||||
|
<li><a href="<?= site_url('v2/jwt-decoder') ?>" class="<?= ($currentPage ?? '') === 'jwt-decoder' ? 'active' : 'text-white/60 hover:text-white' ?>">JWT Decoder</a></li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- User Profile Section -->
|
||||||
|
<div class="p-3 border-t border-white/5">
|
||||||
|
<div class="flex items-center gap-2 p-2 rounded-lg bg-black/10">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-primary/30 text-white text-sm">
|
||||||
|
<span><?= substr(esc($user->username ?? 'U'), 0, 1) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-white truncate"><?= esc($user->username ?? 'User') ?></div>
|
||||||
|
</div>
|
||||||
|
<a href="<?= site_url('logout') ?>" class="btn btn-ghost btn-xs text-white/60 hover:text-error" title="Sign Out">
|
||||||
|
<i data-lucide="log-out" class="w-4 h-4"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div class="toast toast-end z-50" x-data>
|
||||||
|
<template x-for="toast in $store.toast?.messages || []" :key="toast.id">
|
||||||
|
<div
|
||||||
|
class="alert shadow-xl backdrop-blur-sm"
|
||||||
|
:class="{
|
||||||
|
'alert-success bg-success/90': toast.type === 'success',
|
||||||
|
'alert-error bg-error/90': toast.type === 'error',
|
||||||
|
'alert-info bg-info/90': toast.type === 'info',
|
||||||
|
'alert-warning bg-warning/90': toast.type === 'warning'
|
||||||
|
}"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0 translate-x-8"
|
||||||
|
x-transition:enter-end="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave-end="opacity-0 translate-x-8"
|
||||||
|
@click="$store.toast.dismiss(toast.id)"
|
||||||
|
>
|
||||||
|
<span x-text="toast.message" class="font-medium"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if(window.lucide) lucide.createIcons();
|
||||||
|
});
|
||||||
|
// Set Base URL for JS
|
||||||
|
window.BASEURL = '<?= base_url() ?>';
|
||||||
|
|
||||||
|
// Toast Store for Alpine
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.store('toast', {
|
||||||
|
messages: [],
|
||||||
|
show(message, type = 'info') {
|
||||||
|
const id = Date.now();
|
||||||
|
this.messages.push({ id, message, type });
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dismiss(id);
|
||||||
|
}, 4000);
|
||||||
|
},
|
||||||
|
dismiss(id) {
|
||||||
|
this.messages = this.messages.filter(m => m.id !== id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?= $this->renderSection('script') ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
218
app/Views/layouts/v2_auth.php
Normal file
218
app/Views/layouts/v2_auth.php
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="clqms">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= $title ?? 'Login' ?> - CLQMS</title>
|
||||||
|
<meta name="description" content="Sign in to CLQMS - Clinical Laboratory Quality Management System">
|
||||||
|
|
||||||
|
<!-- Tailwind 4 + DaisyUI 5 CDN -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
|
||||||
|
<!-- Google Fonts - Inter -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Alpine.js -->
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Lucide Icons (local) -->
|
||||||
|
<script src="<?= base_url('assets/js/lucide.min.js') ?>"></script>
|
||||||
|
|
||||||
|
<style type="text/tailwindcss">
|
||||||
|
/* Custom CLQMS Medical Theme */
|
||||||
|
[data-theme="clqms"] {
|
||||||
|
--color-base-100: oklch(98% 0.005 240);
|
||||||
|
--color-base-200: oklch(95% 0.01 240);
|
||||||
|
--color-base-300: oklch(90% 0.015 240);
|
||||||
|
--color-base-content: oklch(25% 0.02 240);
|
||||||
|
|
||||||
|
--color-primary: oklch(55% 0.2 175);
|
||||||
|
--color-primary-content: oklch(100% 0 0);
|
||||||
|
|
||||||
|
--color-secondary: oklch(60% 0.15 250);
|
||||||
|
--color-secondary-content: oklch(100% 0 0);
|
||||||
|
|
||||||
|
--color-accent: oklch(70% 0.2 140);
|
||||||
|
--color-accent-content: oklch(20% 0.05 140);
|
||||||
|
|
||||||
|
--color-neutral: oklch(30% 0.02 250);
|
||||||
|
--color-neutral-content: oklch(95% 0.01 250);
|
||||||
|
|
||||||
|
--color-info: oklch(65% 0.2 230);
|
||||||
|
--color-info-content: oklch(100% 0 0);
|
||||||
|
|
||||||
|
--color-success: oklch(65% 0.2 145);
|
||||||
|
--color-success-content: oklch(100% 0 0);
|
||||||
|
|
||||||
|
--color-warning: oklch(80% 0.18 85);
|
||||||
|
--color-warning-content: oklch(25% 0.05 85);
|
||||||
|
|
||||||
|
--color-error: oklch(60% 0.25 25);
|
||||||
|
--color-error-content: oklch(100% 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-20px) rotate(5deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 20px rgba(45, 212, 191, 0.3); }
|
||||||
|
50% { box-shadow: 0 0 40px rgba(45, 212, 191, 0.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-shift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated gradient background */
|
||||||
|
.auth-gradient-bg {
|
||||||
|
background: linear-gradient(-45deg,
|
||||||
|
oklch(95% 0.03 175),
|
||||||
|
oklch(97% 0.02 200),
|
||||||
|
oklch(96% 0.03 250),
|
||||||
|
oklch(98% 0.01 180)
|
||||||
|
);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradient-shift 15s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating decoration animation */
|
||||||
|
.float-animation {
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-animation-delayed {
|
||||||
|
animation: float 8s ease-in-out infinite;
|
||||||
|
animation-delay: -3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card glow effect */
|
||||||
|
.card-glow {
|
||||||
|
animation: pulse-glow 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass morphism */
|
||||||
|
.glass-card {
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Medical pattern overlay */
|
||||||
|
.medical-pattern {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 1px 1px, rgba(45, 212, 191, 0.08) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DNA helix pattern */
|
||||||
|
.dna-pattern {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent,
|
||||||
|
transparent 10px,
|
||||||
|
rgba(45, 212, 191, 0.03) 10px,
|
||||||
|
rgba(45, 212, 191, 0.03) 20px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<?= $this->renderSection('styles') ?>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen font-sans antialiased auth-gradient-bg">
|
||||||
|
|
||||||
|
<main class="min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Background Decorations -->
|
||||||
|
<div class="absolute inset-0 z-0 medical-pattern dna-pattern"></div>
|
||||||
|
|
||||||
|
<!-- Floating orbs -->
|
||||||
|
<div class="absolute top-20 left-20 w-64 h-64 bg-primary/20 rounded-full blur-3xl float-animation"></div>
|
||||||
|
<div class="absolute bottom-20 right-20 w-80 h-80 bg-secondary/20 rounded-full blur-3xl float-animation-delayed"></div>
|
||||||
|
<div class="absolute top-1/2 left-1/4 w-40 h-40 bg-accent/15 rounded-full blur-2xl float-animation" style="animation-delay: -2s;"></div>
|
||||||
|
|
||||||
|
<!-- Medical icons decorations (hidden on mobile) -->
|
||||||
|
<div class="hidden lg:block absolute top-32 right-32 text-primary/10 float-animation-delayed">
|
||||||
|
<i data-lucide="test-tube-2" class="w-16 h-16"></i>
|
||||||
|
</div>
|
||||||
|
<div class="hidden lg:block absolute bottom-32 left-32 text-secondary/10 float-animation">
|
||||||
|
<i data-lucide="microscope" class="w-20 h-20"></i>
|
||||||
|
</div>
|
||||||
|
<div class="hidden lg:block absolute top-1/4 left-16 text-accent/10 float-animation-delayed" style="animation-delay: -1s;">
|
||||||
|
<i data-lucide="dna" class="w-12 h-12"></i>
|
||||||
|
</div>
|
||||||
|
<div class="hidden lg:block absolute bottom-1/4 right-16 text-primary/10 float-animation" style="animation-delay: -4s;">
|
||||||
|
<i data-lucide="heart-pulse" class="w-14 h-14"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="relative z-10 w-full max-w-6xl p-4">
|
||||||
|
<?= $this->renderSection('content') ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div class="toast toast-top toast-center z-50" x-data>
|
||||||
|
<template x-for="toast in $store.toast?.messages || []" :key="toast.id">
|
||||||
|
<div
|
||||||
|
class="alert shadow-xl backdrop-blur-sm"
|
||||||
|
:class="{
|
||||||
|
'alert-success bg-success/90': toast.type === 'success',
|
||||||
|
'alert-error bg-error/90': toast.type === 'error',
|
||||||
|
'alert-info bg-info/90': toast.type === 'info',
|
||||||
|
'alert-warning bg-warning/90': toast.type === 'warning'
|
||||||
|
}"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-8 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 -translate-y-8 scale-95"
|
||||||
|
>
|
||||||
|
<span x-text="toast.message" class="font-medium"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if(window.lucide) lucide.createIcons();
|
||||||
|
});
|
||||||
|
// Set Base URL for JS
|
||||||
|
window.BASEURL = '<?= base_url() ?>';
|
||||||
|
|
||||||
|
// Toast Store for Alpine
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.store('toast', {
|
||||||
|
messages: [],
|
||||||
|
show(message, type = 'info') {
|
||||||
|
const id = Date.now();
|
||||||
|
this.messages.push({ id, message, type });
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dismiss(id);
|
||||||
|
}, 4000);
|
||||||
|
},
|
||||||
|
dismiss(id) {
|
||||||
|
this.messages = this.messages.filter(m => m.id !== id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?= $this->renderSection('script') ?>
|
||||||
|
<?= $this->renderSection('scripts') ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,40 +1,328 @@
|
|||||||
<?= $this->extend('layouts/main') ?>
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
<?= $this->section('content') ?>
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
<div style="min-height: 100vh; padding: 2rem;">
|
<div class="space-y-6">
|
||||||
<div class="card card-glass fade-in" style="max-width: 600px; margin: 2rem auto; text-align: center;">
|
|
||||||
|
|
||||||
<div class="login-logo" style="margin-bottom: 1.5rem;">
|
<!-- Welcome Hero Section -->
|
||||||
<i data-lucide="layout-dashboard"></i>
|
<div class="card bg-gradient-to-br from-primary via-primary to-secondary text-primary-content shadow-xl overflow-hidden relative">
|
||||||
|
<div class="absolute inset-0 opacity-10">
|
||||||
|
<svg viewBox="0 0 400 200" class="w-full h-full" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<pattern id="hero-grid" width="30" height="30" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="white" stroke-width="0.5"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#hero-grid)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating decorative icons -->
|
||||||
|
<div class="absolute top-6 right-8 opacity-20 hidden lg:block">
|
||||||
|
<i data-lucide="microscope" class="w-20 h-20"></i>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-4 right-32 opacity-15 hidden lg:block">
|
||||||
|
<i data-lucide="test-tube-2" class="w-12 h-12"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body relative z-10 p-8">
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/20">
|
||||||
|
<span class="text-2xl font-bold"><?= substr(esc($user->username ?? 'U'), 0, 1) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl lg:text-3xl font-bold">Welcome back, <?= esc($user->username ?? 'User') ?>!</h1>
|
||||||
|
<p class="text-primary-content/70 text-sm flex items-center gap-2 mt-1">
|
||||||
|
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||||
|
<?= date('l, F j, Y') ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-primary-content/80 max-w-xl">
|
||||||
|
Your laboratory is running smoothly. You have
|
||||||
|
<span class="font-bold bg-white/20 px-2 py-0.5 rounded-lg">3 pending tests</span>
|
||||||
|
and <span class="font-bold text-warning">2 alerts</span> requiring attention.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="btn btn-warning shadow-lg gap-2 hover:scale-105 transition-transform">
|
||||||
|
<i data-lucide="bell-ring" class="w-4 h-4"></i>
|
||||||
|
View Alerts
|
||||||
|
<span class="badge badge-sm bg-white/20 border-0">2</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn bg-white/20 border-white/30 text-white hover:bg-white/30 gap-2">
|
||||||
|
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||||
|
New Order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats Grid -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
|
||||||
|
<!-- Patients Today -->
|
||||||
|
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 border border-base-200/50 group">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wider text-base-content/50 font-semibold mb-1">Patients Today</p>
|
||||||
|
<h3 class="text-3xl font-extrabold text-primary">24</h3>
|
||||||
|
<p class="text-xs text-success flex items-center gap-1 mt-2 font-medium">
|
||||||
|
<i data-lucide="trending-up" class="w-3 h-3"></i>
|
||||||
|
+14% from yesterday
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||||
|
<i data-lucide="users" class="w-6 h-6 text-primary"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Tests -->
|
||||||
|
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 border border-base-200/50 group">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wider text-base-content/50 font-semibold mb-1">Pending Tests</p>
|
||||||
|
<h3 class="text-3xl font-extrabold text-secondary">15</h3>
|
||||||
|
<p class="text-xs text-warning flex items-center gap-1 mt-2 font-medium">
|
||||||
|
<i data-lucide="alert-triangle" class="w-3 h-3"></i>
|
||||||
|
5 urgent priority
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-secondary/10 flex items-center justify-center group-hover:bg-secondary/20 transition-colors">
|
||||||
|
<i data-lucide="test-tube" class="w-6 h-6 text-secondary"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Completed -->
|
||||||
|
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 border border-base-200/50 group">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wider text-base-content/50 font-semibold mb-1">Completed</p>
|
||||||
|
<h3 class="text-3xl font-extrabold text-success">42</h3>
|
||||||
|
<p class="text-xs text-success flex items-center gap-1 mt-2 font-medium">
|
||||||
|
<i data-lucide="check-circle" class="w-3 h-3"></i>
|
||||||
|
All validated
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-success/10 flex items-center justify-center group-hover:bg-success/20 transition-colors">
|
||||||
|
<i data-lucide="file-check-2" class="w-6 h-6 text-success"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Efficiency -->
|
||||||
|
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 border border-base-200/50 group">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wider text-base-content/50 font-semibold mb-1">Efficiency</p>
|
||||||
|
<h3 class="text-3xl font-extrabold text-info">86<span class="text-lg">%</span></h3>
|
||||||
|
<p class="text-xs text-base-content/50 mt-2">Week over week</p>
|
||||||
|
</div>
|
||||||
|
<div class="radial-progress text-info text-xs font-bold" style="--value:86; --size:3rem; --thickness:4px;" role="progressbar">
|
||||||
|
86%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="lg:col-span-2 card bg-base-100 shadow-lg border border-base-200/50">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="card-title text-lg font-bold flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-warning/20 flex items-center justify-center">
|
||||||
|
<i data-lucide="zap" class="w-4 h-4 text-warning"></i>
|
||||||
|
</div>
|
||||||
|
Quick Actions
|
||||||
|
</h2>
|
||||||
|
<span class="badge badge-ghost text-xs">Most used</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<a href="#" class="card bg-base-200/50 hover:bg-primary hover:text-primary-content border border-base-300/50 hover:border-primary transition-all duration-200 hover:scale-[1.02] hover:shadow-lg group">
|
||||||
|
<div class="card-body items-center text-center p-5">
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-primary/10 group-hover:bg-white/20 flex items-center justify-center mb-2 transition-colors">
|
||||||
|
<i data-lucide="user-plus" class="w-6 h-6 group-hover:scale-110 transition-transform"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold">Add Patient</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="#" class="card bg-base-200/50 hover:bg-secondary hover:text-secondary-content border border-base-300/50 hover:border-secondary transition-all duration-200 hover:scale-[1.02] hover:shadow-lg group">
|
||||||
|
<div class="card-body items-center text-center p-5">
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-secondary/10 group-hover:bg-white/20 flex items-center justify-center mb-2 transition-colors">
|
||||||
|
<i data-lucide="flask-conical" class="w-6 h-6 group-hover:scale-110 transition-transform"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold">New Order</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="#" class="card bg-base-200/50 hover:bg-accent hover:text-accent-content border border-base-300/50 hover:border-accent transition-all duration-200 hover:scale-[1.02] hover:shadow-lg group">
|
||||||
|
<div class="card-body items-center text-center p-5">
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-accent/10 group-hover:bg-white/20 flex items-center justify-center mb-2 transition-colors">
|
||||||
|
<i data-lucide="printer" class="w-6 h-6 group-hover:scale-110 transition-transform"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold">Print Labels</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="#" class="card bg-base-200/50 hover:bg-info hover:text-info-content border border-base-300/50 hover:border-info transition-all duration-200 hover:scale-[1.02] hover:shadow-lg group">
|
||||||
|
<div class="card-body items-center text-center p-5">
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-info/10 group-hover:bg-white/20 flex items-center justify-center mb-2 transition-colors">
|
||||||
|
<i data-lucide="search" class="w-6 h-6 group-hover:scale-110 transition-transform"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold">Search Results</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Status -->
|
||||||
|
<div class="card bg-base-100 shadow-lg border border-base-200/50">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-lg font-bold flex items-center gap-2 mb-4">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-success/20 flex items-center justify-center">
|
||||||
|
<i data-lucide="activity" class="w-4 h-4 text-success"></i>
|
||||||
|
</div>
|
||||||
|
System Status
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between p-3 bg-base-200/30 rounded-xl hover:bg-base-200/50 transition-colors">
|
||||||
|
<span class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="relative flex h-2.5 w-2.5">
|
||||||
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
|
||||||
|
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-success"></span>
|
||||||
|
</span>
|
||||||
|
LIS Connection
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-success badge-sm font-semibold">Online</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between p-3 bg-base-200/30 rounded-xl hover:bg-base-200/50 transition-colors">
|
||||||
|
<span class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="relative flex h-2.5 w-2.5">
|
||||||
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
|
||||||
|
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-success"></span>
|
||||||
|
</span>
|
||||||
|
Database
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-success badge-sm font-semibold">Optimal</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between p-3 bg-warning/5 rounded-xl border border-warning/20">
|
||||||
|
<span class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="relative flex h-2.5 w-2.5">
|
||||||
|
<span class="animate-pulse absolute inline-flex h-full w-full rounded-full bg-warning opacity-75"></span>
|
||||||
|
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-warning"></span>
|
||||||
|
</span>
|
||||||
|
Analyzer Interface
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-warning badge-sm font-semibold">Syncing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider my-4"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<a href="<?= site_url('logout') ?>" class="btn btn-error btn-outline btn-sm gap-2 hover:scale-[1.02] transition-transform">
|
||||||
|
<i data-lucide="log-out" class="w-4 h-4"></i>
|
||||||
|
End Session
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 style="margin-bottom: 0.5rem;">🎉 Welcome to Dashboard!</h1>
|
<!-- Recent Activity Section -->
|
||||||
<p class="text-muted" style="margin-bottom: 2rem;">
|
<div class="card bg-base-100 shadow-lg border border-base-200/50">
|
||||||
You're successfully logged in. This is a placeholder page.
|
<div class="card-body">
|
||||||
</p>
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="card-title text-lg font-bold flex items-center gap-2">
|
||||||
<?php if (isset($user)): ?>
|
<div class="w-8 h-8 rounded-lg bg-info/20 flex items-center justify-center">
|
||||||
<div class="alert alert-success" style="text-align: left;">
|
<i data-lucide="clock" class="w-4 h-4 text-info"></i>
|
||||||
<i data-lucide="check-circle" style="width: 18px; height: 18px;"></i>
|
</div>
|
||||||
<span>Logged in as: <strong><?= esc($user->username ?? 'User') ?></strong></span>
|
Recent Activity
|
||||||
</div>
|
</h2>
|
||||||
<?php endif; ?>
|
<a href="#" class="btn btn-ghost btn-sm gap-1">
|
||||||
|
View All
|
||||||
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
|
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||||
<a href="/login" class="btn btn-secondary">
|
</a>
|
||||||
<i data-lucide="arrow-left" style="width: 18px; height: 18px;"></i>
|
</div>
|
||||||
Back to Login
|
|
||||||
</a>
|
<div class="overflow-x-auto">
|
||||||
<form action="/logout" method="get" style="margin: 0;">
|
<table class="table table-zebra">
|
||||||
<button type="submit" class="btn btn-primary">
|
<thead>
|
||||||
<i data-lucide="log-out" style="width: 18px; height: 18px;"></i>
|
<tr class="text-xs uppercase tracking-wider">
|
||||||
Logout
|
<th>Time</th>
|
||||||
</button>
|
<th>Action</th>
|
||||||
</form>
|
<th>Details</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="hover">
|
||||||
|
<td class="font-mono text-sm text-base-content/70">09:45 AM</td>
|
||||||
|
<td class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-success/10 flex items-center justify-center">
|
||||||
|
<i data-lucide="check" class="w-4 h-4 text-success"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium">Test Validated</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-base-content/70">CBC Panel - Patient #1234</td>
|
||||||
|
<td><span class="badge badge-success badge-sm">Complete</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover">
|
||||||
|
<td class="font-mono text-sm text-base-content/70">09:30 AM</td>
|
||||||
|
<td class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<i data-lucide="flask-conical" class="w-4 h-4 text-primary"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium">New Order Created</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-base-content/70">Lipid Profile - Patient #5678</td>
|
||||||
|
<td><span class="badge badge-info badge-sm">Processing</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover">
|
||||||
|
<td class="font-mono text-sm text-base-content/70">09:15 AM</td>
|
||||||
|
<td class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-warning/10 flex items-center justify-center">
|
||||||
|
<i data-lucide="alert-triangle" class="w-4 h-4 text-warning"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium">QC Alert</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-base-content/70">Glucose analyzer - Calibration needed</td>
|
||||||
|
<td><span class="badge badge-warning badge-sm">Pending</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
@ -1,130 +1,233 @@
|
|||||||
<?= $this->extend('layouts/main') ?>
|
<?= $this->extend('layouts/v2_auth') ?>
|
||||||
|
|
||||||
<?= $this->section('content') ?>
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
<div class="login-container">
|
<div class="card lg:card-side bg-base-100/95 glass-card shadow-2xl border border-white/20 overflow-hidden max-w-4xl mx-auto card-glow" x-data="loginForm" x-ref="loginCard">
|
||||||
<div class="login-card card card-glass fade-in" x-data="loginForm" x-ref="loginCard">
|
|
||||||
|
<!-- Illustration Side -->
|
||||||
|
<div class="card-body lg:w-1/2 flex flex-col justify-center items-center text-center p-10 relative overflow-hidden bg-gradient-to-br from-primary via-primary to-secondary">
|
||||||
|
|
||||||
|
<!-- Abstract geometric patterns -->
|
||||||
|
<div class="absolute inset-0 opacity-20">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full">
|
||||||
|
<svg viewBox="0 0 400 400" class="w-full h-full" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="white" stroke-width="0.5"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#grid)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating molecules decoration -->
|
||||||
|
<div class="absolute top-8 right-8 opacity-30">
|
||||||
|
<i data-lucide="atom" class="w-12 h-12 text-white float-animation"></i>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-12 left-8 opacity-30">
|
||||||
|
<i data-lucide="flask-conical" class="w-10 h-10 text-white float-animation-delayed"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 space-y-6">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="relative">
|
||||||
|
<div class="bg-white/20 backdrop-blur-md rounded-3xl w-28 h-28 flex items-center justify-center ring-4 ring-white/10 mx-auto shadow-2xl">
|
||||||
|
<img src="<?= base_url('assets/images/logo.png') ?>" alt="CLQMS Logo" class="w-16 h-16 drop-shadow-lg">
|
||||||
|
</div>
|
||||||
|
<div class="absolute -bottom-2 left-1/2 -translate-x-1/2">
|
||||||
|
<span class="badge badge-sm bg-white/20 text-white border-0 backdrop-blur-sm">Secure</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="space-y-2 pt-4">
|
||||||
|
<h1 class="text-4xl font-extrabold text-white tracking-tight drop-shadow-lg">CLQMS</h1>
|
||||||
|
<div class="h-1 w-16 bg-white/40 rounded-full mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="text-white/85 text-lg max-w-xs mx-auto leading-relaxed">
|
||||||
|
Clinical Laboratory<br>
|
||||||
|
<span class="font-semibold">Quality Management System</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Features badges -->
|
||||||
|
<div class="flex flex-wrap justify-center gap-2 pt-4">
|
||||||
|
<span class="badge badge-lg bg-white/15 text-white border-white/20 gap-1 backdrop-blur-sm">
|
||||||
|
<i data-lucide="shield-check" class="w-3 h-3"></i>
|
||||||
|
ISO 15189
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-lg bg-white/15 text-white border-white/20 gap-1 backdrop-blur-sm">
|
||||||
|
<i data-lucide="lock" class="w-3 h-3"></i>
|
||||||
|
HIPAA
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version badge -->
|
||||||
|
<div class="badge badge-outline badge-lg text-white/60 border-white/20 mt-4">v2.0.0</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom gradient fade -->
|
||||||
|
<div class="absolute bottom-0 w-full h-32 bg-gradient-to-t from-black/20 to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Side -->
|
||||||
|
<div class="card-body lg:w-1/2 p-8 lg:p-12 bg-base-100/50">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="login-header">
|
<div class="mb-8 text-center lg:text-left">
|
||||||
<div class="login-logo">
|
<h2 class="text-2xl font-bold text-base-content mb-2">Welcome Back!</h2>
|
||||||
<i data-lucide="flask-conical"></i>
|
<p class="text-base-content/60">Sign in to access your laboratory dashboard</p>
|
||||||
</div>
|
|
||||||
<h1 class="login-title">Welcome Back!</h1>
|
|
||||||
<p class="login-subtitle">Sign in to your CLQMS account</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Alert -->
|
<!-- Error Alert -->
|
||||||
<template x-if="error">
|
<template x-if="error">
|
||||||
<div class="alert alert-error" x-transition>
|
<div class="alert alert-error mb-6 shadow-lg rounded-xl text-sm" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
|
||||||
<i data-lucide="alert-circle" style="width: 18px; height: 18px;"></i>
|
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||||
<span x-text="error"></span>
|
<span x-text="error"></span>
|
||||||
|
<button @click="error = null" class="btn btn-ghost btn-xs btn-circle">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form @submit.prevent="submitLogin">
|
<form @submit.prevent="submitLogin" class="space-y-5">
|
||||||
|
|
||||||
<!-- Username Field -->
|
<!-- Username Field -->
|
||||||
<div class="form-group">
|
<div class="form-control w-full">
|
||||||
<label class="form-label" for="username">Username</label>
|
<label class="label pb-1">
|
||||||
<div class="form-input-icon">
|
<span class="label-text font-semibold text-base-content/80">Username</span>
|
||||||
<i data-lucide="user" class="icon" style="width: 18px; height: 18px;"></i>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="username"
|
class="input input-bordered w-full focus:input-primary focus:shadow-lg focus:shadow-primary/10 transition-all"
|
||||||
class="form-input"
|
placeholder="Enter your username"
|
||||||
placeholder="Enter your username"
|
x-model="username"
|
||||||
x-model="username"
|
:disabled="isLoading"
|
||||||
:disabled="isLoading"
|
autocomplete="username"
|
||||||
autocomplete="username"
|
autofocus
|
||||||
autofocus
|
/>
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password Field -->
|
<!-- Password Field -->
|
||||||
<div class="form-group">
|
<div class="form-control w-full">
|
||||||
<label class="form-label" for="password">Password</label>
|
<label class="label pb-1 justify-between">
|
||||||
<div class="form-input-icon">
|
<span class="label-text font-semibold text-base-content/80">Password</span>
|
||||||
<i data-lucide="lock" class="icon" style="width: 18px; height: 18px;"></i>
|
<a href="#" class="link link-primary link-hover text-xs font-medium">Forgot password?</a>
|
||||||
<input
|
</label>
|
||||||
:type="showPassword ? 'text' : 'password'"
|
<input
|
||||||
id="password"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
class="form-input"
|
class="input input-bordered w-full focus:input-primary focus:shadow-lg focus:shadow-primary/10 transition-all"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
x-model="password"
|
x-model="password"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
style="padding-right: 3rem;"
|
/>
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="password-toggle"
|
|
||||||
@click="togglePassword"
|
|
||||||
:title="showPassword ? 'Hide password' : 'Show password'"
|
|
||||||
>
|
|
||||||
<i :data-lucide="showPassword ? 'eye-off' : 'eye'" style="width: 18px; height: 18px;"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remember Me & Forgot Password -->
|
<!-- Remember Me -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="form-control">
|
||||||
<label class="checkbox-wrapper">
|
<label class="label cursor-pointer justify-start gap-3 py-3 hover:bg-base-200/50 rounded-xl px-2 -mx-2 transition-colors">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox-input"
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
x-model="rememberMe"
|
x-model="rememberMe"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
>
|
/>
|
||||||
<span class="checkbox-label">Remember me</span>
|
<span class="label-text text-base-content/70">Remember me on this device</span>
|
||||||
</label>
|
</label>
|
||||||
<a href="#" class="text-sm text-primary">Forgot password?</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<button
|
<div class="form-control pt-2">
|
||||||
type="submit"
|
<button
|
||||||
class="btn btn-primary btn-lg btn-block"
|
type="submit"
|
||||||
:disabled="isLoading"
|
class="btn btn-primary btn-block shadow-xl shadow-primary/30 hover:shadow-primary/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200 font-bold text-base rounded-xl h-12"
|
||||||
>
|
:disabled="isLoading"
|
||||||
<template x-if="isLoading">
|
>
|
||||||
<div class="spinner"></div>
|
<template x-if="isLoading">
|
||||||
</template>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
<template x-if="!isLoading">
|
</template>
|
||||||
<i data-lucide="log-in" style="width: 20px; height: 20px;"></i>
|
<span x-text="isLoading ? 'Signing in...' : 'Sign In'"></span>
|
||||||
</template>
|
<i x-show="!isLoading" data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||||
<span x-text="isLoading ? 'Signing in...' : 'Sign In'"></span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="divider text-xs text-base-content/30 my-6">SECURE CONNECTION</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="login-footer">
|
<div class="text-center space-y-2">
|
||||||
<p class="text-muted">
|
<div class="flex justify-center gap-4">
|
||||||
© <?= date('Y') ?> CLQMS • Clinical Laboratory QMS
|
<span class="badge badge-ghost badge-sm gap-1">
|
||||||
|
<i data-lucide="shield" class="w-3 h-3"></i>
|
||||||
|
256-bit SSL
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-ghost badge-sm gap-1">
|
||||||
|
<i data-lucide="check-circle" class="w-3 h-3"></i>
|
||||||
|
Protected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/40 pt-2">
|
||||||
|
© <?= date('Y') ?> Clinical Laboratory QMS
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?= $this->endSection() ?>
|
|
||||||
|
|
||||||
<?= $this->section('scripts') ?>
|
|
||||||
<script>
|
<script>
|
||||||
// Re-initialize Lucide icons after Alpine updates the DOM
|
document.addEventListener('alpine:init', () => {
|
||||||
document.addEventListener('alpine:initialized', () => {
|
Alpine.data('loginForm', () => ({
|
||||||
// Watch for DOM changes and re-create icons
|
username: '',
|
||||||
const observer = new MutationObserver(() => {
|
password: '',
|
||||||
lucide.createIcons();
|
rememberMe: false,
|
||||||
|
showPassword: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
togglePassword() {
|
||||||
|
this.showPassword = !this.showPassword;
|
||||||
|
this.$nextTick(() => lucide.createIcons());
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitLogin() {
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
if (!this.username || !this.password) {
|
||||||
|
this.error = 'Please enter both username and password.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(r => setTimeout(r, 800));
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '';
|
||||||
|
|
||||||
|
const u = document.createElement('input'); u.name = 'username'; u.value = this.username; form.appendChild(u);
|
||||||
|
const p = document.createElement('input'); p.name = 'password'; p.value = this.password; form.appendChild(p);
|
||||||
|
if(this.rememberMe) {
|
||||||
|
const r = document.createElement('input'); r.name = 'remember'; r.value = '1'; form.appendChild(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'An unexpected error occurred. Please try again.';
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
37
app/Views/v2/README.md
Normal file
37
app/Views/v2/README.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# V2 Frontend - Internal Dev UI
|
||||||
|
|
||||||
|
> 🔒 **Private**: This is a hidden frontend for backend development. Not for team use.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
- **URL**: `/v2`
|
||||||
|
- **Auth**: JWT (same login as main app)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- Quick overview and system stats
|
||||||
|
|
||||||
|
### API Tester
|
||||||
|
- Interactive REST client
|
||||||
|
- Test all CLQMS endpoints
|
||||||
|
- Supports GET, POST, PATCH, DELETE
|
||||||
|
|
||||||
|
### Database Browser
|
||||||
|
- View database tables
|
||||||
|
- Quick data inspection
|
||||||
|
|
||||||
|
### Logs Viewer
|
||||||
|
- Read CI4 application logs
|
||||||
|
- Filter by date and level
|
||||||
|
|
||||||
|
### JWT Decoder
|
||||||
|
- Inspect current token
|
||||||
|
- View claims and expiry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### 2024-12-22
|
||||||
|
- Initial V2 frontend created
|
||||||
|
- Added dashboard, API tester, DB browser, logs, JWT decoder
|
||||||
68
app/Views/v2/api-tester.php
Normal file
68
app/Views/v2/api-tester.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow" x-data="apiTester">
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<!-- Request Form -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
|
<select x-model="method" class="select select-bordered w-full sm:w-32">
|
||||||
|
<option value="GET">GET</option>
|
||||||
|
<option value="POST">POST</option>
|
||||||
|
<option value="PATCH">PATCH</option>
|
||||||
|
<option value="DELETE">DELETE</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="url"
|
||||||
|
placeholder="Enter URL (e.g., /api/patient)"
|
||||||
|
class="input input-bordered flex-1 font-mono"
|
||||||
|
>
|
||||||
|
<button @click="sendRequest" :disabled="loading" class="btn btn-primary gap-2">
|
||||||
|
<span class="loading loading-spinner loading-sm" x-show="loading"></span>
|
||||||
|
<i data-lucide="send" class="w-4 h-4" x-show="!loading"></i>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Body (for POST/PATCH) -->
|
||||||
|
<div x-show="method === 'POST' || method === 'PATCH'" x-transition class="mt-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Request Body (JSON)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
x-model="body"
|
||||||
|
class="textarea textarea-bordered w-full font-mono h-32"
|
||||||
|
placeholder='{ "key": "value" }'
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Endpoints -->
|
||||||
|
<div class="flex flex-wrap gap-2 mt-4">
|
||||||
|
<span class="text-base-content/60 text-sm">Quick:</span>
|
||||||
|
<button @click="setEndpoint('GET', '<?= site_url('api/patient') ?>')" class="btn btn-xs btn-ghost">Patients</button>
|
||||||
|
<button @click="setEndpoint('GET', '<?= site_url('api/tests') ?>')" class="btn btn-xs btn-ghost">Tests</button>
|
||||||
|
<button @click="setEndpoint('GET', '<?= site_url('api/valueset') ?>')" class="btn btn-xs btn-ghost">ValueSets</button>
|
||||||
|
<button @click="setEndpoint('GET', '<?= site_url('api/location') ?>')" class="btn btn-xs btn-ghost">Locations</button>
|
||||||
|
<button @click="setEndpoint('GET', '<?= site_url('api/organization/site') ?>')" class="btn btn-xs btn-ghost">Sites</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Response -->
|
||||||
|
<div class="card bg-base-100 shadow mt-4" x-data x-show="$store.apiResponse.hasResponse" x-transition>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="card-title">Response</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="badge" :class="$store.apiResponse.statusClass" x-text="$store.apiResponse.status"></div>
|
||||||
|
<span class="text-base-content/60 text-sm" x-text="$store.apiResponse.time + 'ms'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="bg-base-200 p-4 rounded-lg overflow-auto max-h-96 text-sm font-mono"><code x-text="$store.apiResponse.body"></code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
169
app/Views/v2/dashboard.php
Normal file
169
app/Views/v2/dashboard.php
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div x-data="dashboardComponent()">
|
||||||
|
<!-- Welcome Card -->
|
||||||
|
<div class="card bg-gradient-to-br from-primary via-primary to-secondary text-primary-content shadow-xl mb-6 overflow-hidden relative">
|
||||||
|
<div class="absolute top-4 right-6 opacity-15 hidden lg:block">
|
||||||
|
<i data-lucide="dna" class="w-16 h-16"></i>
|
||||||
|
</div>
|
||||||
|
<div class="card-body relative z-10 p-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center gap-4">
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/20">
|
||||||
|
<i data-lucide="user-circle" class="w-8 h-8"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-2xl font-bold">Welcome back, <?= esc($user->username ?? 'User') ?>!</h1>
|
||||||
|
<p class="text-primary-content/70 flex items-center gap-2">
|
||||||
|
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||||
|
<span x-text="date"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:flex items-center gap-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-xl">
|
||||||
|
<i data-lucide="activity" class="w-5 h-5 text-success"></i>
|
||||||
|
<span class="font-mono text-lg" x-text="time"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="card bg-base-100 shadow-lg border border-base-200/50 hover:shadow-xl transition-all hover:-translate-y-1">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wider text-base-content/50 font-semibold mb-1">Welcome</p>
|
||||||
|
<h3 class="text-xl font-bold"><?= esc($user->username ?? 'User') ?></h3>
|
||||||
|
<p class="text-xs text-base-content/50 mt-1">Administrator</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-secondary/10 flex items-center justify-center">
|
||||||
|
<i data-lucide="user" class="w-6 h-6 text-secondary"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-lg border border-base-200/50 hover:shadow-xl transition-all hover:-translate-y-1">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wider text-base-content/50 font-semibold mb-1">Current Time</p>
|
||||||
|
<h3 class="text-xl font-bold font-mono text-accent" x-text="time"></h3>
|
||||||
|
<p class="text-xs text-base-content/50 mt-1" x-text="date"></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-accent/10 flex items-center justify-center">
|
||||||
|
<i data-lucide="clock" class="w-6 h-6 text-accent"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-lg border border-base-200/50 hover:shadow-xl transition-all hover:-translate-y-1">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wider text-base-content/50 font-semibold mb-1">Total Patients</p>
|
||||||
|
<h3 class="text-3xl font-extrabold text-primary">
|
||||||
|
<span x-show="totalPatients !== null" x-text="totalPatients"></span>
|
||||||
|
<span x-show="totalPatients === null" class="loading loading-dots loading-sm"></span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<i data-lucide="users" class="w-6 h-6 text-primary"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Patients -->
|
||||||
|
<div class="card bg-base-100 shadow-lg border border-base-200/50 mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="card-title"><i data-lucide="history" class="w-5 h-5 opacity-50"></i> Recent Patients</h2>
|
||||||
|
<a href="<?= site_url('v2/patients') ?>" class="btn btn-sm btn-ghost">View All</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr class="bg-base-200/50"><th>Patient</th><th>Gender</th><th>Birthdate</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-if="loadingPatients"><tr><td colspan="5" class="text-center py-8"><span class="loading loading-spinner text-primary"></span></td></tr></template>
|
||||||
|
<template x-for="p in patients" :key="p.InternalPID">
|
||||||
|
<tr class="hover group">
|
||||||
|
<td><div class="flex items-center gap-3">
|
||||||
|
<div class="avatar placeholder"><div class="bg-gradient-to-br from-primary to-secondary text-white rounded-xl w-10 h-10"><span x-text="p.NameFirst.charAt(0)"></span></div></div>
|
||||||
|
<div><div class="font-bold" x-text="[p.NameFirst, p.NameLast].join(' ')"></div><div class="text-xs opacity-50 font-mono" x-text="'ID: ' + p.PatientID"></div></div>
|
||||||
|
</div></td>
|
||||||
|
<td><span class="badge badge-ghost" x-text="p.Gender"></span></td>
|
||||||
|
<td class="font-mono text-sm" x-text="p.Birthdate"></td>
|
||||||
|
<td><span class="badge badge-success gap-1"><span class="w-1.5 h-1.5 rounded-full bg-success-content/80"></span>Active</span></td>
|
||||||
|
<td class="text-right"><a :href="'<?= site_url('v2/patients/') ?>' + p.InternalPID" class="btn btn-ghost btn-sm opacity-0 group-hover:opacity-100">View</a></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-if="!loadingPatients && patients.length === 0"><tr><td colspan="5" class="text-center py-8 opacity-50">No patients found</td></tr></template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Links -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<a href="<?= site_url('v2/patients') ?>" class="card bg-base-100 hover:bg-primary hover:text-primary-content border border-base-200/50 transition-all hover:scale-[1.02] group">
|
||||||
|
<div class="card-body items-center text-center p-4">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-primary/10 group-hover:bg-white/20 flex items-center justify-center mb-2"><i data-lucide="users" class="w-5 h-5"></i></div>
|
||||||
|
<span class="text-sm font-semibold">Patients</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="<?= site_url('v2/valuesets') ?>" class="card bg-base-100 hover:bg-secondary hover:text-secondary-content border border-base-200/50 transition-all hover:scale-[1.02] group">
|
||||||
|
<div class="card-body items-center text-center p-4">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-secondary/10 group-hover:bg-white/20 flex items-center justify-center mb-2"><i data-lucide="list-tree" class="w-5 h-5"></i></div>
|
||||||
|
<span class="text-sm font-semibold">Value Sets</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="<?= site_url('v2/api-tester') ?>" class="card bg-base-100 hover:bg-accent hover:text-accent-content border border-base-200/50 transition-all hover:scale-[1.02] group">
|
||||||
|
<div class="card-body items-center text-center p-4">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-accent/10 group-hover:bg-white/20 flex items-center justify-center mb-2"><i data-lucide="terminal" class="w-5 h-5"></i></div>
|
||||||
|
<span class="text-sm font-semibold">API Tester</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="<?= site_url('v2/db-browser') ?>" class="card bg-base-100 hover:bg-info hover:text-info-content border border-base-200/50 transition-all hover:scale-[1.02] group">
|
||||||
|
<div class="card-body items-center text-center p-4">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-info/10 group-hover:bg-white/20 flex items-center justify-center mb-2"><i data-lucide="database" class="w-5 h-5"></i></div>
|
||||||
|
<span class="text-sm font-semibold">Database</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('script') ?>
|
||||||
|
<script type="module">
|
||||||
|
import Alpine, { Utils } from '<?= base_url('/assets/js/app.js'); ?>';
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('dashboardComponent', () => ({
|
||||||
|
patients: [], loadingPatients: true, totalPatients: null, time: '', date: '',
|
||||||
|
init() { this.updateClock(); setInterval(() => this.updateClock(), 1000); this.fetchPatients(); },
|
||||||
|
updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
this.time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
this.date = now.toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
},
|
||||||
|
async fetchPatients() {
|
||||||
|
this.loadingPatients = true;
|
||||||
|
try {
|
||||||
|
const data = await Utils.api('<?= site_url('api/patient') ?>?limit=5&sort=CreateDate:desc');
|
||||||
|
this.patients = data.data || [];
|
||||||
|
this.totalPatients = this.patients.length > 0 ? '24' : '0';
|
||||||
|
} catch(e) { console.error(e); this.totalPatients = '—'; }
|
||||||
|
finally { this.loadingPatients = false; setTimeout(() => window.lucide?.createIcons(), 50); }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
Alpine.start();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
80
app/Views/v2/db-browser.php
Normal file
80
app/Views/v2/db-browser.php
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row gap-4" x-data="dbBrowser">
|
||||||
|
|
||||||
|
<!-- Tables List -->
|
||||||
|
<div class="card bg-base-100 shadow w-full lg:w-64 lg:shrink-0">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-base">Tables</h3>
|
||||||
|
<template x-if="loadingTables">
|
||||||
|
<div class="flex justify-center py-4">
|
||||||
|
<span class="loading loading-spinner loading-md"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ul class="menu menu-sm bg-base-200 rounded-box max-h-96 lg:max-h-[60vh] overflow-y-auto" x-show="!loadingTables">
|
||||||
|
<template x-for="table in tables" :key="table">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
:class="{ 'active': selectedTable === table }"
|
||||||
|
@click="selectTable(table)"
|
||||||
|
x-text="table"
|
||||||
|
></a>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Panel -->
|
||||||
|
<div class="card bg-base-100 shadow flex-1">
|
||||||
|
<div class="card-body">
|
||||||
|
<template x-if="!selectedTable">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-base-content/50">
|
||||||
|
<i data-lucide="database" class="w-12 h-12 mb-4"></i>
|
||||||
|
<p>Select a table to view data</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="selectedTable && loadingData">
|
||||||
|
<div class="flex justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="selectedTable && !loadingData && tableData">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="card-title" x-text="selectedTable"></h3>
|
||||||
|
<div class="badge badge-primary" x-text="tableData.count + ' rows'"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<template x-for="field in tableData.fields" :key="field.name">
|
||||||
|
<th x-text="field.name"></th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="(row, idx) in tableData.data" :key="idx">
|
||||||
|
<tr>
|
||||||
|
<template x-for="field in tableData.fields" :key="field.name">
|
||||||
|
<td class="max-w-xs truncate" x-text="row[field.name] ?? '-'"></td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
94
app/Views/v2/jwt-decoder.php
Normal file
94
app/Views/v2/jwt-decoder.php
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Current JWT Token</h2>
|
||||||
|
|
||||||
|
<?php if ($token): ?>
|
||||||
|
|
||||||
|
<!-- Raw Token -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Raw Token</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2 bg-base-200 p-3 rounded-lg">
|
||||||
|
<code class="flex-1 text-sm font-mono truncate"><?= esc(substr($token, 0, 60)) ?>...</code>
|
||||||
|
<button
|
||||||
|
onclick="navigator.clipboard.writeText('<?= esc($token) ?>'); this.classList.add('btn-success'); setTimeout(() => this.classList.remove('btn-success'), 1000)"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($decoded): ?>
|
||||||
|
|
||||||
|
<!-- Decoded Header -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Header</span>
|
||||||
|
</label>
|
||||||
|
<pre class="bg-base-200 p-4 rounded-lg text-sm font-mono overflow-auto"><?= json_encode($decoded['header'], JSON_PRETTY_PRINT) ?></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decoded Payload -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Payload</span>
|
||||||
|
</label>
|
||||||
|
<pre class="bg-primary/10 border-l-4 border-primary p-4 rounded-lg text-sm font-mono overflow-auto"><?= json_encode($decoded['payload'], JSON_PRETTY_PRINT) ?></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token Info -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Token Info</span>
|
||||||
|
</label>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<tbody>
|
||||||
|
<?php if (isset($decoded['payload']['exp'])): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="text-base-content/60">Expires</td>
|
||||||
|
<td>
|
||||||
|
<?= date('Y-m-d H:i:s', $decoded['payload']['exp']) ?>
|
||||||
|
<?php
|
||||||
|
$remaining = $decoded['payload']['exp'] - time();
|
||||||
|
if ($remaining > 0):
|
||||||
|
?>
|
||||||
|
<div class="badge badge-success ml-2"><?= round($remaining / 60) ?> min remaining</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="badge badge-error ml-2">Expired</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (isset($decoded['payload']['iat'])): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="text-base-content/60">Issued At</td>
|
||||||
|
<td><?= date('Y-m-d H:i:s', $decoded['payload']['iat']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-base-content/50">
|
||||||
|
<i data-lucide="key" class="w-12 h-12 mb-4"></i>
|
||||||
|
<p>No JWT token found</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
70
app/Views/v2/login.php
Normal file
70
app/Views/v2/login.php
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<?= $this->extend('layouts/v2-login') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div class="card w-full max-w-md bg-base-100/95 backdrop-blur-xl shadow-2xl border border-base-200" x-data="v2LoginForm">
|
||||||
|
<div class="card-body p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-2xl ring-4 ring-primary/20 mx-auto mb-4">
|
||||||
|
<img src="<?= base_url('assets/images/logo.png') ?>" alt="Logo" class="w-12 h-12" onerror="this.style.display='none'; this.parentElement.innerHTML='<i data-lucide=\'flask-conical\' class=\'w-10 h-10 text-white\'></i>'; lucide.createIcons();">
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-base-content mb-2">Welcome to CLQMS</h1>
|
||||||
|
<p class="text-base-content/60 text-sm">Clinical Laboratory Quality Management</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="error">
|
||||||
|
<div class="alert alert-error mb-6" x-transition>
|
||||||
|
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||||
|
<span x-text="error"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitLogin" class="space-y-5">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">Username</span></label>
|
||||||
|
<input type="text" placeholder="Enter your username" class="input input-bordered w-full focus:input-primary" x-model="username" :disabled="isLoading" autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">Password</span></label>
|
||||||
|
<input type="password" placeholder="Enter your password" class="input input-bordered w-full focus:input-primary" x-model="password" :disabled="isLoading">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-block shadow-lg shadow-primary/30 font-bold mt-6" :disabled="isLoading">
|
||||||
|
<span class="loading loading-spinner loading-sm" x-show="isLoading"></span>
|
||||||
|
<span x-show="!isLoading">Sign In</span>
|
||||||
|
<span x-show="isLoading">Signing in...</span>
|
||||||
|
<i data-lucide="arrow-right" class="w-4 h-4" x-show="!isLoading"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider text-xs text-base-content/30 my-6">SECURE CONNECTION</div>
|
||||||
|
<p class="text-center text-xs text-base-content/40">© <?= date('Y') ?> CLQMS - Laboratory Management</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('script') ?>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('v2LoginForm', () => ({
|
||||||
|
username: '', password: '', isLoading: false, error: null,
|
||||||
|
async submitLogin() {
|
||||||
|
this.error = null;
|
||||||
|
if (!this.username.trim()) { this.error = 'Please enter username'; return; }
|
||||||
|
if (!this.password) { this.error = 'Please enter password'; return; }
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('<?= site_url('api/auth/login') ?>', {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'}, credentials: 'include',
|
||||||
|
body: JSON.stringify({ username: this.username.trim(), password: this.password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.status === 'success') window.location.href = '<?= site_url('v2') ?>';
|
||||||
|
else this.error = data.message || 'Invalid credentials';
|
||||||
|
} catch(e) { this.error = 'Connection error'; }
|
||||||
|
finally { this.isLoading = false; }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
51
app/Views/v2/logs.php
Normal file
51
app/Views/v2/logs.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow" x-data="logsViewer">
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="card-title">Application Logs</h2>
|
||||||
|
<button @click="loadLogs" :disabled="loading" class="btn btn-sm btn-ghost gap-2">
|
||||||
|
<span class="loading loading-spinner loading-xs" x-show="loading"></span>
|
||||||
|
<i data-lucide="refresh-cw" class="w-4 h-4" x-show="!loading"></i>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="loading">
|
||||||
|
<div class="flex justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="!loading && logs.length === 0">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-base-content/50">
|
||||||
|
<i data-lucide="file-text" class="w-12 h-12 mb-4"></i>
|
||||||
|
<p>No logs found</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="!loading && logs.length > 0">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<template x-for="log in logs" :key="log.name">
|
||||||
|
<div class="collapse collapse-arrow bg-base-200">
|
||||||
|
<input type="checkbox" :checked="expandedLogs.includes(log.name)" @change="toggleLog(log.name)" />
|
||||||
|
<div class="collapse-title font-medium flex items-center gap-2">
|
||||||
|
<i data-lucide="file-text" class="w-4 h-4"></i>
|
||||||
|
<span x-text="log.name"></span>
|
||||||
|
<span class="badge badge-sm" x-text="formatSize(log.size)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<pre class="text-xs font-mono whitespace-pre-wrap break-all bg-base-300 p-4 rounded-lg max-h-64 overflow-auto" x-text="log.content"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
295
app/Views/v2/organization.php
Normal file
295
app/Views/v2/organization.php
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div x-data="organizationManager()">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">Organization</h2>
|
||||||
|
<p class="text-base-content/60">Manage <span x-text="activeTab + 's'"></span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Removed Tab List -->
|
||||||
|
<!-- The tab is now determined by the URL parameter passed from controller -->
|
||||||
|
|
||||||
|
<!-- Generic Data Table -->
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="card-title capitalize" x-text="activeTab + 's'"></h3>
|
||||||
|
<button @click="openModal()" class="btn btn-primary btn-sm gap-2">
|
||||||
|
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||||
|
Add <span class="capitalize" x-text="activeTab"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<template x-if="isLoading">
|
||||||
|
<div class="flex justify-center p-8">
|
||||||
|
<span class="loading loading-spinner text-primary"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="overflow-x-auto" x-show="!isLoading">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<!-- Dynamic Headers based on type -->
|
||||||
|
<template x-if="activeTab === 'account'"><th>Parent Account</th></template>
|
||||||
|
<template x-if="activeTab === 'site'"><th>Account ID</th></template>
|
||||||
|
<template x-if="activeTab === 'department'"><th>Site ID</th></template>
|
||||||
|
<template x-if="activeTab === 'workstation'"><th>Department ID</th></template>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="row in data" :key="getRowId(row)">
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono text-xs" x-text="getRowId(row)"></td>
|
||||||
|
<td class="font-bold" x-text="getRowName(row)"></td>
|
||||||
|
|
||||||
|
<!-- Account specific -->
|
||||||
|
<template x-if="activeTab === 'account'">
|
||||||
|
<td x-text="row.Parent || '-'"></td>
|
||||||
|
</template>
|
||||||
|
<!-- Site specific -->
|
||||||
|
<template x-if="activeTab === 'site'">
|
||||||
|
<td x-text="row.AccountID || '-'"></td>
|
||||||
|
</template>
|
||||||
|
<!-- Department specific -->
|
||||||
|
<template x-if="activeTab === 'department'">
|
||||||
|
<td x-text="row.SiteID || '-'"></td>
|
||||||
|
</template>
|
||||||
|
<!-- Workstation specific -->
|
||||||
|
<template x-if="activeTab === 'workstation'">
|
||||||
|
<td x-text="row.DepartmentID || '-'"></td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="editRow(row)" class="btn btn-xs btn-ghost btn-square">
|
||||||
|
<i data-lucide="pencil" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="deleteRow(row)" class="btn btn-xs btn-ghost btn-square text-error">
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-if="data.length === 0">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-base-content/50 py-4">No records found</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generic Modal -->
|
||||||
|
<dialog id="orgModal" class="modal" :class="{ 'modal-open': isModalOpen }">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4" x-text="isEdit ? 'Edit ' + activeTab : 'New ' + activeTab"></h3>
|
||||||
|
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="form-control w-full mb-4">
|
||||||
|
<label class="label"><span class="label-text">Name</span></label>
|
||||||
|
<input type="text" x-model="form.Name" class="input input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Fields -->
|
||||||
|
<template x-if="activeTab === 'account'">
|
||||||
|
<div class="form-control w-full mb-4">
|
||||||
|
<label class="label"><span class="label-text">Parent Account</span></label>
|
||||||
|
<input type="text" x-model="form.Parent" class="input input-bordered w-full" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Site Fields: Account ID Link -->
|
||||||
|
<template x-if="activeTab === 'site'">
|
||||||
|
<div class="form-control w-full mb-4">
|
||||||
|
<label class="label"><span class="label-text">Account ID</span></label>
|
||||||
|
<input type="number" x-model="form.AccountID" class="input input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Department Fields: Site ID Link -->
|
||||||
|
<template x-if="activeTab === 'department'">
|
||||||
|
<div class="form-control w-full mb-4">
|
||||||
|
<label class="label"><span class="label-text">Site ID</span></label>
|
||||||
|
<input type="number" x-model="form.SiteID" class="input input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Workstation Fields: Department ID Link -->
|
||||||
|
<template x-if="activeTab === 'workstation'">
|
||||||
|
<div class="form-control w-full mb-4">
|
||||||
|
<label class="label"><span class="label-text">Department ID</span></label>
|
||||||
|
<input type="number" x-model="form.DepartmentID" class="input input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" @click="closeModal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="isSaving">
|
||||||
|
<span x-show="isSaving" class="loading loading-spinner loading-xs"></span>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('script') ?>
|
||||||
|
<script type="module">
|
||||||
|
import Alpine, { Utils } from '<?= base_url('/assets/js/app.js'); ?>';
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('organizationManager', () => ({
|
||||||
|
// Initialize with the type passed from PHP view
|
||||||
|
activeTab: '<?= $type ?? 'account' ?>',
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
isModalOpen: false,
|
||||||
|
isEdit: false,
|
||||||
|
isSaving: false,
|
||||||
|
form: {},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadData();
|
||||||
|
},
|
||||||
|
|
||||||
|
// NOTE: setTab removed as we use routing now
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.data = [];
|
||||||
|
try {
|
||||||
|
const response = await Utils.api(`<?= site_url('api/organization/') ?>${this.activeTab}`);
|
||||||
|
this.data = response.data || [];
|
||||||
|
// Re-render icons
|
||||||
|
setTimeout(() => window.lucide?.createIcons(), 50);
|
||||||
|
} catch (e) {
|
||||||
|
Alpine.store('toast').error(e.message);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getRowId(row) {
|
||||||
|
if(this.activeTab === 'account') return row.AccountID;
|
||||||
|
if(this.activeTab === 'site') return row.SiteID;
|
||||||
|
if(this.activeTab === 'discipline') return row.DisciplineID;
|
||||||
|
if(this.activeTab === 'department') return row.DepartmentID;
|
||||||
|
if(this.activeTab === 'workstation') return row.WorkstationID;
|
||||||
|
return row.ID;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRowName(row) {
|
||||||
|
if(this.activeTab === 'account') return row.AccountName;
|
||||||
|
if(this.activeTab === 'site') return row.SiteName;
|
||||||
|
if(this.activeTab === 'discipline') return row.DisciplineName;
|
||||||
|
if(this.activeTab === 'department') return row.DepartmentName;
|
||||||
|
if(this.activeTab === 'workstation') return row.WorkstationName;
|
||||||
|
return row.Name;
|
||||||
|
},
|
||||||
|
|
||||||
|
openModal() {
|
||||||
|
this.isEdit = false;
|
||||||
|
this.form = { Name: '' };
|
||||||
|
this.isModalOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
editRow(row) {
|
||||||
|
this.isEdit = true;
|
||||||
|
this.form = { ...row };
|
||||||
|
// Map specific name fields to generic 'Name' for the form input
|
||||||
|
if(this.activeTab === 'account') this.form.Name = row.AccountName;
|
||||||
|
if(this.activeTab === 'site') this.form.Name = row.SiteName;
|
||||||
|
if(this.activeTab === 'discipline') this.form.Name = row.DisciplineName;
|
||||||
|
if(this.activeTab === 'department') this.form.Name = row.DepartmentName;
|
||||||
|
if(this.activeTab === 'workstation') this.form.Name = row.WorkstationName;
|
||||||
|
|
||||||
|
this.isModalOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeModal() {
|
||||||
|
this.isModalOpen = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
this.isSaving = true;
|
||||||
|
try {
|
||||||
|
const payload = { ...this.form };
|
||||||
|
|
||||||
|
// Map generic Name back to specific field
|
||||||
|
if(this.activeTab === 'account') payload.AccountName = this.form.Name;
|
||||||
|
if(this.activeTab === 'site') payload.SiteName = this.form.Name;
|
||||||
|
if(this.activeTab === 'discipline') payload.DisciplineName = this.form.Name;
|
||||||
|
if(this.activeTab === 'department') payload.DepartmentName = this.form.Name;
|
||||||
|
if(this.activeTab === 'workstation') payload.WorkstationName = this.form.Name;
|
||||||
|
|
||||||
|
// ID for updates
|
||||||
|
if(this.isEdit) {
|
||||||
|
if(this.activeTab === 'account') payload.AccountID = this.form.AccountID;
|
||||||
|
if(this.activeTab === 'site') payload.SiteID = this.form.SiteID;
|
||||||
|
if(this.activeTab === 'discipline') payload.DisciplineID = this.form.DisciplineID;
|
||||||
|
if(this.activeTab === 'department') payload.DepartmentID = this.form.DepartmentID;
|
||||||
|
if(this.activeTab === 'workstation') payload.WorkstationID = this.form.WorkstationID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = this.isEdit ? 'PATCH' : 'POST';
|
||||||
|
await Utils.api(`<?= site_url('api/organization/') ?>${this.activeTab}`, {
|
||||||
|
method,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
Alpine.store('toast').success('Saved successfully');
|
||||||
|
this.closeModal();
|
||||||
|
this.loadData();
|
||||||
|
} catch (e) {
|
||||||
|
Alpine.store('toast').error(e.message);
|
||||||
|
} finally {
|
||||||
|
this.isSaving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRow(row) {
|
||||||
|
if(!confirm('Are you sure you want to delete this item?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = this.getRowId(row);
|
||||||
|
const idField = this.activeTab === 'account' ? 'AccountID' :
|
||||||
|
this.activeTab === 'site' ? 'SiteID' :
|
||||||
|
this.activeTab === 'discipline' ? 'DisciplineID' :
|
||||||
|
this.activeTab === 'department' ? 'DepartmentID' :
|
||||||
|
this.activeTab === 'workstation' ? 'WorkstationID' : 'ID';
|
||||||
|
|
||||||
|
await Utils.api(`<?= site_url('api/organization/') ?>${this.activeTab}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ [idField]: id })
|
||||||
|
});
|
||||||
|
|
||||||
|
Alpine.store('toast').success('Deleted successfully');
|
||||||
|
this.loadData();
|
||||||
|
} catch (e) {
|
||||||
|
Alpine.store('toast').error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
Alpine.start();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
456
app/Views/v2/patient-form.php
Normal file
456
app/Views/v2/patient-form.php
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div x-data="patientForm">
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<a href="<?= site_url('v2/patients') ?>" class="btn btn-ghost btn-sm btn-square">
|
||||||
|
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold"><?= isset($patient) ? 'Edit Patient' : 'New Patient' ?></h2>
|
||||||
|
<p class="text-base-content/60"><?= isset($patient) ? 'Update patient information' : 'Register a new patient' ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Alert -->
|
||||||
|
<template x-if="error">
|
||||||
|
<div class="alert alert-error mb-6" x-transition>
|
||||||
|
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||||
|
<span x-text="error"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
|
||||||
|
<!-- Personal Information -->
|
||||||
|
<div class="card bg-base-100 shadow mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">
|
||||||
|
<i data-lucide="user" class="w-5 h-5"></i>
|
||||||
|
Personal Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
|
||||||
|
<!-- Patient ID -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Patient ID (MRN)</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.PatientID" placeholder="Auto-generated if empty">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prefix -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Prefix</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Prefix">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="Mr.">Mr.</option>
|
||||||
|
<option value="Mrs.">Mrs.</option>
|
||||||
|
<option value="Ms.">Ms.</option>
|
||||||
|
<option value="Dr.">Dr.</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Name -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">First Name *</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.NameFirst" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Middle Name -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Middle Name</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.NameMiddle">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Name -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Last Name</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.NameLast">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suffix -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Suffix</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.Suffix" placeholder="Jr., Sr., III...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gender -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Gender *</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Gender" required>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<template x-for="g in genderOptions" :key="g.VID">
|
||||||
|
<option :value="g.VID" x-text="g.VDesc"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Birthdate -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Birthdate *</span></label>
|
||||||
|
<input type="date" class="input input-bordered" x-model="form.Birthdate" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Place of Birth -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Place of Birth</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.PlaceOfBirth">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Marital Status -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Marital Status</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.MaritalStatus">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<template x-for="m in maritalOptions" :key="m.VID">
|
||||||
|
<option :value="m.VID" x-text="m.VDesc"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Religion -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Religion</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Religion">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<template x-for="r in religionOptions" :key="r.VID">
|
||||||
|
<option :value="r.VID" x-text="r.VDesc"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ethnic -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Ethnic</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Ethnic">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<template x-for="e in ethnicOptions" :key="e.VID">
|
||||||
|
<option :value="e.VID" x-text="e.VDesc"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Information -->
|
||||||
|
<div class="card bg-base-100 shadow mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">
|
||||||
|
<i data-lucide="phone" class="w-5 h-5"></i>
|
||||||
|
Contact Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
<!-- Mobile Phone -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Mobile Phone</span></label>
|
||||||
|
<input type="tel" class="input input-bordered" x-model="form.MobilePhone" placeholder="+62...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Phone</span></label>
|
||||||
|
<input type="tel" class="input input-bordered" x-model="form.Phone">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email 1 -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Email Address</span></label>
|
||||||
|
<input type="email" class="input input-bordered" x-model="form.EmailAddress1" placeholder="email@example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email 2 -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Alternate Email</span></label>
|
||||||
|
<input type="email" class="input input-bordered" x-model="form.EmailAddress2">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="card bg-base-100 shadow mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">
|
||||||
|
<i data-lucide="map-pin" class="w-5 h-5"></i>
|
||||||
|
Address
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
<!-- Street 1 -->
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label"><span class="label-text">Street Address</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.Street_1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Street 2 -->
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label"><span class="label-text">Street Address 2</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.Street_2">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Province -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Province</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Province" @change="loadCities">
|
||||||
|
<option value="">Select Province...</option>
|
||||||
|
<template x-for="p in provinces" :key="p.AreaGeoID">
|
||||||
|
<option :value="p.AreaGeoID" x-text="p.AreaName"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- City -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">City</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.City" :disabled="!form.Province">
|
||||||
|
<option value="">Select City...</option>
|
||||||
|
<template x-for="c in cities" :key="c.AreaGeoID">
|
||||||
|
<option :value="c.AreaGeoID" x-text="c.AreaName"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ZIP -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">ZIP Code</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.ZIP">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Country -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Country</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Country">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<template x-for="c in countryOptions" :key="c.VID">
|
||||||
|
<option :value="c.VID" x-text="c.VDesc"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Identifier (PatIdt) -->
|
||||||
|
<div class="card bg-base-100 shadow mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">
|
||||||
|
<i data-lucide="id-card" class="w-5 h-5"></i>
|
||||||
|
Identifier
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">ID Type</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.PatIdt.IdentifierType">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="KTP">KTP</option>
|
||||||
|
<option value="SIM">SIM</option>
|
||||||
|
<option value="Passport">Passport</option>
|
||||||
|
<option value="BPJS">BPJS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">ID Number</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.PatIdt.Identifier">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="flex justify-end gap-4">
|
||||||
|
<a href="<?= site_url('v2/patients') ?>" class="btn btn-ghost">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary gap-2" :disabled="isSubmitting">
|
||||||
|
<span x-show="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
||||||
|
<i x-show="!isSubmitting" data-lucide="save" class="w-4 h-4"></i>
|
||||||
|
<span x-text="isSubmitting ? 'Saving...' : 'Save Patient'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('script') ?>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('patientForm', () => ({
|
||||||
|
isSubmitting: false,
|
||||||
|
error: null,
|
||||||
|
isEdit: <?= isset($patient) ? 'true' : 'false' ?>,
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
form: {
|
||||||
|
InternalPID: <?= isset($patient) ? json_encode($patient['InternalPID']) : 'null' ?>,
|
||||||
|
PatientID: '',
|
||||||
|
Prefix: '',
|
||||||
|
NameFirst: '',
|
||||||
|
NameMiddle: '',
|
||||||
|
NameLast: '',
|
||||||
|
Suffix: '',
|
||||||
|
Gender: '',
|
||||||
|
Birthdate: '',
|
||||||
|
PlaceOfBirth: '',
|
||||||
|
MaritalStatus: '',
|
||||||
|
Religion: '',
|
||||||
|
Ethnic: '',
|
||||||
|
Country: '',
|
||||||
|
Race: '',
|
||||||
|
MobilePhone: '',
|
||||||
|
Phone: '',
|
||||||
|
EmailAddress1: '',
|
||||||
|
EmailAddress2: '',
|
||||||
|
Street_1: '',
|
||||||
|
Street_2: '',
|
||||||
|
Street_3: '',
|
||||||
|
Province: '',
|
||||||
|
City: '',
|
||||||
|
ZIP: '',
|
||||||
|
PatIdt: {
|
||||||
|
IdentifierType: '',
|
||||||
|
Identifier: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dropdown options
|
||||||
|
genderOptions: [],
|
||||||
|
maritalOptions: [],
|
||||||
|
religionOptions: [],
|
||||||
|
ethnicOptions: [],
|
||||||
|
countryOptions: [],
|
||||||
|
provinces: [],
|
||||||
|
cities: [],
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadOptions();
|
||||||
|
|
||||||
|
<?php if (isset($patient)): ?>
|
||||||
|
// Load patient data for edit
|
||||||
|
this.loadPatientData(<?= json_encode($patient) ?>);
|
||||||
|
<?php endif; ?>
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadOptions() {
|
||||||
|
try {
|
||||||
|
// Load ValueSets for dropdowns
|
||||||
|
const [gender, marital, religion, ethnic, country, provinces] = await Promise.all([
|
||||||
|
fetch('<?= site_url('api/valueset/valuesetdef/1') ?>', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
fetch('<?= site_url('api/valueset/valuesetdef/2') ?>', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
fetch('<?= site_url('api/religion') ?>', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
fetch('<?= site_url('api/ethnic') ?>', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
fetch('<?= site_url('api/country') ?>', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
fetch('<?= site_url('api/areageo/provinces') ?>', { credentials: 'include' }).then(r => r.json())
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.genderOptions = gender.data || [];
|
||||||
|
this.maritalOptions = marital.data || [];
|
||||||
|
this.religionOptions = religion.data || [];
|
||||||
|
this.ethnicOptions = ethnic.data || [];
|
||||||
|
this.countryOptions = country.data || [];
|
||||||
|
this.provinces = provinces.data || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load options:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadCities() {
|
||||||
|
if (!this.form.Province) {
|
||||||
|
this.cities = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('<?= site_url('api/areageo/cities') ?>?province=' + this.form.Province, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
this.cities = data.data || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load cities:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPatientData(patient) {
|
||||||
|
// Map patient data to form
|
||||||
|
Object.keys(this.form).forEach(key => {
|
||||||
|
if (key === 'PatIdt') {
|
||||||
|
if (patient.PatIdt) {
|
||||||
|
this.form.PatIdt = patient.PatIdt;
|
||||||
|
}
|
||||||
|
} else if (patient[key] !== undefined && patient[key] !== null) {
|
||||||
|
// Handle VID fields
|
||||||
|
if (patient[key + 'VID']) {
|
||||||
|
this.form[key] = patient[key + 'VID'];
|
||||||
|
} else if (key === 'Province' && patient.ProvinceID) {
|
||||||
|
this.form.Province = patient.ProvinceID;
|
||||||
|
} else if (key === 'City' && patient.CityID) {
|
||||||
|
this.form.City = patient.CityID;
|
||||||
|
} else {
|
||||||
|
this.form[key] = patient[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load cities if province is set
|
||||||
|
if (this.form.Province) {
|
||||||
|
this.loadCities();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitForm() {
|
||||||
|
this.isSubmitting = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = '<?= site_url('api/patient') ?>';
|
||||||
|
const method = this.isEdit ? 'PATCH' : 'POST';
|
||||||
|
|
||||||
|
// Clean up PatIdt if empty
|
||||||
|
const payload = { ...this.form };
|
||||||
|
if (!payload.PatIdt.IdentifierType || !payload.PatIdt.Identifier) {
|
||||||
|
delete payload.PatIdt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
$store.toast.success(this.isEdit ? 'Patient updated successfully' : 'Patient created successfully');
|
||||||
|
// Redirect to patient list
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '<?= site_url('v2/patients') ?>';
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
this.error = data.message || 'Failed to save patient';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.error = 'Connection error: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
305
app/Views/v2/patient-view.php
Normal file
305
app/Views/v2/patient-view.php
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div x-data="patientView">
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<template x-if="isLoading">
|
||||||
|
<div class="flex justify-center items-center py-24">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<template x-if="error && !isLoading">
|
||||||
|
<div class="max-w-lg mx-auto">
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||||
|
<span x-text="error"></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="<?= site_url('v2/patients') ?>" class="btn btn-ghost">Back to Patient List</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Patient Detail -->
|
||||||
|
<template x-if="!isLoading && !error && patient">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="<?= site_url('v2/patients') ?>" class="btn btn-ghost btn-sm btn-square">
|
||||||
|
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold" x-text="fullName"></h2>
|
||||||
|
<div class="flex items-center gap-2 text-base-content/60">
|
||||||
|
<span class="badge badge-primary" x-text="patient.PatientID || 'No MRN'"></span>
|
||||||
|
<span x-text="patient.Gender || ''"></span>
|
||||||
|
<span>•</span>
|
||||||
|
<span x-text="patient.Age || ''"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a :href="'<?= site_url('v2/patients/edit/') ?>' + patient.InternalPID" class="btn btn-primary gap-2">
|
||||||
|
<i data-lucide="pencil" class="w-4 h-4"></i>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- Left Column: Main Info -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
|
||||||
|
<!-- Personal Information -->
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">
|
||||||
|
<i data-lucide="user" class="w-5 h-5"></i>
|
||||||
|
Personal Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Full Name</div>
|
||||||
|
<div class="font-medium" x-text="fullName"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Gender</div>
|
||||||
|
<div class="font-medium" x-text="patient.Gender || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Birthdate</div>
|
||||||
|
<div class="font-medium" x-text="patient.BirthdateConversion || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Age</div>
|
||||||
|
<div class="font-medium" x-text="patient.Age || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Place of Birth</div>
|
||||||
|
<div class="font-medium" x-text="patient.PlaceOfBirth || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Marital Status</div>
|
||||||
|
<div class="font-medium" x-text="patient.MaritalStatus || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Religion</div>
|
||||||
|
<div class="font-medium" x-text="patient.Religion || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Ethnic</div>
|
||||||
|
<div class="font-medium" x-text="patient.Ethnic || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Citizenship</div>
|
||||||
|
<div class="font-medium" x-text="patient.Citizenship || '-'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Information -->
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">
|
||||||
|
<i data-lucide="phone" class="w-5 h-5"></i>
|
||||||
|
Contact Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Mobile Phone</div>
|
||||||
|
<div class="font-medium" x-text="patient.MobilePhone || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Phone</div>
|
||||||
|
<div class="font-medium" x-text="patient.Phone || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Email</div>
|
||||||
|
<div class="font-medium" x-text="patient.EmailAddress1 || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Alternate Email</div>
|
||||||
|
<div class="font-medium" x-text="patient.EmailAddress2 || '-'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">
|
||||||
|
<i data-lucide="map-pin" class="w-5 h-5"></i>
|
||||||
|
Address
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="font-medium" x-text="fullAddress || 'No address on file'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Sidebar Info -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Identifier -->
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">
|
||||||
|
<i data-lucide="id-card" class="w-5 h-5"></i>
|
||||||
|
Identifier
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<template x-if="patient.PatIdt">
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="text-sm text-base-content/60" x-text="patient.PatIdt.IdentifierType"></div>
|
||||||
|
<div class="font-mono font-medium text-lg" x-text="patient.PatIdt.Identifier"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="!patient.PatIdt">
|
||||||
|
<div class="text-base-content/50 mt-4">No identifier on file</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Info -->
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">
|
||||||
|
<i data-lucide="info" class="w-5 h-5"></i>
|
||||||
|
System Info
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3 mt-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Internal ID</div>
|
||||||
|
<div class="font-mono" x-text="patient.InternalPID"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Created</div>
|
||||||
|
<div x-text="patient.CreateDate || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<template x-if="patient.LinkTo">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-base-content/60">Linked Patients</div>
|
||||||
|
<template x-for="link in patient.LinkTo" :key="link.InternalPID">
|
||||||
|
<a
|
||||||
|
:href="'<?= site_url('v2/patients/') ?>' + link.InternalPID"
|
||||||
|
class="link link-primary"
|
||||||
|
x-text="link.PatientID"
|
||||||
|
></a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<template x-if="patient.PatCom">
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">
|
||||||
|
<i data-lucide="message-square" class="w-5 h-5"></i>
|
||||||
|
Comments
|
||||||
|
</h3>
|
||||||
|
<div class="mt-4 whitespace-pre-wrap" x-text="patient.PatCom"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('script') ?>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('patientView', () => ({
|
||||||
|
patient: null,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadPatient();
|
||||||
|
},
|
||||||
|
|
||||||
|
get fullName() {
|
||||||
|
if (!this.patient) return '';
|
||||||
|
return [
|
||||||
|
this.patient.Prefix,
|
||||||
|
this.patient.NameFirst,
|
||||||
|
this.patient.NameMiddle,
|
||||||
|
this.patient.NameLast,
|
||||||
|
this.patient.Suffix
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
},
|
||||||
|
|
||||||
|
get fullAddress() {
|
||||||
|
if (!this.patient) return '';
|
||||||
|
return [
|
||||||
|
this.patient.Street_1,
|
||||||
|
this.patient.Street_2,
|
||||||
|
this.patient.Street_3,
|
||||||
|
this.patient.City,
|
||||||
|
this.patient.Province,
|
||||||
|
this.patient.ZIP,
|
||||||
|
this.patient.Country
|
||||||
|
].filter(Boolean).join(', ');
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadPatient() {
|
||||||
|
const patientId = <?= json_encode($patientId ?? null) ?>;
|
||||||
|
|
||||||
|
if (!patientId) {
|
||||||
|
this.error = 'Patient ID not provided';
|
||||||
|
this.isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('<?= site_url('api/patient/') ?>' + patientId, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
this.patient = data.data;
|
||||||
|
} else {
|
||||||
|
this.error = data.message || 'Failed to load patient';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.error = 'Connection error: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
// Re-init icons when loading state changes
|
||||||
|
setTimeout(() => {
|
||||||
|
if(window.lucide) window.lucide.createIcons();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
687
app/Views/v2/patients.php
Normal file
687
app/Views/v2/patients.php
Normal file
@ -0,0 +1,687 @@
|
|||||||
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div x-data="patientManager()">
|
||||||
|
|
||||||
|
<!-- Page Header with Actions -->
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">Patients</h2>
|
||||||
|
<p class="text-base-content/60">Manage patient records</p>
|
||||||
|
</div>
|
||||||
|
<button @click="openCreateModal()" class="btn btn-primary gap-2">
|
||||||
|
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||||
|
Add Patient
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search & Filter Card -->
|
||||||
|
<div class="card bg-base-100 shadow mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col lg:flex-row gap-4">
|
||||||
|
|
||||||
|
<!-- Search by Name -->
|
||||||
|
<div class="form-control flex-1">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Patient Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name..."
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
x-model="filters.Name"
|
||||||
|
@keyup.enter="searchPatients"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search by ID -->
|
||||||
|
<div class="form-control w-full lg:w-48">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Patient ID</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="MRN..."
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
x-model="filters.PatientID"
|
||||||
|
@keyup.enter="searchPatients"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search by Birthdate -->
|
||||||
|
<div class="form-control w-full lg:w-48">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Birthdate</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
x-model="filters.Birthdate"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Button -->
|
||||||
|
<div class="form-control w-full lg:w-auto">
|
||||||
|
<label class="label lg:invisible">
|
||||||
|
<span class="label-text"> </span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary flex-1 lg:flex-none gap-2"
|
||||||
|
@click="searchPatients"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<i data-lucide="search" class="w-4 h-4"></i>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost"
|
||||||
|
@click="clearFilters"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Table -->
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<template x-if="isLoading">
|
||||||
|
<div class="flex justify-center items-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<template x-if="error && !isLoading">
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||||
|
<span x-text="error"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<template x-if="!isLoading && !error && patients.length === 0">
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<i data-lucide="users" class="w-16 h-16 mx-auto text-base-content/20 mb-4"></i>
|
||||||
|
<h3 class="text-lg font-semibold">No patients found</h3>
|
||||||
|
<p class="text-base-content/60" x-text="hasSearched ? 'Try adjusting your search criteria' : 'Click Search to load patients'"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Data Table -->
|
||||||
|
<template x-if="!isLoading && !error && patients.length > 0">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Patient ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Gender</th>
|
||||||
|
<th>Birthdate</th>
|
||||||
|
<th>Mobile</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="patient in patients" :key="patient.InternalPID">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="font-mono text-sm" x-text="patient.PatientID || '-'"></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="font-medium" x-text="patient.FullName || '-'"></div>
|
||||||
|
<div class="text-xs text-base-content/50" x-text="patient.Email || ''"></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-ghost" x-text="patient.Gender || '-'"></span>
|
||||||
|
</td>
|
||||||
|
<td x-text="formatDate(patient.Birthdate)"></td>
|
||||||
|
<td x-text="patient.MobilePhone || '-'"></td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
@click="openEditModal(patient)"
|
||||||
|
class="btn btn-ghost btn-sm btn-square"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<i data-lucide="pencil" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm btn-square text-error"
|
||||||
|
title="Delete"
|
||||||
|
@click="confirmDelete(patient)"
|
||||||
|
>
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Results Count -->
|
||||||
|
<div class="text-sm text-base-content/60 mt-4">
|
||||||
|
Showing <span x-text="patients.length" class="font-medium"></span> patients
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Modal -->
|
||||||
|
<dialog id="patientModal" class="modal" :class="{ 'modal-open': showFormModal }">
|
||||||
|
<div class="modal-box w-11/12 max-w-5xl">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="closeFormModal()">✕</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3 class="font-bold text-lg mb-6" x-text="isEdit ? 'Edit Patient' : 'New Patient'"></h3>
|
||||||
|
|
||||||
|
<!-- Error Alert in Modal -->
|
||||||
|
<template x-if="formError">
|
||||||
|
<div class="alert alert-error mb-6">
|
||||||
|
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||||
|
<span x-text="formError"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<!-- Personal Information -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<i data-lucide="user" class="w-4 h-4"></i> Personal Information
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Patient ID (MRN)</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.PatientID" placeholder="Auto-generated">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Prefix</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Prefix">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="Mr.">Mr.</option>
|
||||||
|
<option value="Mrs.">Mrs.</option>
|
||||||
|
<option value="Ms.">Ms.</option>
|
||||||
|
<option value="Dr.">Dr.</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">First Name *</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.NameFirst" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Middle Name</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.NameMiddle">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Last Name</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.NameLast">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Suffix</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.Suffix">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Gender *</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Gender" required>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<template x-for="g in options.gender" :key="g.VID">
|
||||||
|
<option :value="g.VID" x-text="g.VDesc"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Birthdate *</span></label>
|
||||||
|
<input type="date" class="input input-bordered" x-model="form.Birthdate" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Place of Birth</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.PlaceOfBirth">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Marital Status</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.MaritalStatus">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<template x-for="m in options.marital" :key="m.VID">
|
||||||
|
<option :value="m.VID" x-text="m.VDesc"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Religion</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Religion">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<template x-for="r in options.religion" :key="r.VID">
|
||||||
|
<option :value="r.VID" x-text="r.VDesc"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Ethnic</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Ethnic">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<template x-for="e in options.ethnic" :key="e.VID">
|
||||||
|
<option :value="e.VID" x-text="e.VDesc"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Information -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<i data-lucide="phone" class="w-4 h-4"></i> Contact Information
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Mobile Phone</span></label>
|
||||||
|
<input type="tel" class="input input-bordered" x-model="form.MobilePhone">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Phone</span></label>
|
||||||
|
<input type="tel" class="input input-bordered" x-model="form.Phone">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Email Address</span></label>
|
||||||
|
<input type="email" class="input input-bordered" x-model="form.EmailAddress1">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Alternate Email</span></label>
|
||||||
|
<input type="email" class="input input-bordered" x-model="form.EmailAddress2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<i data-lucide="map-pin" class="w-4 h-4"></i> Address
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label"><span class="label-text">Street Address</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.Street_1">
|
||||||
|
</div>
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label"><span class="label-text">Street Address 2</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.Street_2">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Province</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Province" @change="loadCities">
|
||||||
|
<option value="">Select Province...</option>
|
||||||
|
<template x-for="p in options.provinces" :key="p.AreaGeoID">
|
||||||
|
<option :value="p.AreaGeoID" x-text="p.AreaName"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">City</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.City" :disabled="!form.Province">
|
||||||
|
<option value="">Select City...</option>
|
||||||
|
<template x-for="c in options.cities" :key="c.AreaGeoID">
|
||||||
|
<option :value="c.AreaGeoID" x-text="c.AreaName"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">ZIP Code</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.ZIP">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Country</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.Country">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<template x-for="c in options.country" :key="c.VID">
|
||||||
|
<option :value="c.VID" x-text="c.VDesc"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Identifier -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<i data-lucide="id-card" class="w-4 h-4"></i> Identifier
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">ID Type</span></label>
|
||||||
|
<select class="select select-bordered" x-model="form.PatIdt.IdentifierType">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="KTP">KTP</option>
|
||||||
|
<option value="SIM">SIM</option>
|
||||||
|
<option value="Passport">Passport</option>
|
||||||
|
<option value="BPJS">BPJS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">ID Number</span></label>
|
||||||
|
<input type="text" class="input input-bordered" x-model="form.PatIdt.Identifier">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" @click="closeFormModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
|
||||||
|
<span x-show="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span x-text="isSubmitting ? 'Saving...' : 'Save Patient'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<dialog id="deleteModal" class="modal" :class="{ 'modal-open': showDeleteModal }">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg">Delete Patient</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
Are you sure you want to delete patient
|
||||||
|
<span class="font-semibold" x-text="deleteTarget?.FullName"></span>?
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn" @click="showDeleteModal = false">Cancel</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-error"
|
||||||
|
@click="deletePatient"
|
||||||
|
:disabled="isDeleting"
|
||||||
|
>
|
||||||
|
<span x-show="isDeleting" class="loading loading-spinner loading-sm"></span>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button @click="showDeleteModal = false">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('script') ?>
|
||||||
|
<script type="module">
|
||||||
|
import Alpine, { Utils } from '<?= base_url('/assets/js/app.js'); ?>';
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('patientManager', () => ({
|
||||||
|
// List State
|
||||||
|
patients: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
hasSearched: false,
|
||||||
|
filters: {
|
||||||
|
Name: '',
|
||||||
|
PatientID: '',
|
||||||
|
Birthdate: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete State
|
||||||
|
showDeleteModal: false,
|
||||||
|
deleteTarget: null,
|
||||||
|
isDeleting: false,
|
||||||
|
|
||||||
|
// Form/Modal State
|
||||||
|
showFormModal: false,
|
||||||
|
isEdit: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
formError: null,
|
||||||
|
|
||||||
|
// Options Cache
|
||||||
|
options: {
|
||||||
|
gender: [],
|
||||||
|
marital: [],
|
||||||
|
religion: [],
|
||||||
|
ethnic: [],
|
||||||
|
country: [],
|
||||||
|
provinces: [],
|
||||||
|
cities: []
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default Form Data
|
||||||
|
defaultForm: {
|
||||||
|
InternalPID: null,
|
||||||
|
PatientID: '',
|
||||||
|
Prefix: '',
|
||||||
|
NameFirst: '',
|
||||||
|
NameMiddle: '',
|
||||||
|
NameLast: '',
|
||||||
|
Suffix: '',
|
||||||
|
Gender: '',
|
||||||
|
Birthdate: '',
|
||||||
|
PlaceOfBirth: '',
|
||||||
|
MaritalStatus: '',
|
||||||
|
Religion: '',
|
||||||
|
Ethnic: '',
|
||||||
|
Country: '',
|
||||||
|
Race: '',
|
||||||
|
MobilePhone: '',
|
||||||
|
Phone: '',
|
||||||
|
EmailAddress1: '',
|
||||||
|
EmailAddress2: '',
|
||||||
|
Street_1: '',
|
||||||
|
Street_2: '',
|
||||||
|
Street_3: '',
|
||||||
|
Province: '',
|
||||||
|
City: '',
|
||||||
|
ZIP: '',
|
||||||
|
PatIdt: {
|
||||||
|
IdentifierType: '',
|
||||||
|
Identifier: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
form: {}, // Initialized in init
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.form = JSON.parse(JSON.stringify(this.defaultForm));
|
||||||
|
this.loadOptions();
|
||||||
|
this.searchPatients(); // Initial load
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- List Actions ---
|
||||||
|
|
||||||
|
async searchPatients() {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.error = null;
|
||||||
|
this.hasSearched = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (this.filters.Name) params.append('Name', this.filters.Name);
|
||||||
|
if (this.filters.PatientID) params.append('PatientID', this.filters.PatientID);
|
||||||
|
if (this.filters.Birthdate) params.append('Birthdate', this.filters.Birthdate);
|
||||||
|
|
||||||
|
const data = await Utils.api('<?= site_url('api/patient') ?>?' + params.toString());
|
||||||
|
this.patients = data.data || [];
|
||||||
|
|
||||||
|
// Re-initialize icons for new rows
|
||||||
|
setTimeout(() => {
|
||||||
|
if(window.lucide) window.lucide.createIcons();
|
||||||
|
}, 50);
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message;
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFilters() {
|
||||||
|
this.filters = { Name: '', PatientID: '', Birthdate: '' };
|
||||||
|
this.searchPatients();
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(dateStr) {
|
||||||
|
return Utils.formatDate(dateStr);
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Delete Actions ---
|
||||||
|
|
||||||
|
confirmDelete(patient) {
|
||||||
|
this.deleteTarget = patient;
|
||||||
|
this.showDeleteModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePatient() {
|
||||||
|
if (!this.deleteTarget) return;
|
||||||
|
this.isDeleting = true;
|
||||||
|
try {
|
||||||
|
await Utils.api('<?= site_url('api/patient') ?>', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ InternalPID: this.deleteTarget.InternalPID })
|
||||||
|
});
|
||||||
|
Alpine.store('toast').success('Patient deleted successfully');
|
||||||
|
this.showDeleteModal = false;
|
||||||
|
this.searchPatients();
|
||||||
|
} catch (e) {
|
||||||
|
Alpine.store('toast').error(e.message || 'Failed to delete patient');
|
||||||
|
} finally {
|
||||||
|
this.isDeleting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Form/Modal Actions ---
|
||||||
|
|
||||||
|
async loadOptions() {
|
||||||
|
try {
|
||||||
|
// Check if already loaded
|
||||||
|
if(this.options.gender.length > 0) return;
|
||||||
|
|
||||||
|
const [gender, marital, religion, ethnic, country, provinces] = await Promise.all([
|
||||||
|
Utils.api('<?= site_url('api/valueset/valuesetdef/1') ?>'),
|
||||||
|
Utils.api('<?= site_url('api/valueset/valuesetdef/2') ?>'),
|
||||||
|
Utils.api('<?= site_url('api/religion') ?>'),
|
||||||
|
Utils.api('<?= site_url('api/ethnic') ?>'),
|
||||||
|
Utils.api('<?= site_url('api/country') ?>'),
|
||||||
|
Utils.api('<?= site_url('api/areageo/provinces') ?>')
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.options.gender = gender.data || [];
|
||||||
|
this.options.marital = marital.data || [];
|
||||||
|
this.options.religion = religion.data || [];
|
||||||
|
this.options.ethnic = ethnic.data || [];
|
||||||
|
this.options.country = country.data || [];
|
||||||
|
this.options.provinces = provinces.data || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load options', e);
|
||||||
|
Alpine.store('toast').error('Failed to load form options');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadCities() {
|
||||||
|
if (!this.form.Province) {
|
||||||
|
this.options.cities = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await Utils.api('<?= site_url('api/areageo/cities') ?>?province=' + this.form.Province);
|
||||||
|
this.options.cities = data.data || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openCreateModal() {
|
||||||
|
this.isEdit = false;
|
||||||
|
this.formError = null;
|
||||||
|
this.form = JSON.parse(JSON.stringify(this.defaultForm));
|
||||||
|
this.showFormModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async openEditModal(patient) {
|
||||||
|
this.isEdit = true;
|
||||||
|
this.formError = null;
|
||||||
|
// Fetch full details if needed, or use row data if sufficient.
|
||||||
|
// Row data might allow for quicker edit if all fields are present, but it's safer to fetch.
|
||||||
|
// Assuming row data is partial:
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fullPatient = await Utils.api('<?= site_url('api/patient/') ?>' + patient.InternalPID);
|
||||||
|
this.mapPatientToForm(fullPatient.data || patient);
|
||||||
|
this.showFormModal = true;
|
||||||
|
} catch(e) {
|
||||||
|
Alpine.store('toast').error('Failed to load patient details');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mapPatientToForm(patient) {
|
||||||
|
// deep copy default first
|
||||||
|
const f = JSON.parse(JSON.stringify(this.defaultForm));
|
||||||
|
|
||||||
|
Object.keys(f).forEach(key => {
|
||||||
|
if (key === 'PatIdt') {
|
||||||
|
if (patient.PatIdt) f.PatIdt = patient.PatIdt;
|
||||||
|
} else if (patient[key] !== undefined && patient[key] !== null) {
|
||||||
|
if (patient[key + 'VID']) {
|
||||||
|
f[key] = patient[key + 'VID'];
|
||||||
|
} else if (key === 'Province' && patient.ProvinceID) {
|
||||||
|
f.Province = patient.ProvinceID;
|
||||||
|
} else if (key === 'City' && patient.CityID) {
|
||||||
|
f.City = patient.CityID;
|
||||||
|
} else {
|
||||||
|
f[key] = patient[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.form = f;
|
||||||
|
|
||||||
|
if (this.form.Province) {
|
||||||
|
this.loadCities();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeFormModal() {
|
||||||
|
this.showFormModal = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitForm() {
|
||||||
|
this.isSubmitting = true;
|
||||||
|
this.formError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = '<?= site_url('api/patient') ?>';
|
||||||
|
const method = this.isEdit ? 'PATCH' : 'POST';
|
||||||
|
|
||||||
|
// Clean up PatIdt if empty
|
||||||
|
const payload = { ...this.form };
|
||||||
|
if (!payload.PatIdt.IdentifierType || !payload.PatIdt.Identifier) {
|
||||||
|
delete payload.PatIdt;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Utils.api(url, {
|
||||||
|
method: method,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
Alpine.store('toast').success(this.isEdit ? 'Patient updated successfully' : 'Patient created successfully');
|
||||||
|
this.closeFormModal();
|
||||||
|
this.searchPatients();
|
||||||
|
} catch (e) {
|
||||||
|
this.formError = e.message;
|
||||||
|
Alpine.store('toast').error('Failed to save patient');
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
Alpine.start();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
386
app/Views/v2/valuesets.php
Normal file
386
app/Views/v2/valuesets.php
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
<?= $this->extend('layouts/v2') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]" x-data="valueSetManager()">
|
||||||
|
|
||||||
|
<!-- Left Column: Value Sets (Defs) - Fixed width -->
|
||||||
|
<div class="w-full lg:w-80 flex flex-col bg-base-100 rounded-box shadow-xl shrink-0">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="p-4 border-b border-base-200">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-bold text-lg flex items-center gap-2">
|
||||||
|
<i data-lucide="book-open" class="w-5 h-5 text-primary"></i>
|
||||||
|
Value Sets
|
||||||
|
</h3>
|
||||||
|
<div class="tooltip tooltip-bottom" data-tip="New Value Set">
|
||||||
|
<button @click="openDefModal()" class="btn btn-sm btn-circle btn-ghost">
|
||||||
|
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<label class="input input-sm input-bordered flex items-center gap-2">
|
||||||
|
<input type="text" class="grow" placeholder="Search..." x-model="searchDef" />
|
||||||
|
<i data-lucide="search" class="w-4 h-4 opacity-70"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-2 scrollbar-thin">
|
||||||
|
<template x-if="filteredDefs.length === 0">
|
||||||
|
<div class="text-center p-8 text-base-content/50 text-sm">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ul class="menu p-0 gap-1 w-full">
|
||||||
|
<template x-for="def in filteredDefs" :key="def.VSetID">
|
||||||
|
<li>
|
||||||
|
<a @click="selectDef(def)"
|
||||||
|
:class="{ 'active': selectedDef?.VSetID === def.VSetID }"
|
||||||
|
class="flex flex-col items-start gap-1 py-3 group transition-colors">
|
||||||
|
|
||||||
|
<!-- Top Line: Name & Edit -->
|
||||||
|
<div class="flex justify-between w-full items-center">
|
||||||
|
<span class="font-medium truncate w-full" x-text="def.VSName || def.VSetID"></span>
|
||||||
|
<!-- Hover Actions -->
|
||||||
|
<button @click.stop="editDef(def)"
|
||||||
|
class="btn btn-xs btn-ghost btn-square opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Edit Definition">
|
||||||
|
<i data-lucide="pencil" class="w-3 h-3"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Line: ID Badge -->
|
||||||
|
<div class="flex justify-between w-full items-center">
|
||||||
|
<span class="badge badge-xs badge-neutral badge-outline font-mono opacity-70" x-text="'ID: ' + def.VSetID"></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Values -->
|
||||||
|
<div class="flex-1 flex flex-col bg-base-100 rounded-box shadow-xl overflow-hidden relative">
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div x-show="!selectedDef" class="absolute inset-0 flex flex-col items-center justify-center text-base-content/30 bg-base-100 z-10">
|
||||||
|
<i data-lucide="layout-list" class="w-24 h-24 mb-4 opacity-20"></i>
|
||||||
|
<h3 class="font-bold text-xl">Select a Value Set</h3>
|
||||||
|
<p>Choose a definition from the left to manage its values</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content (Only when selected) -->
|
||||||
|
<template x-if="selectedDef">
|
||||||
|
<div class="flex flex-col h-full animate-in fade-in duration-200">
|
||||||
|
|
||||||
|
<!-- Main Toolbar/Header -->
|
||||||
|
<div class="p-6 border-b border-base-200 bg-base-100/50 backdrop-blur-sm">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-2xl font-bold font-display" x-text="selectedDef.VSName"></h2>
|
||||||
|
<span class="badge badge-primary badge-outline font-mono" x-text="'ID: ' + selectedDef.VSetID"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-base-content/70 mt-1 max-w-2xl" x-text="selectedDef.VSDesc || 'No description provided'"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 shrink-0">
|
||||||
|
<button @click="openValueModal()" class="btn btn-primary gap-2 shadow-lg hover:translate-y-[-1px] transition-transform">
|
||||||
|
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||||
|
Add Value
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="flex-1 overflow-x-auto bg-base-100">
|
||||||
|
<table class="table table-pin-rows table-lg">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-base-200/50 text-base-content/70">
|
||||||
|
<th class="w-24">VID</th>
|
||||||
|
<th class="w-1/4">Value</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="w-24 text-center">Order</th>
|
||||||
|
<th class="w-24 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="val in values" :key="val.VID">
|
||||||
|
<tr class="hover group transition-colors border-base-100">
|
||||||
|
<td class="font-mono text-xs opacity-50" x-text="val.VID"></td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span class="font-bold" x-text="val.VValue"></span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-base-content/80" x-text="val.VDesc || '-'"></td>
|
||||||
|
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge badge-ghost badge-sm" x-show="val.SortOrder" x-text="val.SortOrder"></span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="flex justify-end gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button @click="editValue(val)" class="btn btn-sm btn-ghost btn-square text-primary" title="Edit Value">
|
||||||
|
<i data-lucide="pencil" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="deleteValue(val)" class="btn btn-sm btn-ghost btn-square text-error" title="Delete Value">
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Empty Table State -->
|
||||||
|
<div x-show="values.length === 0" class="flex flex-col items-center justify-center py-20 text-base-content/40">
|
||||||
|
<i data-lucide="inbox" class="w-12 h-12 mb-2 opacity-50"></i>
|
||||||
|
<p>No values found for this set.</p>
|
||||||
|
<button @click="openValueModal()" class="btn btn-link btn-sm mt-2">Add first value</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer count -->
|
||||||
|
<div class="p-3 border-t border-base-200 text-xs text-base-content/50 text-right bg-base-100">
|
||||||
|
<span x-text="values.length"></span> values
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Def Modal -->
|
||||||
|
<dialog class="modal" :class="{ 'modal-open': isDefModalOpen }">
|
||||||
|
<div class="modal-box transform transition-all">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="isDefModalOpen = false">✕</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3 class="font-bold text-xl mb-6 flex items-center gap-2">
|
||||||
|
<div class="bg-primary/10 p-2 rounded-lg text-primary">
|
||||||
|
<i data-lucide="book-open" class="w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<span x-text="isEditDef ? 'Edit Definition' : 'New Definition'"></span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form @submit.prevent="saveDef">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- ID Field (Readonly/Hidden Logic) -->
|
||||||
|
<div class="form-control" x-show="isEditDef">
|
||||||
|
<label class="label"><span class="label-text font-medium">System ID</span></label>
|
||||||
|
<input type="text" x-model="defForm.VSetID" class="input input-sm input-bordered bg-base-200 font-mono" disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text font-medium">Name</span></label>
|
||||||
|
<input type="text" x-model="defForm.VSName" class="input input-bordered focus:input-primary w-full" placeholder="e.g. Gender" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text font-medium">Description</span></label>
|
||||||
|
<textarea x-model="defForm.VSDesc" class="textarea textarea-bordered focus:textarea-primary h-24" placeholder="Describe the purpose of this value set..." required></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" @click="isDefModalOpen = false">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary px-8">
|
||||||
|
Save Definition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button @click="isDefModalOpen = false">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Value Modal -->
|
||||||
|
<dialog class="modal" :class="{ 'modal-open': isValueModalOpen }">
|
||||||
|
<div class="modal-box transform transition-all">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="isValueModalOpen = false">✕</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3 class="font-bold text-xl mb-6 flex items-center gap-2">
|
||||||
|
<div class="bg-secondary/10 p-2 rounded-lg text-secondary">
|
||||||
|
<i data-lucide="list" class="w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<span x-text="isEditValue ? 'Edit Value' : 'Add New Value'"></span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form @submit.prevent="saveValue">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text font-medium">Display Value</span></label>
|
||||||
|
<input type="text" x-model="valueForm.VValue" class="input input-bordered focus:input-primary w-full" placeholder="e.g. Male" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text font-medium">Description</span></label>
|
||||||
|
<input type="text" x-model="valueForm.VDesc" class="input input-bordered w-full" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text font-medium">Sort Order</span></label>
|
||||||
|
<input type="number" x-model="valueForm.SortOrder" class="input input-bordered w-full" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" @click="isValueModalOpen = false">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary px-8">Save Value</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button @click="isValueModalOpen = false">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('script') ?>
|
||||||
|
<script type="module">
|
||||||
|
import Alpine, { Utils } from '<?= base_url('/assets/js/app.js'); ?>';
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('valueSetManager', () => ({
|
||||||
|
defs: [],
|
||||||
|
values: [],
|
||||||
|
searchDef: '',
|
||||||
|
selectedDef: null,
|
||||||
|
|
||||||
|
// Def Modal
|
||||||
|
isDefModalOpen: false,
|
||||||
|
isEditDef: false,
|
||||||
|
defForm: { VSetID: '', VSName: '', VSDesc: '' },
|
||||||
|
|
||||||
|
// Value Modal
|
||||||
|
isValueModalOpen: false,
|
||||||
|
isEditValue: false,
|
||||||
|
valueForm: { VID: null, VValue: '', VDesc: '', SortOrder: '' },
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadDefs();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadDefs() {
|
||||||
|
try {
|
||||||
|
const res = await Utils.api('<?= site_url('api/valuesetdef/') ?>');
|
||||||
|
this.defs = res.data || [];
|
||||||
|
setTimeout(() => window.lucide?.createIcons(), 50);
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
get filteredDefs() {
|
||||||
|
if(!this.searchDef) return this.defs;
|
||||||
|
return this.defs.filter(d =>
|
||||||
|
(d.VSName && d.VSName.toLowerCase().includes(this.searchDef.toLowerCase())) ||
|
||||||
|
(d.VSetID && d.VSetID.toString().includes(this.searchDef))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectDef(def) {
|
||||||
|
this.selectedDef = def;
|
||||||
|
this.values = [];
|
||||||
|
// Load values for this def
|
||||||
|
try {
|
||||||
|
// Note: VSetID is an integer (e.g. 1)
|
||||||
|
const res = await Utils.api(`<?= site_url('api/valueset/valuesetdef/') ?>${def.VSetID}`);
|
||||||
|
this.values = res.data || [];
|
||||||
|
setTimeout(() => window.lucide?.createIcons(), 50);
|
||||||
|
} catch(e) {
|
||||||
|
this.values = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Def Actions ---
|
||||||
|
openDefModal() {
|
||||||
|
this.isEditDef = false;
|
||||||
|
this.defForm = { VSetID: '', VSName: '', VSDesc: '' };
|
||||||
|
this.isDefModalOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
editDef(def) {
|
||||||
|
this.isEditDef = true;
|
||||||
|
this.defForm = { ...def };
|
||||||
|
this.isDefModalOpen = true;
|
||||||
|
// Since this might be triggered from the list, prevent event bubbling handled in @click.stop
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveDef() {
|
||||||
|
try {
|
||||||
|
const method = this.isEditDef ? 'PATCH' : 'POST';
|
||||||
|
await Utils.api('<?= site_url('api/valuesetdef') ?>', {
|
||||||
|
method,
|
||||||
|
body: JSON.stringify(this.defForm)
|
||||||
|
});
|
||||||
|
Alpine.store('toast').success(this.isEditDef ? 'Updated definition' : 'Created definition');
|
||||||
|
this.isDefModalOpen = false;
|
||||||
|
this.loadDefs();
|
||||||
|
|
||||||
|
// If we edited the currently selected one, update it
|
||||||
|
if(this.selectedDef && this.defForm.VSetID === this.selectedDef.VSetID) {
|
||||||
|
this.selectedDef = { ...this.defForm };
|
||||||
|
}
|
||||||
|
} catch(e) { Alpine.store('toast').error(e.message); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Value Actions ---
|
||||||
|
openValueModal() {
|
||||||
|
this.isEditValue = false;
|
||||||
|
this.valueForm = { VID: null, VValue: '', VDesc: '', SortOrder: '', VSetID: this.selectedDef.VSetID };
|
||||||
|
this.isValueModalOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
editValue(val) {
|
||||||
|
this.isEditValue = true;
|
||||||
|
this.valueForm = { ...val };
|
||||||
|
this.isValueModalOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveValue() {
|
||||||
|
try {
|
||||||
|
const method = this.isEditValue ? 'PATCH' : 'POST';
|
||||||
|
this.valueForm.VSetID = this.selectedDef.VSetID;
|
||||||
|
|
||||||
|
await Utils.api('<?= site_url('api/valueset') ?>', {
|
||||||
|
method,
|
||||||
|
body: JSON.stringify(this.valueForm)
|
||||||
|
});
|
||||||
|
Alpine.store('toast').success(this.isEditValue ? 'Updated value' : 'Added value');
|
||||||
|
this.isValueModalOpen = false;
|
||||||
|
this.selectDef(this.selectedDef); // Refresh values
|
||||||
|
} catch(e) { Alpine.store('toast').error(e.message); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteValue(val) {
|
||||||
|
if(!confirm('Delete this value?')) return;
|
||||||
|
try {
|
||||||
|
await Utils.api('<?= site_url('api/valueset') ?>', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ VID: val.VID })
|
||||||
|
});
|
||||||
|
Alpine.store('toast').success('Deleted');
|
||||||
|
this.selectDef(this.selectedDef);
|
||||||
|
} catch(e) { Alpine.store('toast').error(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
Alpine.start();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
BIN
public/assets/images/logo.png
Normal file
BIN
public/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 537 KiB |
5
public/assets/js/alpine.min.js
vendored
Normal file
5
public/assets/js/alpine.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -1,144 +1,53 @@
|
|||||||
/**
|
/**
|
||||||
* CLQMS Frontend - Global Alpine.js Components & Utilities
|
* CLQMS Frontend - App Entry Point (ESM)
|
||||||
|
* Imports Alpine, sets up global stores/utils, and exports Alpine.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Wait for Alpine to be ready
|
import Alpine from 'https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/module.esm.js';
|
||||||
document.addEventListener('alpine:init', () => {
|
|
||||||
|
|
||||||
/**
|
// Make Alpine available globally for debugging if needed
|
||||||
* Global Auth Store
|
window.Alpine = Alpine;
|
||||||
* Manages authentication state across the app
|
|
||||||
*/
|
// Base URL helper
|
||||||
|
window.BASEURL = '<?= base_url() ?>'; // This logic usually needs to be injected in HTML, but we'll assume it's set in layout
|
||||||
|
|
||||||
|
// --- Global Stores ---
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
// Auth Store
|
||||||
Alpine.store('auth', {
|
Alpine.store('auth', {
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
|
||||||
setUser(userData) {
|
setUser(userData) {
|
||||||
this.user = userData;
|
this.user = userData;
|
||||||
this.isAuthenticated = !!userData;
|
this.isAuthenticated = !!userData;
|
||||||
},
|
},
|
||||||
|
|
||||||
clearUser() {
|
clearUser() {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// Toast Store
|
||||||
* Toast Notification Store
|
|
||||||
*/
|
|
||||||
Alpine.store('toast', {
|
Alpine.store('toast', {
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|
||||||
show(message, type = 'info', duration = 4000) {
|
show(message, type = 'info', duration = 4000) {
|
||||||
const id = Date.now();
|
const id = Date.now();
|
||||||
this.messages.push({ id, message, type });
|
this.messages.push({ id, message, type });
|
||||||
|
setTimeout(() => this.dismiss(id), duration);
|
||||||
setTimeout(() => {
|
|
||||||
this.dismiss(id);
|
|
||||||
}, duration);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
dismiss(id) {
|
dismiss(id) {
|
||||||
this.messages = this.messages.filter(m => m.id !== id);
|
this.messages = this.messages.filter(m => m.id !== id);
|
||||||
},
|
},
|
||||||
|
success(msg) { this.show(msg, 'success'); },
|
||||||
success(message) { this.show(message, 'success'); },
|
error(msg) { this.show(msg, 'error', 6000); },
|
||||||
error(message) { this.show(message, 'error', 6000); },
|
info(msg) { this.show(msg, 'info'); }
|
||||||
info(message) { this.show(message, 'info'); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Login Component
|
|
||||||
*/
|
|
||||||
Alpine.data('loginForm', () => ({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
rememberMe: false,
|
|
||||||
showPassword: false,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
|
|
||||||
async submitLogin() {
|
|
||||||
// Reset error
|
|
||||||
this.error = null;
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!this.username.trim()) {
|
|
||||||
this.error = 'Please enter your username';
|
|
||||||
this.shakeForm();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.password) {
|
|
||||||
this.error = 'Please enter your password';
|
|
||||||
this.shakeForm();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start loading
|
|
||||||
this.isLoading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
},
|
|
||||||
credentials: 'include', // Important for cookies
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: this.username.trim(),
|
|
||||||
password: this.password
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data.status === 'success') {
|
|
||||||
// Store user data
|
|
||||||
Alpine.store('auth').setUser(data.data);
|
|
||||||
|
|
||||||
// Show success feedback
|
|
||||||
Alpine.store('toast').success('Login successful! Redirecting...');
|
|
||||||
|
|
||||||
// Redirect to dashboard
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/dashboard';
|
|
||||||
}, 500);
|
|
||||||
} else {
|
|
||||||
// Handle error
|
|
||||||
this.error = data.message || 'Invalid username or password';
|
|
||||||
this.shakeForm();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Login error:', err);
|
|
||||||
this.error = 'Connection error. Please try again.';
|
|
||||||
this.shakeForm();
|
|
||||||
} finally {
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
shakeForm() {
|
|
||||||
const form = this.$refs.loginCard;
|
|
||||||
if (form) {
|
|
||||||
form.classList.add('shake');
|
|
||||||
setTimeout(() => form.classList.remove('shake'), 500);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
togglePassword() {
|
|
||||||
this.showPassword = !this.showPassword;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// --- Utils ---
|
||||||
* Utility Functions
|
|
||||||
*/
|
export const Utils = {
|
||||||
const Utils = {
|
|
||||||
// Format date to locale string
|
// Format date to locale string
|
||||||
formatDate(dateString) {
|
formatDate(dateString) {
|
||||||
return new Date(dateString).toLocaleDateString('id-ID', {
|
return new Date(dateString).toLocaleDateString('id-ID', {
|
||||||
@ -148,19 +57,6 @@ const Utils = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Debounce function
|
|
||||||
debounce(func, wait) {
|
|
||||||
let timeout;
|
|
||||||
return function executedFunction(...args) {
|
|
||||||
const later = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
func(...args);
|
|
||||||
};
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
// API helper with credentials
|
// API helper with credentials
|
||||||
async api(endpoint, options = {}) {
|
async api(endpoint, options = {}) {
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
@ -172,7 +68,13 @@ const Utils = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(endpoint, { ...defaultOptions, ...options });
|
const response = await fetch(endpoint, { ...defaultOptions, ...options });
|
||||||
const data = await response.json();
|
|
||||||
|
// Handle void responses or non-json
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
let data = {};
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
data = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.message || 'API request failed');
|
throw new Error(data.message || 'API request failed');
|
||||||
@ -182,5 +84,7 @@ const Utils = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expose Utils globally
|
// Export Utils globally if needed for non-module compatibility (optional)
|
||||||
window.Utils = Utils;
|
window.Utils = Utils;
|
||||||
|
|
||||||
|
export default Alpine;
|
||||||
|
|||||||
12
public/assets/js/lucide.min.js
vendored
Normal file
12
public/assets/js/lucide.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
753
public/assets/v2/css/v2.css
Normal file
753
public/assets/v2/css/v2.css
Normal file
@ -0,0 +1,753 @@
|
|||||||
|
/* ========================================
|
||||||
|
CLQMS V2 Frontend - Dark Dev Theme
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Dark Theme Colors */
|
||||||
|
--bg-primary: #0f172a;
|
||||||
|
--bg-secondary: #1e293b;
|
||||||
|
--bg-tertiary: #334155;
|
||||||
|
--bg-card: #1e293b;
|
||||||
|
|
||||||
|
--text-primary: #f1f5f9;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-light: #818cf8;
|
||||||
|
--accent-dark: #4f46e5;
|
||||||
|
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--error: #ef4444;
|
||||||
|
--info: #38bdf8;
|
||||||
|
|
||||||
|
--border-color: #334155;
|
||||||
|
--border-radius: 12px;
|
||||||
|
--border-radius-sm: 8px;
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
--sidebar-collapsed: 64px;
|
||||||
|
|
||||||
|
--transition: 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== SIDEBAR ========== */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width var(--transition);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: var(--sidebar-collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .logo-text,
|
||||||
|
.sidebar.collapsed .nav-item span,
|
||||||
|
.sidebar.collapsed .user-info span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-toggle i {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: all var(--transition);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
color: var(--error) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== MAIN CONTENT ========== */
|
||||||
|
.main-content {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
min-height: 100vh;
|
||||||
|
transition: margin-left var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content.expanded {
|
||||||
|
margin-left: var(--sidebar-collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== CARDS ========== */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== DASHBOARD ========== */
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-key {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-value.code {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== BADGES ========== */
|
||||||
|
.badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success { background: var(--success); color: white; }
|
||||||
|
.badge-error { background: var(--error); color: white; }
|
||||||
|
.badge-warning { background: var(--warning); color: black; }
|
||||||
|
|
||||||
|
/* ========== BUTTONS ========== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== FORMS ========== */
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== API TESTER ========== */
|
||||||
|
.api-form {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-select {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-endpoints {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-endpoints .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-body {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== DB BROWSER ========== */
|
||||||
|
.db-browser {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tables-panel {
|
||||||
|
height: fit-content;
|
||||||
|
position: sticky;
|
||||||
|
top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tables-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover td {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== LOGS ========== */
|
||||||
|
.logs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-file {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-size {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header i.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content pre {
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== JWT DECODER ========== */
|
||||||
|
.token-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-raw {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-raw code {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block.highlight {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== STATES ========== */
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== SPINNER ========== */
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== TOAST ========== */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success { border-left: 3px solid var(--success); }
|
||||||
|
.toast-error { border-left: 3px solid var(--error); }
|
||||||
|
.toast-info { border-left: 3px solid var(--info); }
|
||||||
|
|
||||||
|
/* ========== RESPONSIVE ========== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .logo-text,
|
||||||
|
.sidebar .nav-item span,
|
||||||
|
.sidebar .user-info span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: var(--sidebar-collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-browser {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tables-panel {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
194
public/assets/v2/js/v2.js
Normal file
194
public/assets/v2/js/v2.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* CLQMS V2 Frontend - Alpine.js Components
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast Store
|
||||||
|
*/
|
||||||
|
Alpine.store('toast', {
|
||||||
|
messages: [],
|
||||||
|
|
||||||
|
show(message, type = 'info', duration = 4000) {
|
||||||
|
const id = Date.now();
|
||||||
|
this.messages.push({ id, message, type });
|
||||||
|
setTimeout(() => this.dismiss(id), duration);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismiss(id) {
|
||||||
|
this.messages = this.messages.filter(m => m.id !== id);
|
||||||
|
},
|
||||||
|
|
||||||
|
success(msg) { this.show(msg, 'success'); },
|
||||||
|
error(msg) { this.show(msg, 'error', 6000); },
|
||||||
|
info(msg) { this.show(msg, 'info'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Response Store
|
||||||
|
*/
|
||||||
|
Alpine.store('apiResponse', {
|
||||||
|
hasResponse: false,
|
||||||
|
status: '',
|
||||||
|
statusClass: '',
|
||||||
|
time: 0,
|
||||||
|
body: '',
|
||||||
|
|
||||||
|
set(status, body, time) {
|
||||||
|
this.hasResponse = true;
|
||||||
|
this.status = status;
|
||||||
|
this.statusClass = status >= 200 && status < 300 ? 'badge-success' : 'badge-error';
|
||||||
|
this.time = time;
|
||||||
|
this.body = typeof body === 'string' ? body : JSON.stringify(body, null, 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Tester Component
|
||||||
|
*/
|
||||||
|
Alpine.data('apiTester', () => ({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/patient',
|
||||||
|
body: '{\n \n}',
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
setEndpoint(method, url) {
|
||||||
|
this.method = method;
|
||||||
|
this.url = url;
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendRequest() {
|
||||||
|
this.loading = true;
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = {
|
||||||
|
method: this.method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.method === 'POST' || this.method === 'PATCH') {
|
||||||
|
try {
|
||||||
|
options.body = this.body;
|
||||||
|
JSON.parse(this.body); // Validate JSON
|
||||||
|
} catch (e) {
|
||||||
|
Alpine.store('toast').error('Invalid JSON body');
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this.url, options);
|
||||||
|
const endTime = performance.now();
|
||||||
|
|
||||||
|
let data;
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
data = await response.json();
|
||||||
|
} else {
|
||||||
|
data = await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
Alpine.store('apiResponse').set(
|
||||||
|
response.status,
|
||||||
|
data,
|
||||||
|
Math.round(endTime - startTime)
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
Alpine.store('toast').error('Request failed: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB Browser Component
|
||||||
|
*/
|
||||||
|
Alpine.data('dbBrowser', () => ({
|
||||||
|
tables: [],
|
||||||
|
loadingTables: true,
|
||||||
|
selectedTable: null,
|
||||||
|
tableData: null,
|
||||||
|
loadingData: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadTables();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTables() {
|
||||||
|
this.loadingTables = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/v2/api/tables', { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
this.tables = data.tables || [];
|
||||||
|
} catch (e) {
|
||||||
|
Alpine.store('toast').error('Failed to load tables');
|
||||||
|
} finally {
|
||||||
|
this.loadingTables = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectTable(table) {
|
||||||
|
this.selectedTable = table;
|
||||||
|
this.loadingData = true;
|
||||||
|
this.tableData = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/v2/api/table/${table}`, { credentials: 'include' });
|
||||||
|
this.tableData = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
Alpine.store('toast').error('Failed to load table data');
|
||||||
|
} finally {
|
||||||
|
this.loadingData = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs Viewer Component
|
||||||
|
*/
|
||||||
|
Alpine.data('logsViewer', () => ({
|
||||||
|
logs: [],
|
||||||
|
loading: true,
|
||||||
|
expandedLogs: [],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadLogs();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadLogs() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/v2/api/logs', { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
this.logs = data.logs || [];
|
||||||
|
} catch (e) {
|
||||||
|
Alpine.store('toast').error('Failed to load logs');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleLog(name) {
|
||||||
|
if (this.expandedLogs.includes(name)) {
|
||||||
|
this.expandedLogs = this.expandedLogs.filter(n => n !== name);
|
||||||
|
} else {
|
||||||
|
this.expandedLogs.push(name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user