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:
parent
118d490bbd
commit
cb4181dbff
29
README.md
29
README.md
@ -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.
|
||||
|
||||
|
||||
@ -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
163
app/Controllers/Edge.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
67
app/Controllers/PagesController.php
Normal file
67
app/Controllers/PagesController.php
Normal 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' => ''
|
||||
]);
|
||||
}
|
||||
}
|
||||
58
app/Database/Migrations/2025-12-29-150000_EdgeRes.php
Normal file
58
app/Database/Migrations/2025-12-29-150000_EdgeRes.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
63
app/Models/EdgeResModel.php
Normal file
63
app/Models/EdgeResModel.php
Normal 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')
|
||||
]);
|
||||
}
|
||||
}
|
||||
188
app/Views/layout/main_layout.php
Normal file
188
app/Views/layout/main_layout.php
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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">
|
||||
© <?= 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() ?>
|
||||
188
app/Views/patients/dialog_form.php
Normal file
188
app/Views/patients/dialog_form.php
Normal 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>
|
||||
414
app/Views/patients/patients_index.php
Normal file
414
app/Views/patients/patients_index.php
Normal 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() ?>
|
||||
@ -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
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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() ?>
|
||||
@ -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"> </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() ?>
|
||||
@ -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 |
@ -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;
|
||||
12
public/assets/js/lucide.min.js
vendored
12
public/assets/js/lucide.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 |
Loading…
x
Reference in New Issue
Block a user