refactor: restructure application architecture and consolidate controllers

- Consolidate page controllers into unified PagesController
- Remove deprecated V2 pages, layouts, and controllers (AuthPage, DashboardPage, V2Page)
- Add Edge resource with migration and model (EdgeResModel)
- Implement new main_layout.php for consistent page structure
- Reorganize patient views into dedicated module with dialog form
- Update routing configuration in Routes.php
- Enhance AuthFilter for improved authentication handling
- Clean up unused V2 assets (CSS, JS) and legacy images
- Update README.md with latest project information

This refactoring improves code organization, removes technical debt, and
establishes a cleaner foundation for future development.
This commit is contained in:
mahdahar 2025-12-29 16:57:46 +07:00
parent 118d490bbd
commit cb4181dbff
37 changed files with 1214 additions and 5479 deletions

View File

@ -33,7 +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` |
---
@ -50,6 +50,33 @@ Key documents:
---
## 🔌 Edge API - Instrument Integration
The **Edge API** provides endpoints for integrating laboratory instruments via the `tiny-edge` middleware. Results from instruments are staged in the `edgeres` table before processing into the main patient results (`patres`).
### Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/edge/results` | Receive instrument results (stored in `edgeres`) |
| `GET` | `/api/edge/orders` | Fetch pending orders for an instrument |
| `POST` | `/api/edge/orders/:id/ack` | Acknowledge order delivery to instrument |
| `POST` | `/api/edge/status` | Log instrument status updates |
### Workflow
```
Instrument → tiny-edge → POST /api/edge/results → edgeres table → [Manual/Auto Processing] → patres table
```
**Key Features:**
- **Staging Table:** All results land in `edgeres` first for validation
- **Rerun Handling:** Duplicate `SampleID` + `TestSiteCode` increments `AspCnt` in `patres`
- **Configurable Processing:** Auto or manual processing based on settings
- **Status Tracking:** Full audit trail via `edgestatus` and `edgeack` tables
---
### 📜 Usage Notice
This repository contains proprietary information intended for the 5Panda Team and authorized collaborators.

View File

@ -6,16 +6,23 @@ use CodeIgniter\Router\RouteCollection;
* @var RouteCollection $routes
*/
$routes->options('(:any)', function() { return ''; });
$routes->get('/', 'Pages\V2Page::index');
// Frontend Pages
$routes->get('/login', 'Pages\V2Page::login');
$routes->get('/logout', 'Pages\AuthPage::logout');
$routes->get('/dashboard', 'Pages\V2Page::index');
// Faker
$routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1');
// ===========================================
// Page Routes (Protected - returns views)
// ===========================================
$routes->group('', ['filter' => 'auth'], function($routes) {
$routes->get('/', 'PagesController::dashboard');
$routes->get('/patients', 'PagesController::patients');
$routes->get('/requests', 'PagesController::requests');
$routes->get('/settings', 'PagesController::settings');
});
// Login page (public)
$routes->get('/login', 'PagesController::login');
$routes->group('api', ['filter' => 'auth'], function($routes) {
$routes->get('dashboard', 'Dashboard::index');
$routes->get('result', 'Result::index');
@ -166,6 +173,14 @@ $routes->patch('/api/tests', 'Tests::update');
$routes->get('/api/tests/(:any)', 'Tests::show/$1');
$routes->get('/api/tests', 'Tests::index');
// Edge API - Integration with tiny-edge
$routes->group('/api/edge', function($routes) {
$routes->post('results', 'Edge::results');
$routes->get('orders', 'Edge::orders');
$routes->post('orders/(:num)/ack', 'Edge::ack/$1');
$routes->post('status', 'Edge::status');
});
// Khusus
/*
$routes->get('/api/zones', 'Zones::index');
@ -173,30 +188,3 @@ $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');
});

163
app/Controllers/Edge.php Normal file
View File

@ -0,0 +1,163 @@
<?php
namespace App\Controllers;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
class Edge extends Controller {
use ResponseTrait;
protected $db;
protected $edgeResModel;
public function __construct() {
$this->db = \Config\Database::connect();
$this->edgeResModel = new \App\Models\EdgeResModel();
}
/**
* POST /api/edge/results
* Receive results from tiny-edge
*/
public function results() {
try {
$input = $this->request->getJSON(true);
if (empty($input)) {
return $this->failValidationErrors('Invalid JSON payload');
}
// Extract key fields from payload
$sampleId = $input['sample_id'] ?? null;
$instrumentId = $input['instrument_id'] ?? null;
$patientId = $input['patient_id'] ?? null;
// Store in edgeres table
$data = [
'SiteID' => 1, // Default site, can be configured
'InstrumentID' => $instrumentId,
'SampleID' => $sampleId,
'PatientID' => $patientId,
'Payload' => json_encode($input),
'Status' => 'pending',
'AutoProcess' => 0, // Default to manual processing
'CreateDate' => date('Y-m-d H:i:s')
];
$id = $this->edgeResModel->insert($data);
if (!$id) {
return $this->failServerError('Failed to save result');
}
return $this->respondCreated([
'status' => 'success',
'message' => 'Result received and queued',
'data' => [
'edge_res_id' => $id,
'sample_id' => $sampleId,
'instrument_id' => $instrumentId
]
]);
} catch (\Throwable $e) {
return $this->failServerError('Error processing result: ' . $e->getMessage());
}
}
/**
* GET /api/edge/orders
* Return pending orders for an instrument
*/
public function orders() {
try {
$instrumentId = $this->request->getGet('instrument');
if (!$instrumentId) {
return $this->failValidationErrors('instrument parameter is required');
}
// TODO: Implement order fetching logic
// For now, return empty array
return $this->respond([
'status' => 'success',
'message' => 'Orders fetched',
'data' => []
]);
} catch (\Throwable $e) {
return $this->failServerError('Error fetching orders: ' . $e->getMessage());
}
}
/**
* POST /api/edge/orders/:id/ack
* Acknowledge order delivery
*/
public function ack($orderId = null) {
try {
if (!$orderId) {
return $this->failValidationErrors('Order ID is required');
}
$input = $this->request->getJSON(true);
$instrumentId = $input['instrument_id'] ?? null;
// Log acknowledgment
$this->db->table('edgeack')->insert([
'OrderID' => $orderId,
'InstrumentID' => $instrumentId,
'AckDate' => date('Y-m-d H:i:s'),
'CreateDate' => date('Y-m-d H:i:s')
]);
return $this->respond([
'status' => 'success',
'message' => 'Order acknowledged',
'data' => [
'order_id' => $orderId
]
]);
} catch (\Throwable $e) {
return $this->failServerError('Error acknowledging order: ' . $e->getMessage());
}
}
/**
* POST /api/edge/status
* Log instrument status
*/
public function status() {
try {
$input = $this->request->getJSON(true);
$instrumentId = $input['instrument_id'] ?? null;
$status = $input['status'] ?? null;
$lastActivity = $input['last_activity'] ?? null;
$timestamp = $input['timestamp'] ?? date('Y-m-d H:i:s');
if (!$instrumentId || !$status) {
return $this->failValidationErrors('instrument_id and status are required');
}
// Store status log
$this->db->table('edgestatus')->insert([
'InstrumentID' => $instrumentId,
'Status' => $status,
'LastActivity' => $lastActivity,
'Timestamp' => $timestamp,
'CreateDate' => date('Y-m-d H:i:s')
]);
return $this->respond([
'status' => 'success',
'message' => 'Status logged'
]);
} catch (\Throwable $e) {
return $this->failServerError('Error logging status: ' . $e->getMessage());
}
}
}

View File

@ -1,53 +0,0 @@
<?php
namespace App\Controllers\Pages;
use CodeIgniter\Controller;
/**
* Auth Pages Controller
* Handles rendering of authentication-related pages
*/
class AuthPage extends Controller
{
/**
* Display the login page
*/
public function login()
{
// Check if user is already authenticated
$token = $this->request->getCookie('token');
if ($token) {
// If token exists, redirect to dashboard
return redirect()->to('/dashboard');
}
return view('pages/login', [
'title' => 'Login',
'description' => 'Sign in to your CLQMS account'
]);
}
/**
* Handle logout - clear cookie and redirect
*/
public function logout()
{
// 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')->withCookies();
}
}

View File

@ -1,49 +0,0 @@
<?php
namespace App\Controllers\Pages;
use CodeIgniter\Controller;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
/**
* Dashboard Page Controller
* Handles rendering of the main dashboard
*/
class DashboardPage extends Controller
{
/**
* Display the dashboard page
*/
public function index()
{
// Check authentication
$token = $this->request->getCookie('token');
if (!$token) {
return redirect()->to('/login');
}
try {
$key = getenv('JWT_SECRET');
$decoded = JWT::decode($token, new Key($key, 'HS256'));
return view('pages/dashboard', [
'title' => 'Dashboard',
'description' => 'CLQMS Dashboard - Overview',
'user' => $decoded
]);
} catch (ExpiredException $e) {
// Token expired, redirect to login
$response = service('response');
$response->deleteCookie('token');
return redirect()->to('/login');
} catch (\Exception $e) {
// Invalid token
$response = service('response');
$response->deleteCookie('token');
return redirect()->to('/login');
}
}
}

View File

@ -1,345 +0,0 @@
<?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,
'activePage' => 'dashboard'
]);
}
/**
* API Tester
*/
public function apiTester()
{
if ($redirect = $this->requireAuth()) return $redirect;
return view('v2/api-tester', [
'title' => 'API Tester',
'user' => $this->user,
'activePage' => 'api-tester'
]);
}
/**
* Database Browser
*/
public function dbBrowser()
{
if ($redirect = $this->requireAuth()) return $redirect;
return view('v2/db-browser', [
'title' => 'DB Browser',
'user' => $this->user,
'activePage' => 'db-browser'
]);
}
/**
* Logs Viewer
*/
public function logs()
{
if ($redirect = $this->requireAuth()) return $redirect;
return view('v2/logs', [
'title' => 'Logs',
'user' => $this->user,
'activePage' => '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,
// activePage set below for sub-pages
'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,
'activePage' => 'valuesets'
]);
}
/**
* Patient List
*/
public function patients()
{
if ($redirect = $this->requireAuth()) return $redirect;
return view('v2/patients', [
'title' => 'Patients',
'user' => $this->user,
'activePage' => 'patients'
]);
}
/**
* Patient Create Form
*/
public function patientCreate()
{
if ($redirect = $this->requireAuth()) return $redirect;
return view('v2/patient-form', [
'title' => 'New Patient',
'user' => $this->user,
'activePage' => '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,
'activePage' => '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,
'activePage' => '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,
'activePage' => '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

@ -0,0 +1,67 @@
<?php
namespace App\Controllers;
/**
* PagesController - Serves view pages
*
* This controller only returns views. No business logic.
* All data is fetched via API calls from the frontend.
*/
class PagesController extends BaseController
{
/**
* Dashboard page
*/
public function dashboard()
{
return view('dashboard/dashboard_index', [
'pageTitle' => 'Dashboard',
'activePage' => 'dashboard'
]);
}
/**
* Patients page
*/
public function patients()
{
return view('patients/patients_index', [
'pageTitle' => 'Patients',
'activePage' => 'patients'
]);
}
/**
* Lab Requests page
*/
public function requests()
{
return view('requests/requests_index', [
'pageTitle' => 'Lab Requests',
'activePage' => 'requests'
]);
}
/**
* Settings page
*/
public function settings()
{
return view('settings/settings_index', [
'pageTitle' => 'Settings',
'activePage' => 'settings'
]);
}
/**
* Login page
*/
public function login()
{
return view('auth/login', [
'pageTitle' => 'Login',
'activePage' => ''
]);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateEdgeResTables extends Migration {
public function up() {
// Main edgeres table - staging for instrument results
$this->forge->addField([
'EdgeResID' => ['type' => 'INT', 'auto_increment' => true],
'SiteID' => ['type' => 'INT', 'null' => true],
'InstrumentID' => ['type' => 'varchar', 'constraint' => 100, 'null' => true],
'SampleID' => ['type' => 'varchar', 'constraint' => 30, 'null' => true],
'PatientID' => ['type' => 'varchar', 'constraint' => 50, 'null' => true],
'Payload' => ['type' => 'TEXT', 'null' => true],
'Status' => ['type' => 'varchar', 'constraint' => 20, 'default' => 'pending'],
'AutoProcess' => ['type' => 'TINYINT', 'default' => 0, 'null' => true],
'ProcessedAt' => ['type' => 'DATETIME', 'null' => true],
'ErrorMessage' => ['type' => 'TEXT', 'null' => true],
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
'EndDate' => ['type' => 'DATETIME', 'null' => true],
'ArchiveDate' => ['type' => 'DATETIME', 'null' => true],
'DelDate' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('EdgeResID');
$this->forge->createTable('edgeres');
// Edge status log - for instrument status tracking
$this->forge->addField([
'EdgeStatusID' => ['type' => 'INT', 'auto_increment' => true],
'InstrumentID' => ['type' => 'varchar', 'constraint' => 100, 'null' => true],
'Status' => ['type' => 'varchar', 'constraint' => 50, 'null' => true],
'LastActivity' => ['type' => 'DATETIME', 'null' => true],
'Timestamp' => ['type' => 'DATETIME', 'null' => true],
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('EdgeStatusID');
$this->forge->createTable('edgestatus');
// Edge order acknowledgment log
$this->forge->addField([
'EdgeAckID' => ['type' => 'INT', 'auto_increment' => true],
'OrderID' => ['type' => 'INT', 'null' => true],
'InstrumentID' => ['type' => 'varchar', 'constraint' => 100, 'null' => true],
'AckDate' => ['type' => 'DATETIME', 'null' => true],
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('EdgeAckID');
$this->forge->createTable('edgeack');
}
public function down() {
$this->forge->dropTable('edgeack', true);
$this->forge->dropTable('edgestatus', true);
$this->forge->dropTable('edgeres', true);
}
}

View File

@ -16,14 +16,22 @@ class AuthFilter implements FilterInterface
$key = getenv('JWT_SECRET');
$token = $request->getCookie('token'); // ambil dari cookie
// Check if this is an API request or a page request
$isApiRequest = strpos($request->getUri()->getPath(), '/api/') !== false
|| $request->isAJAX();
// Kalau tidak ada token
if (!$token) {
return Services::response()
->setStatusCode(401)
->setJSON([
'status' => 'failed',
'message' => 'Unauthorized: Token not found'
]);
if ($isApiRequest) {
return Services::response()
->setStatusCode(401)
->setJSON([
'status' => 'failed',
'message' => 'Unauthorized: Token not found'
]);
}
// Redirect to login for page requests
return redirect()->to('/v2/login');
}
try {
@ -36,12 +44,16 @@ class AuthFilter implements FilterInterface
// $request->userData = $decoded;
} catch (\Exception $e) {
return Services::response()
->setStatusCode(401)
->setJSON([
'status' => 'failed',
'message' => 'Unauthorized: ' . $e->getMessage()
]);
if ($isApiRequest) {
return Services::response()
->setStatusCode(401)
->setJSON([
'status' => 'failed',
'message' => 'Unauthorized: ' . $e->getMessage()
]);
}
// Redirect to login for page requests
return redirect()->to('/v2/login');
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class EdgeResModel extends Model {
protected $table = 'edgeres';
protected $primaryKey = 'EdgeResID';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'SiteID',
'InstrumentID',
'SampleID',
'PatientID',
'Payload',
'Status',
'AutoProcess',
'ProcessedAt',
'ErrorMessage',
'CreateDate',
'EndDate',
'ArchiveDate',
'DelDate'
];
protected $useTimestamps = false;
protected $createdField = 'CreateDate';
protected $updatedField = 'EndDate';
/**
* Get pending results for processing
*/
public function getPending($limit = 100) {
return $this->where('Status', 'pending')
->whereNull('DelDate')
->orderBy('CreateDate', 'ASC')
->findAll($limit);
}
/**
* Mark as processed
*/
public function markProcessed($id) {
return $this->update($id, [
'Status' => 'processed',
'ProcessedAt' => date('Y-m-d H:i:s')
]);
}
/**
* Mark as error
*/
public function markError($id, $errorMessage) {
return $this->update($id, [
'Status' => 'error',
'ErrorMessage' => $errorMessage,
'ProcessedAt' => date('Y-m-d H:i:s')
]);
}
}

View File

@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="en" data-theme="business">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= esc($pageTitle ?? 'CLQMS') ?> - CLQMS</title>
<!-- TailwindCSS + DaisyUI CDN -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@5.0.0-beta.9/daisyui.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/js/all.min.js"></script>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
[x-cloak] { display: none !important; }
/* Custom scrollbar for dark theme */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: rgba(0,0,0,0.1); }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
/* Sidebar transition */
.sidebar-transition { transition: width 0.3s ease, transform 0.3s ease; }
</style>
</head>
<body class="min-h-screen flex bg-base-200" x-data="layout()">
<!-- Sidebar -->
<aside
class="sidebar-transition fixed lg:relative z-40 h-screen bg-base-300 flex flex-col shadow-xl"
:class="sidebarOpen ? 'w-56' : 'w-0 lg:w-16'"
>
<!-- Sidebar Header -->
<div class="h-16 flex items-center justify-between px-4 border-b border-base-content/10" x-show="sidebarOpen" x-cloak>
<span class="text-xl font-bold text-primary">CLQMS</span>
</div>
<!-- Navigation -->
<nav class="flex-1 py-4 overflow-y-auto" :class="sidebarOpen ? 'px-3' : 'px-1'">
<ul class="menu space-y-1">
<!-- Dashboard -->
<li>
<a href="<?= base_url('/') ?>"
class="flex items-center gap-3 rounded-lg"
:class="'<?= $activePage ?? '' ?>' === 'dashboard' ? 'bg-primary text-primary-content' : 'hover:bg-base-content/10'">
<i class="fa-solid fa-th-large w-5 text-center"></i>
<span x-show="sidebarOpen" x-cloak>Dashboard</span>
</a>
</li>
<!-- Patients -->
<li>
<a href="<?= base_url('/patients') ?>"
class="flex items-center gap-3 rounded-lg"
:class="'<?= $activePage ?? '' ?>' === 'patients' ? 'bg-primary text-primary-content' : 'hover:bg-base-content/10'">
<i class="fa-solid fa-users w-5 text-center"></i>
<span x-show="sidebarOpen" x-cloak>Patients</span>
</a>
</li>
<!-- Lab Requests -->
<li>
<a href="<?= base_url('/requests') ?>"
class="flex items-center gap-3 rounded-lg"
:class="'<?= $activePage ?? '' ?>' === 'requests' ? 'bg-primary text-primary-content' : 'hover:bg-base-content/10'">
<i class="fa-solid fa-flask w-5 text-center"></i>
<span x-show="sidebarOpen" x-cloak>Lab Requests</span>
</a>
</li>
<!-- Settings -->
<li>
<a href="<?= base_url('/settings') ?>"
class="flex items-center gap-3 rounded-lg"
:class="'<?= $activePage ?? '' ?>' === 'settings' ? 'bg-primary text-primary-content' : 'hover:bg-base-content/10'">
<i class="fa-solid fa-cog w-5 text-center"></i>
<span x-show="sidebarOpen" x-cloak>Settings</span>
</a>
</li>
</ul>
</nav>
</aside>
<!-- Overlay for mobile -->
<div
x-show="sidebarOpen"
@click="sidebarOpen = false"
class="fixed inset-0 bg-black/50 z-30 lg:hidden"
x-cloak
></div>
<!-- Main Content Wrapper -->
<div class="flex-1 flex flex-col min-h-screen">
<!-- Top Navbar -->
<nav class="h-16 bg-base-100 border-b border-base-content/10 flex items-center justify-between px-4 sticky top-0 z-20">
<!-- Left: Burger Menu -->
<div class="flex items-center gap-4">
<button @click="sidebarOpen = !sidebarOpen" class="btn btn-ghost btn-sm btn-square">
<i class="fa-solid fa-bars text-lg"></i>
</button>
<h1 class="text-lg font-semibold"><?= esc($pageTitle ?? 'Dashboard') ?></h1>
</div>
<!-- Right: Theme Toggle & User -->
<div class="flex items-center gap-2">
<!-- Theme Toggle -->
<label class="swap swap-rotate btn btn-ghost btn-sm btn-square">
<input type="checkbox" class="theme-controller" value="corporate" @change="toggleTheme($event)" :checked="lightMode" />
<i class="swap-off fa-solid fa-moon text-lg"></i>
<i class="swap-on fa-solid fa-sun text-lg"></i>
</label>
<!-- User Dropdown -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-10">
<span class="text-sm">U</span>
</div>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-content/10">
<li><a href="#"><i class="fa-solid fa-user mr-2"></i> Profile</a></li>
<li><a href="#"><i class="fa-solid fa-cog mr-2"></i> Settings</a></li>
<li class="border-t border-base-content/10 mt-1 pt-1">
<a href="<?= base_url('/logout') ?>" class="text-error">
<i class="fa-solid fa-sign-out-alt mr-2"></i> Logout
</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Page Content -->
<main class="flex-1 p-4 lg:p-6 overflow-auto">
<?= $this->renderSection('content') ?>
</main>
<!-- Footer -->
<footer class="bg-base-100 border-t border-base-content/10 py-4 px-6">
<div class="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-base-content/60">
<span>© 2025 5panda. All rights reserved.</span>
<span>CLQMS v1.0.0</span>
</div>
</footer>
</div>
<!-- Global Scripts -->
<script>
window.BASEURL = "<?= base_url() ?>";
function layout() {
return {
sidebarOpen: window.innerWidth >= 1024,
lightMode: localStorage.getItem('theme') === 'corporate',
init() {
// Apply saved theme
const savedTheme = localStorage.getItem('theme') || 'business';
document.documentElement.setAttribute('data-theme', savedTheme);
this.lightMode = savedTheme === 'corporate';
// Handle resize
window.addEventListener('resize', () => {
if (window.innerWidth >= 1024) {
this.sidebarOpen = true;
}
});
},
toggleTheme(event) {
const theme = event.target.checked ? 'corporate' : 'business';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
this.lightMode = event.target.checked;
}
}
}
</script>
<?= $this->renderSection('script') ?>
</body>
</html>

View File

@ -1,60 +0,0 @@
<!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>

View File

@ -1,387 +0,0 @@
<!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 w-full">
<ul class="menu menu-sm w-full p-0 [&_li>*]:rounded-none text-sm gap-0.5">
<li>
<a href="<?= site_url('v2') ?>" class="<?= ($activePage ?? '') === '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="<?= ($activePage ?? '') === '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="<?= ($activePage ?? '') === '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="<?= ($activePage ?? '') === 'api-tester' ? 'active' : 'text-white/60 hover:text-white' ?>">API Tester</a></li>
<li><a href="<?= site_url('v2/db-browser') ?>" class="<?= ($activePage ?? '') === 'db-browser' ? 'active' : 'text-white/60 hover:text-white' ?>">DB Browser</a></li>
<li><a href="<?= site_url('v2/logs') ?>" class="<?= ($activePage ?? '') === 'logs' ? 'active' : 'text-white/60 hover:text-white' ?>">Logs</a></li>
<li><a href="<?= site_url('v2/jwt-decoder') ?>" class="<?= ($activePage ?? '') === 'jwt-decoder' ? 'active' : 'text-white/60 hover:text-white' ?>">JWT Decoder</a></li>
</ul>
</details>
</li>
</ul>
</nav>
<!-- User Profile Section -->
<div class="p-4 border-t border-white/5 bg-black/20">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="w-10 h-10 rounded-full bg-gradient-to-tr from-primary to-secondary text-white ring-2 ring-white/10 shadow-lg">
<span class="font-bold text-sm"><?= substr(esc($user->username ?? 'U'), 0, 1) ?></span>
</div>
</div>
<div class="flex-1 min-w-0 flex flex-col justify-center">
<div class="text-sm font-bold text-white truncate leading-tight"><?= esc($user->username ?? 'User') ?></div>
<div class="text-[10px] text-white/50 truncate">View Profile</div>
</div>
<a href="<?= site_url('logout') ?>" class="btn btn-ghost btn-circle btn-sm text-white/60 hover:text-error hover:bg-error/10" 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);
},
// Convenience methods following the workflow pattern
success(message) { this.show(message, 'success'); },
error(message) { this.show(message, 'error'); },
info(message) { this.show(message, 'info'); },
warning(message) { this.show(message, 'warning'); },
dismiss(id) {
this.messages = this.messages.filter(m => m.id !== id);
}
});
});
</script>
<?= $this->renderSection('script') ?>
</body>
</html>

View File

@ -1,218 +0,0 @@
<!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,328 +0,0 @@
<?= $this->extend('layouts/v2') ?>
<?= $this->section('content') ?>
<div class="space-y-6">
<!-- 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>
<!-- 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>
<?= $this->endSection() ?>

View File

@ -1,233 +0,0 @@
<?= $this->extend('layouts/v2_auth') ?>
<?= $this->section('content') ?>
<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="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 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" class="space-y-5">
<!-- Username Field -->
<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-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 -->
<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 checkbox-primary checkbox-sm"
x-model="rememberMe"
:disabled="isLoading"
/>
<span class="label-text text-base-content/70">Remember me on this device</span>
</label>
</div>
<!-- Submit 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="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>
<script>
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;
}
}
}));
});
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,188 @@
<!-- Patient Form Modal -->
<dialog id="patient_modal" class="modal" :class="showModal && 'modal-open'">
<div class="modal-box w-11/12 max-w-2xl bg-base-100">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2">
<i class="fa-solid fa-user-plus text-primary"></i>
<span x-text="isEditing ? 'Edit Patient' : 'New Patient'"></span>
</h3>
<button class="btn btn-ghost btn-sm btn-square" @click="closeModal()">
<i class="fa-solid fa-times"></i>
</button>
</div>
<!-- Form -->
<div class="space-y-4">
<!-- Patient ID -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Patient ID (MRN)</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder="Auto-generated if empty"
x-model="form.PatientID"
/>
</div>
<!-- Name Row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">First Name <span class="text-error">*</span></span>
</label>
<input
type="text"
class="input input-bordered"
:class="errors.NameFirst && 'input-error'"
x-model="form.NameFirst"
/>
<label class="label" x-show="errors.NameFirst">
<span class="label-text-alt text-error" x-text="errors.NameFirst"></span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">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 font-medium">Last Name <span class="text-error">*</span></span>
</label>
<input
type="text"
class="input input-bordered"
:class="errors.NameLast && 'input-error'"
x-model="form.NameLast"
/>
<label class="label" x-show="errors.NameLast">
<span class="label-text-alt text-error" x-text="errors.NameLast"></span>
</label>
</div>
</div>
<!-- Gender & Birthdate -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Gender</span>
</label>
<select class="select select-bordered" x-model="form.Gender">
<option value="1">Male</option>
<option value="2">Female</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Birth Date</span>
</label>
<input
type="date"
class="input input-bordered"
x-model="form.Birthdate"
/>
</div>
</div>
<!-- Contact Info -->
<div class="divider">Contact Information</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Mobile Phone</span>
</label>
<input
type="tel"
class="input input-bordered"
placeholder="+62..."
x-model="form.MobilePhone"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email</span>
</label>
<input
type="email"
class="input input-bordered"
placeholder="patient@email.com"
x-model="form.EmailAddress1"
/>
</div>
</div>
<!-- Address -->
<div class="divider">Address</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Street Address</span>
</label>
<input
type="text"
class="input input-bordered"
x-model="form.Street_1"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">City</span>
</label>
<input
type="text"
class="input input-bordered"
x-model="form.City"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Province</span>
</label>
<input
type="text"
class="input input-bordered"
x-model="form.Province"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">ZIP Code</span>
</label>
<input
type="text"
class="input input-bordered"
x-model="form.ZIP"
/>
</div>
</div>
</div>
<!-- Actions -->
<div class="modal-action">
<button class="btn btn-ghost" @click="closeModal()">Cancel</button>
<button class="btn btn-primary" @click="save()" :disabled="saving">
<span x-show="saving" class="loading loading-spinner loading-sm"></span>
<i x-show="!saving" class="fa-solid fa-save mr-1"></i>
<span x-text="saving ? 'Saving...' : 'Save Patient'"></span>
</button>
</div>
</div>
<div class="modal-backdrop bg-black/50" @click="closeModal()"></div>
</dialog>

View File

@ -0,0 +1,414 @@
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="patients()" x-init="init()">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<!-- Total Patients -->
<div class="card bg-base-100 shadow-sm border border-base-content/10">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-base-content/60">Total Patients</p>
<p class="text-2xl font-bold" x-text="stats.total">0</p>
</div>
<div class="w-12 h-12 bg-primary/20 rounded-full flex items-center justify-center">
<i class="fa-solid fa-users text-primary text-xl"></i>
</div>
</div>
</div>
</div>
<!-- New Today -->
<div class="card bg-base-100 shadow-sm border border-base-content/10">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-base-content/60">New Today</p>
<p class="text-2xl font-bold text-success" x-text="stats.newToday">0</p>
</div>
<div class="w-12 h-12 bg-success/20 rounded-full flex items-center justify-center">
<i class="fa-solid fa-user-plus text-success text-xl"></i>
</div>
</div>
</div>
</div>
<!-- Pending -->
<div class="card bg-base-100 shadow-sm border border-base-content/10">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-base-content/60">Pending Visits</p>
<p class="text-2xl font-bold text-warning" x-text="stats.pending">0</p>
</div>
<div class="w-12 h-12 bg-warning/20 rounded-full flex items-center justify-center">
<i class="fa-solid fa-clock text-warning text-xl"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="card bg-base-100 shadow-sm border border-base-content/10 mb-6">
<div class="card-body p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<!-- Search -->
<div class="join w-full sm:w-auto">
<input
type="text"
placeholder="Search by name, ID, phone..."
class="input input-bordered join-item w-full sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary join-item" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<!-- Actions -->
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i> New Patient
</button>
</div>
</div>
</div>
<!-- Patient List Table -->
<div class="card bg-base-100 shadow-sm border border-base-content/10 overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-8 text-center" x-cloak>
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-2 text-base-content/60">Loading patients...</p>
</div>
<!-- Table -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table table-zebra">
<thead class="bg-base-200">
<tr>
<th class="font-semibold">Patient ID</th>
<th class="font-semibold">Name</th>
<th class="font-semibold">Gender</th>
<th class="font-semibold">Birth Date</th>
<th class="font-semibold">Phone</th>
<th class="font-semibold text-center">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-2 text-base-content/40">
<i class="fa-solid fa-inbox text-4xl"></i>
<p>No patients found</p>
<button class="btn btn-sm btn-primary mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Patient
</button>
</div>
</td>
</tr>
</template>
<!-- Patient Rows -->
<template x-for="patient in list" :key="patient.InternalPID">
<tr class="hover:bg-base-200/50 cursor-pointer" @click="viewPatient(patient.InternalPID)">
<td>
<span class="badge badge-ghost font-mono" x-text="patient.PatientID || '-'"></span>
</td>
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-primary/20 text-primary rounded-full w-10">
<span x-text="(patient.NameFirst || '?')[0].toUpperCase()"></span>
</div>
</div>
<div>
<div class="font-medium" x-text="(patient.NameFirst || '') + ' ' + (patient.NameLast || '')"></div>
<div class="text-xs text-base-content/50" x-text="patient.EmailAddress1 || ''"></div>
</div>
</div>
</td>
<td>
<span
class="badge badge-sm"
:class="patient.Gender == 1 ? 'badge-info' : 'badge-secondary'"
x-text="patient.Gender == 1 ? 'Male' : patient.Gender == 2 ? 'Female' : '-'"
></span>
</td>
<td x-text="formatDate(patient.Birthdate)"></td>
<td x-text="patient.MobilePhone || patient.Phone || '-'"></td>
<td class="text-center" @click.stop>
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editPatient(patient.InternalPID)" title="Edit">
<i class="fa-solid fa-pen text-info"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(patient)" title="Delete">
<i class="fa-solid fa-trash text-error"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination (placeholder) -->
<div class="p-4 border-t border-base-content/10 flex items-center justify-between" x-show="list && list.length > 0">
<span class="text-sm text-base-content/60" x-text="'Showing ' + list.length + ' patients'"></span>
<div class="join">
<button class="join-item btn btn-sm">«</button>
<button class="join-item btn btn-sm btn-active">1</button>
<button class="join-item btn btn-sm">2</button>
<button class="join-item btn btn-sm">3</button>
<button class="join-item btn btn-sm">»</button>
</div>
</div>
</div>
<!-- Include Form Dialog -->
<?= $this->include('patients/dialog_form') ?>
<!-- Delete Confirmation Dialog -->
<dialog id="delete_modal" class="modal" :class="showDeleteModal && 'modal-open'">
<div class="modal-box">
<h3 class="font-bold text-lg text-error">
<i class="fa-solid fa-exclamation-triangle mr-2"></i> Confirm Delete
</h3>
<p class="py-4">
Are you sure you want to delete patient <strong x-text="deleteTarget?.PatientID"></strong>?
This action cannot be undone.
</p>
<div class="modal-action">
<button class="btn btn-ghost" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-error" @click="deletePatient()" :disabled="deleting">
<span x-show="deleting" class="loading loading-spinner loading-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
<div class="modal-backdrop bg-black/50" @click="showDeleteModal = false"></div>
</dialog>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function patients() {
return {
// State
loading: false,
list: [],
keyword: "",
// Stats
stats: {
total: 0,
newToday: 0,
pending: 0
},
// Form Modal
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
InternalPID: null,
PatientID: "",
NameFirst: "",
NameMiddle: "",
NameLast: "",
Gender: 1,
Birthdate: "",
MobilePhone: "",
EmailAddress1: "",
Street_1: "",
City: "",
Province: "",
ZIP: ""
},
// Delete Modal
showDeleteModal: false,
deleteTarget: null,
deleting: false,
// Lifecycle
async init() {
await this.fetchList();
},
// Fetch patient list
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/patient?${params}`);
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
this.stats.total = this.list.length;
// Calculate new today (simplified - you may want server-side)
const today = new Date().toISOString().split('T')[0];
this.stats.newToday = this.list.filter(p => p.CreateDate && p.CreateDate.startsWith(today)).length;
} catch (err) {
console.error(err);
this.list = [];
} finally {
this.loading = false;
}
},
// Format date
formatDate(dateStr) {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
} catch {
return dateStr;
}
},
// View patient details
viewPatient(id) {
// Could navigate to detail page or open drawer
console.log('View patient:', id);
},
// Show form for new patient
showForm() {
this.isEditing = false;
this.form = {
InternalPID: null,
PatientID: "",
NameFirst: "",
NameMiddle: "",
NameLast: "",
Gender: 1,
Birthdate: "",
MobilePhone: "",
EmailAddress1: "",
Street_1: "",
City: "",
Province: "",
ZIP: ""
};
this.errors = {};
this.showModal = true;
},
// Edit patient
async editPatient(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/patient/${id}`);
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
alert('Failed to load patient');
}
},
// Validate form
validate() {
const e = {};
if (!this.form.NameFirst?.trim()) e.NameFirst = "First name is required";
if (!this.form.NameLast?.trim()) e.NameLast = "Last name is required";
this.errors = e;
return Object.keys(e).length === 0;
},
// Close modal
closeModal() {
this.showModal = false;
this.errors = {};
},
// Save patient
async save() {
if (!this.validate()) return;
this.saving = true;
try {
let res;
if (this.isEditing && this.form.InternalPID) {
res = await fetch(`${BASEURL}api/patient`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
} else {
res = await fetch(`${BASEURL}api/patient`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
}
const data = await res.json();
if (res.ok) {
this.closeModal();
await this.fetchList();
} else {
alert(data.message || "Failed to save");
}
} catch (err) {
console.error(err);
alert("Failed to save patient");
} finally {
this.saving = false;
}
},
// Confirm delete
confirmDelete(patient) {
this.deleteTarget = patient;
this.showDeleteModal = true;
},
// Delete patient
async deletePatient() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/patient`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InternalPID: this.deleteTarget.InternalPID })
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
} else {
alert("Failed to delete");
}
} catch (err) {
console.error(err);
alert("Failed to delete patient");
} finally {
this.deleting = false;
this.deleteTarget = null;
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -1,37 +0,0 @@
# 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

@ -1,68 +0,0 @@
<?= $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() ?>

View File

@ -1,169 +0,0 @@
<?= $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

@ -1,80 +0,0 @@
<?= $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

@ -1,94 +0,0 @@
<?= $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() ?>

View File

@ -1,70 +0,0 @@
<?= $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() ?>

View File

@ -1,51 +0,0 @@
<?= $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

@ -1,297 +0,0 @@
<?= $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 -->
<template x-teleport="body">
<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>
</template>
</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

@ -1,456 +0,0 @@
<?= $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

@ -1,305 +0,0 @@
<?= $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() ?>

View File

@ -1,694 +0,0 @@
<?= $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
NOTE: Per /php-alpinejs-pattern workflow, for larger apps this can be
separated into: <?php echo $this->include('patients/dialog_form'); ?>
-->
<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>
</template>
<!-- Delete Confirmation Modal -->
<template x-teleport="body">
<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>
</template>
</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() ?>

View File

@ -1,390 +0,0 @@
<?= $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 -->
<template x-teleport="body">
<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>
</template>
<!-- Value Modal -->
<template x-teleport="body">
<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>
</template>
</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() ?>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

View File

@ -1,90 +0,0 @@
/**
* CLQMS Frontend - App Entry Point (ESM)
* Imports Alpine, sets up global stores/utils, and exports Alpine.
*/
import Alpine from 'https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/module.esm.js';
// 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 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'); }
});
});
// --- Utils ---
export const Utils = {
// Format date to locale string
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
},
// API helper with credentials
async api(endpoint, options = {}) {
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include'
};
const response = await fetch(endpoint, { ...defaultOptions, ...options });
// 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');
}
return data;
}
};
// Export Utils globally if needed for non-module compatibility (optional)
window.Utils = Utils;
export default Alpine;

File diff suppressed because one or more lines are too long

View File

@ -1,753 +0,0 @@
/* ========================================
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;
}
}

View File

@ -1,194 +0,0 @@
/**
* 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';
}
}));
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB