adding v2 frontend

This commit is contained in:
mahdahar 2025-12-22 16:54:19 +07:00
parent eb305d8567
commit 061af6e6d7
34 changed files with 7146 additions and 349 deletions

27
.agent/workflows/agent.md Normal file
View 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

View File

@ -33,6 +33,7 @@ The system is currently undergoing a strategic **Architectural Redesign** to con
| **Framework** | CodeIgniter 4 |
| **Security** | JWT (JSON Web Tokens) Authorization |
| **Database** | MySQL (Optimized Schema Migration in progress) |
| **Dev Tools** | Internal utilities available at `/v2` |
---

View File

@ -16,7 +16,7 @@ class App extends BaseConfig
*
* 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.
@ -40,7 +40,8 @@ class App extends BaseConfig
* something else. If you have configured your web server to remove this file
* from your site URIs, set this variable to an empty string.
*/
public string $indexPage = 'index.php';
#public string $indexPage = 'index.php';
public string $indexPage = '';
/**
* --------------------------------------------------------------------------

View File

@ -53,7 +53,7 @@ class Filters extends BaseFilters
*/
public array $required = [
'before' => [
'forcehttps', // Force Global Secure Requests
// 'forcehttps', // Force Global Secure Requests - disabled for localhost
'pagecache', // Web Page Caching
],
'after' => [

View File

@ -6,12 +6,12 @@ use CodeIgniter\Router\RouteCollection;
* @var RouteCollection $routes
*/
$routes->options('(:any)', function() { return ''; });
$routes->get('/', 'Home::index');
$routes->get('/', 'Pages\V2Page::index');
// Frontend Pages
$routes->get('/login', 'Pages\AuthPage::login');
$routes->get('/login', 'Pages\V2Page::login');
$routes->get('/logout', 'Pages\AuthPage::logout');
$routes->get('/dashboard', 'Pages\DashboardPage::index');
$routes->get('/dashboard', 'Pages\V2Page::index');
// Faker
$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/(: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->patch('/api/valueset', 'ValueSet\ValueSet::update');
$routes->delete('/api/valueset', 'ValueSet\ValueSet::delete');
$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->patch('/api/valuesetdef', 'ValueSet\ValueSetDef::update');
$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/provinces', 'Zones::getProvinces');
$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');
});

View File

@ -112,14 +112,15 @@ class Auth extends Controller {
}
// 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([
'name' => 'token', // nama token
'value' => $jwt, // value dari jwt yg sudah di hash
'expire' => 864000, // 10 hari
'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
'samesite' => Cookie::SAMESITE_NONE
'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX
]);
// Response tanpa token di body
@ -133,14 +134,15 @@ class Auth extends Controller {
// ok
public function logout() {
// Definisikan ini pada cookies browser, harus sama dengan cookies login
$isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
return $this->response->setCookie([
'name' => 'token',
'value' => '',
'expire' => time() - 3600,
'path' => '/',
'secure' => true,
'secure' => $isSecure,
'httponly' => true,
'samesite' => Cookie::SAMESITE_NONE
'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX
])->setJSON([
'status' => 'success',

View File

@ -34,10 +34,20 @@ class AuthPage extends Controller
*/
public function logout()
{
// Delete the token cookie
$response = service('response');
$response->deleteCookie('token');
// Determine secure status matching Auth controller logic
$isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
// 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();
}
}

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

View File

@ -9,10 +9,12 @@ class DummySeeder extends Seeder {
$now = date('Y-m-d H:i:s');
// users
// Password: 'password' for all users (bcrypt hash)
$passwordHash = password_hash('password', PASSWORD_BCRYPT);
$data = [
['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'],
['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => '$2y$12$KwPedIPb7K/0IR/8/FcwdOMG4eBNNAXSjXnbkB26SwjH4Nf7PaYBe'],
['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'],
['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => $passwordHash],
['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => $passwordHash],
['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => $passwordHash],
];
$this->db->table('users')->insertBatch($data);

View File

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

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

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

View File

@ -1,40 +1,328 @@
<?= $this->extend('layouts/main') ?>
<?= $this->extend('layouts/v2') ?>
<?= $this->section('content') ?>
<div style="min-height: 100vh; padding: 2rem;">
<div class="card card-glass fade-in" style="max-width: 600px; margin: 2rem auto; text-align: center;">
<div class="space-y-6">
<div class="login-logo" style="margin-bottom: 1.5rem;">
<i data-lucide="layout-dashboard"></i>
<!-- Welcome Hero Section -->
<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>
<h1 style="margin-bottom: 0.5rem;">🎉 Welcome to Dashboard!</h1>
<p class="text-muted" style="margin-bottom: 2rem;">
You're successfully logged in. This is a placeholder page.
</p>
<?php if (isset($user)): ?>
<div class="alert alert-success" style="text-align: left;">
<i data-lucide="check-circle" style="width: 18px; height: 18px;"></i>
<span>Logged in as: <strong><?= esc($user->username ?? 'User') ?></strong></span>
</div>
<?php endif; ?>
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
<a href="/login" class="btn btn-secondary">
<i data-lucide="arrow-left" style="width: 18px; height: 18px;"></i>
Back to Login
</a>
<form action="/logout" method="get" style="margin: 0;">
<button type="submit" class="btn btn-primary">
<i data-lucide="log-out" style="width: 18px; height: 18px;"></i>
Logout
</button>
</form>
<!-- Recent Activity Section -->
<div class="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-info/20 flex items-center justify-center">
<i data-lucide="clock" class="w-4 h-4 text-info"></i>
</div>
Recent Activity
</h2>
<a href="#" class="btn btn-ghost btn-sm gap-1">
View All
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr class="text-xs uppercase tracking-wider">
<th>Time</th>
<th>Action</th>
<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>
<?= $this->endSection() ?>

View File

@ -1,130 +1,233 @@
<?= $this->extend('layouts/main') ?>
<?= $this->extend('layouts/v2_auth') ?>
<?= $this->section('content') ?>
<div class="login-container">
<div class="login-card card card-glass fade-in" x-data="loginForm" x-ref="loginCard">
<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">
<!-- 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 -->
<div class="login-header">
<div class="login-logo">
<i data-lucide="flask-conical"></i>
</div>
<h1 class="login-title">Welcome Back!</h1>
<p class="login-subtitle">Sign in to your CLQMS account</p>
<div class="mb-8 text-center lg:text-left">
<h2 class="text-2xl font-bold text-base-content mb-2">Welcome Back!</h2>
<p class="text-base-content/60">Sign in to access your laboratory dashboard</p>
</div>
<!-- Error Alert -->
<template x-if="error">
<div class="alert alert-error" x-transition>
<i data-lucide="alert-circle" style="width: 18px; height: 18px;"></i>
<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" class="w-5 h-5"></i>
<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>
</template>
<!-- Login Form -->
<form @submit.prevent="submitLogin">
<form @submit.prevent="submitLogin" class="space-y-5">
<!-- Username Field -->
<div class="form-group">
<label class="form-label" for="username">Username</label>
<div class="form-input-icon">
<i data-lucide="user" class="icon" style="width: 18px; height: 18px;"></i>
<input
type="text"
id="username"
class="form-input"
placeholder="Enter your username"
x-model="username"
:disabled="isLoading"
autocomplete="username"
autofocus
>
</div>
<div class="form-control w-full">
<label class="label pb-1">
<span class="label-text font-semibold text-base-content/80">Username</span>
</label>
<input
type="text"
class="input input-bordered w-full focus:input-primary focus:shadow-lg focus:shadow-primary/10 transition-all"
placeholder="Enter your username"
x-model="username"
:disabled="isLoading"
autocomplete="username"
autofocus
/>
</div>
<!-- Password Field -->
<div class="form-group">
<label class="form-label" for="password">Password</label>
<div class="form-input-icon">
<i data-lucide="lock" class="icon" style="width: 18px; height: 18px;"></i>
<input
:type="showPassword ? 'text' : 'password'"
id="password"
class="form-input"
placeholder="Enter your password"
x-model="password"
:disabled="isLoading"
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 class="form-control w-full">
<label class="label pb-1 justify-between">
<span class="label-text font-semibold text-base-content/80">Password</span>
<a href="#" class="link link-primary link-hover text-xs font-medium">Forgot password?</a>
</label>
<input
:type="showPassword ? 'text' : 'password'"
class="input input-bordered w-full focus:input-primary focus:shadow-lg focus:shadow-primary/10 transition-all"
placeholder="Enter your password"
x-model="password"
:disabled="isLoading"
autocomplete="current-password"
/>
</div>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between mb-4">
<label class="checkbox-wrapper">
<!-- Remember Me -->
<div class="form-control">
<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
type="checkbox"
class="checkbox-input"
class="checkbox checkbox-primary checkbox-sm"
x-model="rememberMe"
:disabled="isLoading"
>
<span class="checkbox-label">Remember me</span>
/>
<span class="label-text text-base-content/70">Remember me on this device</span>
</label>
<a href="#" class="text-sm text-primary">Forgot password?</a>
</div>
<!-- Submit Button -->
<button
type="submit"
class="btn btn-primary btn-lg btn-block"
:disabled="isLoading"
>
<template x-if="isLoading">
<div class="spinner"></div>
</template>
<template x-if="!isLoading">
<i data-lucide="log-in" style="width: 20px; height: 20px;"></i>
</template>
<span x-text="isLoading ? 'Signing in...' : 'Sign In'"></span>
</button>
<div class="form-control pt-2">
<button
type="submit"
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">
<span class="loading loading-spinner loading-sm"></span>
</template>
<span x-text="isLoading ? 'Signing in...' : 'Sign In'"></span>
<i x-show="!isLoading" data-lucide="arrow-right" class="w-4 h-4"></i>
</button>
</div>
</form>
<!-- Divider -->
<div class="divider text-xs text-base-content/30 my-6">SECURE CONNECTION</div>
<!-- Footer -->
<div class="login-footer">
<p class="text-muted">
&copy; <?= date('Y') ?> CLQMS • Clinical Laboratory QMS
<div class="text-center space-y-2">
<div class="flex justify-center gap-4">
<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">
&copy; <?= date('Y') ?> Clinical Laboratory QMS
</p>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
// Re-initialize Lucide icons after Alpine updates the DOM
document.addEventListener('alpine:initialized', () => {
// Watch for DOM changes and re-create icons
const observer = new MutationObserver(() => {
lucide.createIcons();
document.addEventListener('alpine:init', () => {
Alpine.data('loginForm', () => ({
username: '',
password: '',
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>
<?= $this->endSection() ?>

37
app/Views/v2/README.md Normal file
View 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

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

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

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

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

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

View 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
View 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">&nbsp;</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
View 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() ?>

1852
llms.txt Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

5
public/assets/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -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
document.addEventListener('alpine:init', () => {
import Alpine from 'https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/module.esm.js';
/**
* Global Auth Store
* Manages authentication state across the app
*/
// Make Alpine available globally for debugging if needed
window.Alpine = Alpine;
// 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', {
user: null,
isAuthenticated: false,
setUser(userData) {
this.user = userData;
this.isAuthenticated = !!userData;
},
clearUser() {
this.user = null;
this.isAuthenticated = false;
}
});
/**
* Toast Notification Store
*/
// 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);
setTimeout(() => this.dismiss(id), duration);
},
dismiss(id) {
this.messages = this.messages.filter(m => m.id !== id);
},
success(message) { this.show(message, 'success'); },
error(message) { this.show(message, 'error', 6000); },
info(message) { this.show(message, 'info'); }
success(msg) { this.show(msg, 'success'); },
error(msg) { this.show(msg, 'error', 6000); },
info(msg) { this.show(msg, '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;
}
}));
});
/**
* Utility Functions
*/
const Utils = {
// --- Utils ---
export const Utils = {
// Format date to locale string
formatDate(dateString) {
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
async api(endpoint, options = {}) {
const defaultOptions = {
@ -172,7 +68,13 @@ const Utils = {
};
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) {
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;
export default Alpine;

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