2025-12-30 14:30:35 +07:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en" data-theme="light">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<title><?= esc($pageTitle ?? 'CLQMS') ?> - CLQMS</title>
|
|
|
|
|
|
|
|
|
|
<!-- 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">
|
|
|
|
|
|
|
|
|
|
<!-- TailwindCSS 4 CDN -->
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
|
|
|
|
|
|
|
|
<!-- Custom Styles -->
|
|
|
|
|
<link rel="stylesheet" href="<?= base_url('css/v2/styles.css') ?>">
|
|
|
|
|
|
|
|
|
|
<!-- FontAwesome -->
|
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/js/all.min.js"></script>
|
|
|
|
|
|
|
|
|
|
<!-- Alpine.js -->
|
2025-12-30 16:54:33 +07:00
|
|
|
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
|
2025-12-30 14:30:35 +07:00
|
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
|
|
|
</head>
|
|
|
|
|
<body class="min-h-screen flex" style="background: rgb(var(--color-bg));" x-data="layout()">
|
|
|
|
|
|
|
|
|
|
<!-- Sidebar -->
|
|
|
|
|
<aside
|
|
|
|
|
class="sidebar sticky top-0 z-40 h-screen flex flex-col shadow-2xl"
|
|
|
|
|
:class="sidebarOpen ? 'w-64' : 'w-0 lg:w-20'"
|
|
|
|
|
style="transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1);"
|
|
|
|
|
>
|
|
|
|
|
<!-- Sidebar Header -->
|
|
|
|
|
<div class="h-16 flex items-center justify-between px-4 border-b border-white/10" x-show="sidebarOpen" x-cloak>
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-lg">
|
|
|
|
|
<i class="fa-solid fa-flask text-white text-lg"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="text-xl font-bold text-white">CLQMS</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Collapsed Logo -->
|
|
|
|
|
<div class="h-16 flex items-center justify-center border-b border-white/10" x-show="!sidebarOpen" x-cloak>
|
|
|
|
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-lg">
|
|
|
|
|
<i class="fa-solid fa-flask text-white text-lg"></i>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Navigation -->
|
|
|
|
|
<nav class="flex-1 py-6 overflow-y-auto" :class="sidebarOpen ? 'px-4' : 'px-2'">
|
|
|
|
|
<ul class="menu">
|
|
|
|
|
<!-- Dashboard -->
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/') ?>"
|
|
|
|
|
:class="isActive('v2') ? 'active' : ''"
|
|
|
|
|
class="group">
|
|
|
|
|
<i class="fa-solid fa-th-large w-5 text-center transition-transform group-hover:scale-110"></i>
|
|
|
|
|
<span x-show="sidebarOpen" x-cloak>Dashboard</span>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
|
|
|
|
|
<!-- Patients -->
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/patients') ?>"
|
|
|
|
|
:class="isActive('patients') ? 'active' : ''"
|
|
|
|
|
class="group">
|
|
|
|
|
<i class="fa-solid fa-users w-5 text-center transition-transform group-hover:scale-110"></i>
|
|
|
|
|
<span x-show="sidebarOpen" x-cloak>Patients</span>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
|
|
|
|
|
<!-- Lab Requests -->
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/requests') ?>"
|
|
|
|
|
:class="isActive('requests') ? 'active' : ''"
|
|
|
|
|
class="group">
|
|
|
|
|
<i class="fa-solid fa-flask w-5 text-center transition-transform group-hover:scale-110"></i>
|
|
|
|
|
<span x-show="sidebarOpen" x-cloak>Lab Requests</span>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
|
|
|
|
|
<!-- Master Data Sections -->
|
|
|
|
|
<template x-if="sidebarOpen">
|
|
|
|
|
<li class="px-3 py-2 mt-4 mb-1">
|
|
|
|
|
<span class="text-xs font-semibold uppercase tracking-wider opacity-60">Master Data</span>
|
|
|
|
|
</li>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<!-- Organization (Nested Group) -->
|
|
|
|
|
<li>
|
|
|
|
|
<div x-data="{
|
|
|
|
|
isOpen: orgOpen,
|
|
|
|
|
toggle() { this.isOpen = !this.isOpen; $root.layout().orgOpen = this.isOpen }
|
|
|
|
|
}" x-init="$watch('orgOpen', v => isOpen = v)">
|
|
|
|
|
<button @click="isOpen = !isOpen"
|
|
|
|
|
class="group w-full flex items-center justify-between"
|
|
|
|
|
:class="isParentActive('organization') ? 'text-primary font-medium' : ''">
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
<i class="fa-solid fa-building w-5 text-center transition-transform group-hover:scale-110"></i>
|
|
|
|
|
<span x-show="sidebarOpen">Organization</span>
|
|
|
|
|
</div>
|
|
|
|
|
<i x-show="sidebarOpen" class="fa-solid fa-chevron-down text-xs transition-transform" :class="isOpen && 'rotate-180'"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<ul x-show="isOpen && sidebarOpen" x-collapse class="ml-8 mt-2 space-y-1">
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/master/organization/accounts') ?>"
|
|
|
|
|
:class="isActive('organization/accounts') ? 'active' : ''"
|
|
|
|
|
class="text-sm">Accounts</a>
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/master/organization/sites') ?>"
|
|
|
|
|
:class="isActive('organization/sites') ? 'active' : ''"
|
|
|
|
|
class="text-sm">Sites</a>
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/master/organization/disciplines') ?>"
|
|
|
|
|
:class="isActive('organization/disciplines') ? 'active' : ''"
|
|
|
|
|
class="text-sm">Disciplines</a>
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/master/organization/departments') ?>"
|
|
|
|
|
:class="isActive('organization/departments') ? 'active' : ''"
|
|
|
|
|
class="text-sm">Departments</a>
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/master/organization/workstations') ?>"
|
|
|
|
|
:class="isActive('organization/workstations') ? 'active' : ''"
|
|
|
|
|
class="text-sm">Workstations</a>
|
|
|
|
|
</li>
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
|
|
|
|
|
<!-- Specimen (Nested Group) -->
|
|
|
|
|
<li>
|
|
|
|
|
<div x-data="{
|
|
|
|
|
isOpen: specimenOpen,
|
|
|
|
|
toggle() { this.isOpen = !this.isOpen; $root.layout().specimenOpen = this.isOpen }
|
|
|
|
|
}" x-init="$watch('specimenOpen', v => isOpen = v)">
|
|
|
|
|
<button @click="isOpen = !isOpen"
|
|
|
|
|
class="group w-full flex items-center justify-between"
|
|
|
|
|
:class="isParentActive('specimen') ? 'text-primary font-medium' : ''">
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
<i class="fa-solid fa-vial w-5 text-center transition-transform group-hover:scale-110"></i>
|
|
|
|
|
<span x-show="sidebarOpen">Specimen</span>
|
|
|
|
|
</div>
|
|
|
|
|
<i x-show="sidebarOpen" class="fa-solid fa-chevron-down text-xs transition-transform" :class="isOpen && 'rotate-180'"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<ul x-show="isOpen && sidebarOpen" x-collapse class="ml-8 mt-2 space-y-1">
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/master/specimen/containers') ?>"
|
|
|
|
|
:class="isActive('specimen/containers') ? 'active' : ''"
|
|
|
|
|
class="text-sm">Container Defs</a>
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/master/specimen/preparations') ?>"
|
|
|
|
|
:class="isActive('specimen/preparations') ? 'active' : ''"
|
|
|
|
|
class="text-sm">Preparations</a>
|
|
|
|
|
</li>
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
|
|
|
|
|
<!-- Lab Tests -->
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/master/tests') ?>"
|
|
|
|
|
:class="isActive('master/tests') ? 'active' : ''"
|
|
|
|
|
class="group">
|
|
|
|
|
<i class="fa-solid fa-microscope w-5 text-center transition-transform group-hover:scale-110"></i>
|
|
|
|
|
<span x-show="sidebarOpen">Lab Tests</span>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
|
|
|
|
|
<!-- Value Sets -->
|
|
|
|
|
<li>
|
|
|
|
|
<a href="<?= base_url('/v2/master/valuesets') ?>"
|
|
|
|
|
:class="isActive('master/valuesets') ? 'active' : ''"
|
|
|
|
|
class="group">
|
|
|
|
|
<i class="fa-solid fa-list-check w-5 text-center transition-transform group-hover:scale-110"></i>
|
|
|
|
|
<span x-show="sidebarOpen">Value Sets</span>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
|
|
|
|
|
<!-- Settings -->
|
|
|
|
|
<li class="mt-4">
|
|
|
|
|
<a href="<?= base_url('/v2/settings') ?>"
|
|
|
|
|
:class="isActive('settings') ? 'active' : ''"
|
|
|
|
|
class="group">
|
|
|
|
|
<i class="fa-solid fa-cog w-5 text-center transition-transform group-hover:scale-110"></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 backdrop-blur-sm"
|
|
|
|
|
x-cloak
|
|
|
|
|
x-transition:enter="transition ease-out duration-200"
|
|
|
|
|
x-transition:enter-start="opacity-0"
|
|
|
|
|
x-transition:enter-end="opacity-100"
|
|
|
|
|
x-transition:leave="transition ease-in duration-150"
|
|
|
|
|
x-transition:leave-start="opacity-100"
|
|
|
|
|
x-transition:leave-end="opacity-0"
|
|
|
|
|
></div>
|
|
|
|
|
|
|
|
|
|
<!-- Main Content Wrapper -->
|
|
|
|
|
<div class="flex-1 flex flex-col min-h-screen">
|
|
|
|
|
|
|
|
|
|
<!-- Top Navbar -->
|
|
|
|
|
<nav class="h-16 flex items-center justify-between px-4 lg:px-6 sticky top-0 z-20 glass shadow-sm">
|
|
|
|
|
<!-- Left: Burger Menu & Title -->
|
|
|
|
|
<div class="flex items-center gap-4">
|
|
|
|
|
<button @click="sidebarOpen = !sidebarOpen" class="btn btn-ghost btn-square">
|
|
|
|
|
<i class="fa-solid fa-bars text-lg"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<h1 class="text-lg font-semibold" style="color: rgb(var(--color-text));"><?= esc($pageTitle ?? 'Dashboard') ?></h1>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Right: Theme Toggle & User -->
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<!-- Theme Toggle -->
|
|
|
|
|
<button @click="toggleTheme()" class="btn btn-ghost btn-square group" title="Toggle theme">
|
|
|
|
|
<i x-show="lightMode" class="fa-solid fa-moon text-lg transition-transform group-hover:rotate-12"></i>
|
|
|
|
|
<i x-show="!lightMode" class="fa-solid fa-sun text-lg transition-transform group-hover:rotate-45"></i>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<!-- User Dropdown -->
|
|
|
|
|
<div class="dropdown dropdown-end" x-data="{ open: false }">
|
|
|
|
|
<button @click="open = !open" class="btn btn-ghost gap-2 px-3">
|
|
|
|
|
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-md">
|
|
|
|
|
<span class="text-xs font-semibold text-white">U</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="hidden sm:inline text-sm font-medium">User</span>
|
|
|
|
|
<i class="fa-solid fa-chevron-down text-xs opacity-60 transition-transform" :class="open && 'rotate-180'"></i>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<!-- Dropdown Content -->
|
|
|
|
|
<div
|
|
|
|
|
x-show="open"
|
|
|
|
|
@click.away="open = false"
|
|
|
|
|
x-cloak
|
|
|
|
|
class="dropdown-content mt-2 w-72 shadow-2xl"
|
|
|
|
|
x-transition:enter="transition ease-out duration-150"
|
|
|
|
|
x-transition:enter-start="opacity-0 transform scale-95"
|
|
|
|
|
x-transition:enter-end="opacity-100 transform scale-100"
|
|
|
|
|
x-transition:leave="transition ease-in duration-100"
|
|
|
|
|
x-transition:leave-start="opacity-100 transform scale-100"
|
|
|
|
|
x-transition:leave-end="opacity-0 transform scale-95"
|
|
|
|
|
>
|
|
|
|
|
<!-- User Info Header -->
|
|
|
|
|
<div class="px-4 py-4" style="border-bottom: 1px solid rgb(var(--color-border));">
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-blue-600 to-blue-900 flex items-center justify-center shadow-lg">
|
|
|
|
|
<span class="text-sm font-semibold text-white">U</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p class="font-semibold text-sm" style="color: rgb(var(--color-text));">User Name</p>
|
|
|
|
|
<p class="text-xs" style="color: rgb(var(--color-text-muted));">user@example.com</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Menu Items -->
|
|
|
|
|
<ul class="menu menu-sm p-2">
|
|
|
|
|
<li>
|
|
|
|
|
<a href="#" class="flex items-center gap-3 py-2">
|
|
|
|
|
<i class="fa-solid fa-user w-4 text-center"></i>
|
|
|
|
|
<span>Profile</span>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<a href="#" class="flex items-center gap-3 py-2">
|
|
|
|
|
<i class="fa-solid fa-cog w-4 text-center"></i>
|
|
|
|
|
<span>Settings</span>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
|
|
|
<!-- Logout -->
|
|
|
|
|
<div style="border-top: 1px solid rgb(var(--color-border));" class="p-2">
|
|
|
|
|
<button @click="logout()" class="btn btn-ghost btn-sm w-full justify-start gap-3 hover:bg-red-50" style="color: rgb(var(--color-error));">
|
|
|
|
|
<i class="fa-solid fa-sign-out-alt w-4 text-center"></i>
|
|
|
|
|
<span>Logout</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
<!-- Page Content -->
|
|
|
|
|
<main class="flex-1 p-4 lg:p-6 overflow-auto">
|
|
|
|
|
<?= $this->renderSection('content') ?>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<!-- Footer -->
|
|
|
|
|
<footer class="glass border-t py-4 px-6" style="border-color: rgb(var(--color-border));">
|
|
|
|
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm" style="color: rgb(var(--color-text-muted));">
|
|
|
|
|
<span>© 2025 5Panda. All rights reserved.</span>
|
|
|
|
|
<span>CLQMS v1.0.0</span>
|
|
|
|
|
</div>
|
|
|
|
|
</footer>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Global Scripts -->
|
|
|
|
|
<script>
|
|
|
|
|
window.BASEURL = "<?= base_url() ?>".replace(/\/$/, "") + "/";
|
|
|
|
|
|
|
|
|
|
function layout() {
|
|
|
|
|
return {
|
|
|
|
|
sidebarOpen: localStorage.getItem('sidebarOpen') !== 'false',
|
|
|
|
|
lightMode: localStorage.getItem('theme') !== 'dark',
|
|
|
|
|
orgOpen: false,
|
|
|
|
|
specimenOpen: false,
|
|
|
|
|
currentPath: window.location.pathname,
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
// Apply saved theme (default to light theme)
|
|
|
|
|
const savedTheme = localStorage.getItem('theme') || 'light';
|
|
|
|
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
|
|
|
|
this.lightMode = savedTheme === 'light';
|
|
|
|
|
|
|
|
|
|
// Detect sidebar open/closed for mobile
|
|
|
|
|
if (window.innerWidth < 1024) this.sidebarOpen = false;
|
|
|
|
|
|
|
|
|
|
// Auto-expand menus based on active path
|
|
|
|
|
this.orgOpen = this.currentPath.includes('organization');
|
|
|
|
|
this.specimenOpen = this.currentPath.includes('specimen');
|
|
|
|
|
|
|
|
|
|
// Watch sidebar state to persist
|
|
|
|
|
this.$watch('sidebarOpen', val => localStorage.setItem('sidebarOpen', val));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
isActive(path) {
|
|
|
|
|
// Get the current path without query strings or hash
|
|
|
|
|
const current = window.location.pathname;
|
|
|
|
|
|
|
|
|
|
// Handle dashboard as root - exact match only
|
|
|
|
|
if (path === 'v2') {
|
|
|
|
|
return current === '/v2' || current === '/v2/' || current === '/clqms-be/v2' || current === '/clqms-be/v2/';
|
|
|
|
|
}
|
|
|
|
|
// For other paths, check if current path contains the expected path segment
|
|
|
|
|
// Use exact match with /v2/ prefix
|
|
|
|
|
const checkPath = '/v2/' + path;
|
|
|
|
|
return current.includes(checkPath);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
isParentActive(parent) {
|
|
|
|
|
return this.currentPath.includes(parent);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
toggleTheme() {
|
|
|
|
|
this.lightMode = !this.lightMode;
|
|
|
|
|
const theme = this.lightMode ? 'light' : 'dark';
|
|
|
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
|
|
|
localStorage.setItem('theme', theme);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async logout() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${BASEURL}v2/auth/logout`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
window.location.href = `${BASEURL}v2/login`;
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Logout error:', err);
|
|
|
|
|
window.location.href = `${BASEURL}v2/login`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<?= $this->renderSection('script') ?>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|