Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code

This commit is contained in:
bagus-arie05
2025-10-14 09:13:20 +07:00
17 changed files with 3584 additions and 4548 deletions

No files matched your search

+474
View File
@@ -0,0 +1,474 @@
/* assets/styles/loginpage/login.css */
/* Main Background */
.login-background {
background: linear-gradient(135deg, #FFC857 0%, #FF9B1B 25%, #FF8F00 50%, #FF6F00 75%, #E65100 100%);
min-height: 100vh;
position: relative;
overflow: hidden;
padding-top: 64px;
}
.login-background::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 10% 20%, rgba(255, 200, 87, 0.4) 0%, transparent 50%),
radial-gradient(circle at 90% 80%, rgba(255, 155, 27, 0.4) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, rgba(230, 81, 0, 0.3) 0%, transparent 70%);
pointer-events: none;
}
/* Navigation Bar */
.navbar {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(50px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
position: fixed !important;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.brand-logo {
color: white;
font-size: 1.2rem;
}
.nav-link {
color: white !important;
text-transform: none;
font-weight: 500;
}
/* Floating Medical Icons */
.floating-medical-icon {
position: absolute;
animation: dvdBounce 20s linear infinite;
opacity: 0.8;
transition: opacity 0.3s ease;
z-index: 1;
width: fit-content;
height: fit-content;
}
.floating-medical-icon:hover {
opacity: 1;
}
.floating-medical-icon .v-icon {
color: rgba(255, 255, 255, 0.15) !important;
}
.icon-1 {
top: 0%;
left: 0%;
animation-delay: 0s;
animation-duration: 25s;
animation-name: dvdBounce1;
}
.icon-1 .v-icon {
color: rgba(6, 37, 83, 0.15) !important;
}
.icon-2 {
top: 0%;
right: 0%;
animation-delay: 0s;
animation-duration: 30s;
animation-name: dvdBounce2;
}
.icon-2 .v-icon {
color: rgba(15, 180, 70, 0.12) !important;
}
.icon-3 {
bottom: 0%;
left: 0%;
animation-delay: 0s;
animation-duration: 22s;
animation-name: dvdBounce3;
}
.icon-3 .v-icon {
color: rgba(23, 178, 206, 0.18) !important;
}
.icon-4 {
top: 50%;
left: 0%;
animation-delay: 0s;
animation-duration: 28s;
animation-name: dvdBounce4;
}
.icon-4 .v-icon {
color: rgba(223, 8, 8, 0.14) !important;
}
.icon-5 {
bottom: 0%;
right: 0%;
animation-delay: 0s;
animation-duration: 26s;
animation-name: dvdBounce5;
}
.icon-5 .v-icon {
color: rgba(148, 15, 236, 0.16) !important;
}
.icon-6 {
top: 25%;
left: 0%;
animation-delay: 0s;
animation-duration: 24s;
animation-name: dvdBounce6;
}
.icon-6 .v-icon {
color: rgba(87, 48, 5, 0.13) !important;
}
/* DVD Bounce Animation 1 - Top Left to Bottom Right */
@keyframes dvdBounce1 {
0% { transform: translate(0, 0); }
25% { transform: translate(80vw, 70vh); }
50% { transform: translate(20vw, 10vh); }
75% { transform: translate(70vw, 80vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 2 - Top Right to Bottom Left */
@keyframes dvdBounce2 {
0% { transform: translate(0, 0); }
25% { transform: translate(-75vw, 60vh); }
50% { transform: translate(-30vw, 20vh); }
75% { transform: translate(-85vw, 75vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 3 - Bottom Left to Top Right */
@keyframes dvdBounce3 {
0% { transform: translate(0, 0); }
25% { transform: translate(70vw, -60vh); }
50% { transform: translate(40vw, -80vh); }
75% { transform: translate(90vw, -30vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 4 - Middle Left across screen */
@keyframes dvdBounce4 {
0% { transform: translate(0, 0); }
16.6% { transform: translate(60vw, -30vh); }
33.3% { transform: translate(90vw, 20vh); }
50% { transform: translate(50vw, 40vh); }
66.6% { transform: translate(10vw, -20vh); }
83.3% { transform: translate(80vw, -40vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 5 - Bottom Right to Top Left */
@keyframes dvdBounce5 {
0% { transform: translate(0, 0); }
25% { transform: translate(-60vw, -70vh); }
50% { transform: translate(-90vw, -20vh); }
75% { transform: translate(-40vw, -80vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 6 - Complex zigzag pattern */
@keyframes dvdBounce6 {
0% { transform: translate(0, 0); }
14.3% { transform: translate(50vw, 30vh); }
28.6% { transform: translate(85vw, -20vh); }
42.9% { transform: translate(30vw, 60vh); }
57.1% { transform: translate(70vw, 10vh); }
71.4% { transform: translate(15vw, 70vh); }
85.7% { transform: translate(80vw, 40vh); }
100% { transform: translate(0, 0); }
}
/* Main Card - Contains Both Sections */
.main-card {
z-index: 3;
max-width: 1100px;
margin: 2rem;
overflow: hidden;
}
.white-card {
background: white !important;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 20px 50px rgba(0, 0, 0, 0.15),
0 8px 25px rgba(0, 0, 0, 0.1);
}
/* Left Section Styling */
.text-section-box {
background: linear-gradient(135deg, #61cbdd 0%, #4194c4 50%, #2089b9 100%);
min-height: 600px;
position: relative;
overflow: hidden;
}
.text-section-box::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
pointer-events: none;
}
.hero-content {
max-width: 100%;
position: relative;
z-index: 1;
}
.hero-title-box {
color: white;
font-size: 2.5rem;
font-weight: 900;
line-height: 1.1;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.2);
}
.hero-description-box {
color: rgba(255, 255, 255, 0.95);
font-size: 1rem;
line-height: 1.6;
margin-top: 2rem;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.2);
}
.hospital-logo-large {
height: 80px;
width: auto;
filter: drop-shadow(2px 2px 6px rgba(0, 0, 0, 0.3));
}
/* Right Section Styling */
.login-section-box {
background: white;
min-height: 600px;
}
.login-content {
max-width: 400px;
margin: 0 auto;
}
/* White Dialog Styles */
.white-dialog {
background: white !important;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
}
/* Card Header - Dark Text */
.welcome-title-dark {
color: #37474F;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.login-instruction-dark {
color: #546E7A;
font-size: 0.8rem;
letter-spacing: 0.3px;
}
/* App Title - Dark */
.app-title-dark {
color: #0053AD;
text-shadow: none;
}
.hospital-logo {
height: 64px;
width: auto;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
}
.sso-text-dark {
color: #546E7A;
font-size: 0.95rem;
font-weight: 500;
}
/* Buttons */
.login-btn {
background: linear-gradient(135deg, #0053AD 0%, #0663C7 50%, #0671E0 100%) !important;
color: white !important;
border: none;
box-shadow:
0 8px 25px rgba(0, 83, 173, 0.3),
0 4px 12px rgba(0, 83, 173, 0.2);
transition: all 0.3s ease;
text-transform: none;
font-size: 1rem;
height: 56px;
}
.login-btn:hover {
transform: translateY(-2px);
background: linear-gradient(135deg, #004A9B 0%, #0558B0 50%, #0661CA 100%) !important;
box-shadow:
0 12px 30px rgba(0, 83, 173, 0.4),
0 6px 15px rgba(0, 83, 173, 0.3);
}
.register-btn-dark {
color: #0053AD !important;
border: 2px solid #0053AD !important;
background: transparent !important;
transition: all 0.3s ease;
text-transform: none;
height: 48px;
}
.register-btn-dark:hover {
background: rgba(0, 83, 173, 0.05) !important;
border-color: #0663C7 !important;
transform: translateY(-1px);
}
/* Custom Divider - Dark */
.custom-divider-dark {
border-color: rgba(0, 0, 0, 0.12) !important;
opacity: 1 !important;
}
/* Help Text and Links - Dark */
.help-text-dark {
color: #546E7A;
font-size: 0.9rem;
}
.contact-link-dark {
color: #0053AD !important;
text-decoration: underline;
text-transform: none;
font-size: 0.9rem;
}
.help-link-dark {
color: #78909C;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.help-link-dark:hover {
color: #0053AD;
}
/* Transparent Background */
.transparent {
background: transparent !important;
}
/* Navigation Dropdown Styles */
.nav-dropdown {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
min-width: 220px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dropdown-item {
color: #FF9B1B;
transition: all 0.3s ease;
border-radius: 8px;
margin: 4px 8px;
padding: 8px 12px;
}
.dropdown-item:hover {
background: linear-gradient(135deg, #FF9B1B 0%, #FF8F00 100%);
color: white;
transform: translateX(4px);
}
.dropdown-item .v-icon {
transition: all 0.3s ease;
}
.dropdown-item:hover .v-icon {
transform: scale(1.1);
}
.nav-link {
text-transform: none;
font-weight: 500;
transition: all 0.3s ease;
}
.nav-link:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Responsive Design */
@media (max-width: 960px) {
.login-background {
padding-top: 56px;
}
.main-card {
margin: 1rem;
}
.text-section-box {
min-height: auto;
text-align: center;
}
.login-section-box {
min-height: auto;
}
.hero-title-box {
font-size: 2rem;
}
.hero-description-box {
font-size: 0.95rem;
}
}
@media (max-width: 600px) {
.login-background {
padding-top: 56px;
}
.main-card {
margin: 0.5rem;
}
.hero-title-box {
font-size: 1.75rem;
}
.text-section-box,
.login-section-box {
padding: 1.5rem !important;
}
}
@@ -1,3 +1,4 @@
<!-- components/AppBar.vue -->
<template>
<v-app-bar app color="#ff9248" dark>
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
+60 -39
View File
@@ -2,31 +2,62 @@
<v-menu
v-model="menu"
:close-on-content-click="false"
location="bottom right"
origin="top right"
location="top end"
origin="bottom right"
transition="slide-y-transition"
open-on-hover
>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props">
<template v-slot:activator="{ props: menuProps }">
<v-list-item
v-if="!rail"
v-bind="menuProps"
:title="user?.name || user?.preferred_username || 'User'"
:subtitle="user?.email || 'No email'"
class="pa-2 ma-1 rounded-lg bg-blue-grey-lighten-5 text-blue-grey-darken-4"
color="blue-grey-darken-4"
link
>
<template v-slot:prepend>
<v-avatar size="40">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
</template>
<template v-slot:append>
<v-icon size="20">mdi-chevron-up</v-icon>
</template>
</v-list-item>
<v-btn
v-else
icon
v-bind="menuProps"
size="large"
variant="text"
color="blue-grey-darken-4"
:title="user?.name || 'Profile'"
>
<v-avatar size="40">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
</v-btn>
</template>
<v-card class="rounded-lg elevation-4 pa-4" width="300">
<v-card class="rounded-lg elevation-4 pa-4" width="300" color="blue-grey-lighten-5">
<div class="d-flex align-center pb-2">
<v-avatar size="48">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="ml-4">
<div class="text-subtitle-1 font-weight-bold">
<div class="text-subtitle-1 font-weight-bold text-blue-grey-darken-4">
{{ user?.name || user?.preferred_username || 'User' }}
</div>
<div class="text-caption text-grey-darken-1">
@@ -40,16 +71,16 @@
<v-divider class="my-2"></v-divider>
<v-list dense>
<v-list-item link class="rounded-lg" @click="handleAction('account')">
<v-list-item link class="rounded-lg text-blue-grey-darken-4" @click="handleAction('account')">
<template v-slot:prepend>
<v-icon>mdi-cog</v-icon>
<v-icon color="blue-grey-darken-4">mdi-cog</v-icon>
</template>
<v-list-item-title>Pengaturan Akun</v-list-item-title>
</v-list-item>
<v-list-item link class="rounded-lg" @click="handleAction('darkMode')">
<v-list-item link class="rounded-lg text-blue-grey-darken-4" @click="handleAction('darkMode')">
<template v-slot:prepend>
<v-icon>mdi-weather-night</v-icon>
<v-icon color="blue-grey-darken-4">mdi-weather-night</v-icon>
</template>
<v-list-item-title>Mode Gelap</v-list-item-title>
<template v-slot:append>
@@ -57,23 +88,23 @@
v-model="darkMode"
hide-details
density="compact"
color="primary"
color="orange-darken-2"
></v-switch>
</template>
</v-list-item>
<v-list-item link class="rounded-lg" @click="handleAction('profile')">
<v-list-item link class="rounded-lg text-blue-grey-darken-4" @click="handleAction('profile')">
<template v-slot:prepend>
<v-icon>mdi-account</v-icon>
<v-icon color="blue-grey-darken-4">mdi-account</v-icon>
</template>
<v-list-item-title>Profil Saya</v-list-item-title>
</v-list-item>
<v-divider class="my-2"></v-divider>
<v-list-item
link
class="rounded-lg text-red"
<v-list-item
link
class="rounded-lg text-red"
@click="signOut"
:disabled="isLoggingOut"
>
@@ -91,12 +122,16 @@
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
// Props
const props = defineProps({
user: {
type: Object,
required: true
},
rail: {
type: Boolean,
required: true
}
})
@@ -105,43 +140,29 @@ const darkMode = ref(false);
const isLoggingOut = ref(false);
const emit = defineEmits(['logout']);
/**
* Handles the logout action - delegates to parent
*/
const signOut = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false;
try {
console.log('🚪 ProfilePopup signOut called...')
emit('logout');
// 🚨 Emits event up to SideBar to trigger useAuth().logout()
emit('logout');
} finally {
isLoggingOut.value = false;
}
};
const handleAction = (action) => {
console.log('Action triggered:', action);
switch(action) {
case 'account':
// Navigate to account settings
navigateTo('/settings/account')
break;
case 'profile':
// Navigate to profile page
navigateTo('/profile')
break;
case 'darkMode':
// Dark mode toggle is handled by v-model
break;
default:
console.log('Unknown action:', action);
}
// Close menu for navigation actions
if (action !== 'darkMode') {
menu.value = false;
}
+7 -18
View File
@@ -1,3 +1,4 @@
<!-- components/sideBar.vue -->
<template>
<v-navigation-drawer
v-model="drawer"
@@ -112,27 +113,15 @@
<v-divider class="my-4 mx-2"></v-divider>
</v-list>
<v-spacer></v-spacer>
<template v-slot:append>
<div class="pa-3">
<ProfileMenu
:user="user.value" :rail="rail"
@logout="handleLogout"
class="full-width-on-expand"
/>
</div>
</template>
</v-navigation-drawer>
</template>
<script setup>
import { ref, computed } from "vue";
import { useRoute } from "vue-router";
// Import store Pinia
import { useNavItemsStore } from "@/stores/navItems"; // Sesuaikan path ini jika perlu
import ProfileMenu from "./ProfileMenu.vue";
import { useNavItemsStore } from "@/stores/navItems";
import ProfilePopup from "./ProfilePopup.vue";
import { useAuth } from "~/composables/useAuth";
// State
const drawer = ref(true);
@@ -143,7 +132,7 @@ const hoverTimeout = ref(null);
const user = ref({
name: 'Adam Sulfat',
email: '[email protected]',
picture: 'https://i.pravatar.cc/150?img=33', // Ganti dengan URL gambar dinamis
picture: 'https://i.pravatar.cc/150?img=33',
id: 'a1b2c3d4e5f6g7h8'
});
const navItemsStore = useNavItemsStore();
@@ -151,10 +140,10 @@ const navItemsStore = useNavItemsStore();
const currentRoute = useRoute();
const currentActiveMenu = computed(() => {
// Menggunakan Getter untuk mendapatkan array menu yang pasti valid
const items = navItemsStore.getNavItems;
// Pengecekan keamanan (walaupun Getter seharusnya sudah menjamin array)
if (!Array.isArray(items) || items.length === 0) {
return "";
}
+35
View File
@@ -0,0 +1,35 @@
export const usePermission = () => {
/**
* Extract group and role from path
* Example: "/Instalasi STIM/Devops/Superadmin"
* → { group: "STIM", role: "superadmin" }
*/
const parsePath = (path: string) => {
const parts = path.split("/").filter(Boolean);
const group = parts[1] || ""; // "STIM"
const role = parts.at(-1)?.toLowerCase() || ""; // "superadmin"
return { group, role };
};
/**
* Fetch permission from backend API
*/
const fetchPermission = async (path: string) => {
const { group, role } = parsePath(path);
const url = `http://10.10.150.131:8080/api/permission?roles=${role}&groups=${group}`;
const { data, error } = await useFetch(url, {
method: "GET",
});
if (error.value) {
console.error("❌ Gagal mengambil permission:", error.value);
throw error.value;
}
console.log("✅ Permission data:", data.value);
return data.value;
};
return { parsePath, fetchPermission };
};
+286
View File
@@ -0,0 +1,286 @@
<template>
<v-app>
<!-- <LazyAppBar @toggle-rail="rail = !rail" /> -->
<v-navigation-drawer
v-model="drawer"
:rail="rail"
permanent
app
color="white"
width="280"
rail-width="72"
>
<div
class="pa-4 d-flex align-center cursor-pointer"
style="height: 64px;"
@click="rail = !rail"
>
<v-icon color="#ff9248" size="32">mdi-hospital-building</v-icon>
<v-toolbar-title v-show="!rail" class="ml-3 text-h6">
Antrean RSSA
</v-toolbar-title>
</div>
<v-divider></v-divider>
<v-list density="default" nav class="py-2">
<v-list-subheader v-show="!rail" class="text-caption text-grey">
OVERVIEW
</v-list-subheader>
<template v-for="item in items" :key="item.title">
<v-menu
v-if="item.children"
open-on-hover
location="end"
:disabled="!rail"
>
<template v-slot:activator="{ props }">
<v-list-group v-if="!rail" :value="item.title">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.title"
:active="item.title === currentActiveMenu"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
</template>
<v-list-item
v-for="child in item.children"
:key="child.title"
:to="child.to"
:title="child.title"
:active="child.to === currentRoute.path"
rounded="lg"
class="mx-2 my-1 pl-12"
>
</v-list-item>
</v-list-group>
<v-list-item
v-else
v-bind="props"
:prepend-icon="item.icon"
:active="item.title === currentActiveMenu"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
</template>
<v-list class="py-2" style="min-width: 200px;">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ item.title }}
</v-list-item-title>
</v-list-item>
<v-divider class="my-2"></v-divider>
<v-list-item
v-for="child in item.children"
:key="child.title"
:to="child.to"
:title="child.title"
:active="child.to === currentRoute.path"
rounded="lg"
class="mx-2"
>
</v-list-item>
</v-list>
</v-menu>
<v-list-item
v-else
:prepend-icon="item.icon"
:title="!rail ? item.title : ''"
:to="item.to"
:active="item.to === currentRoute.path"
rounded="lg"
class="mx-2 my-1"
>
<template v-slot:append v-if="item.badge && !rail">
<v-chip size="x-small" color="primary">{{ item.badge }}</v-chip>
</template>
</v-list-item>
</template>
<v-divider class="my-4"></v-divider>
<v-list-subheader v-show="!rail" class="text-caption text-grey">
ACCOUNT
</v-list-subheader>
<v-list-item
prepend-icon="mdi-cog-outline"
:title="!rail ? 'Settings' : ''"
to="/settings"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
<v-list-item
prepend-icon="mdi-logout"
:title="!rail ? 'Log out' : ''"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
</v-list>
<template v-slot:append>
<div class="pa-4 d-flex justify-center align-center">
<v-icon color="primary" size="20">mdi-white-balance-sunny</v-icon>
<v-switch
v-show="!rail"
v-model="darkMode"
hide-details
density="compact"
color="primary"
class="mx-2"
></v-switch>
<v-icon v-show="!rail" size="20">mdi-moon-waning-crescent</v-icon>
</div>
<v-divider></v-divider>
<div class="pa-4">
<div class="d-flex align-center">
<v-avatar color="#ff9248" size="40">
<span class="text-white">AS</span>
</v-avatar>
<div v-show="!rail" class="ml-3 flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">Adam Sulfat</div>
<div class="text-caption text-grey">[email protected]</div>
</div>
<v-btn
v-show="!rail"
icon
size="small"
variant="text"
>
<v-icon size="20">mdi-dots-vertical</v-icon>
</v-btn>
</div>
</div>
</template>
</v-navigation-drawer>
<v-main>
<v-container fluid class="pa-6">
<slot></slot>
</v-container>
</v-main>
<v-footer app class="bg-grey-lighten-4">
<v-container fluid class="py-2">
<v-row no-gutters align="center">
<v-col cols="12" md="6">
<span class="text-caption text-grey-darken-2">
RSUD Dr. Saiful Anwar Malang | Jl. Jaksa Agung Suprapto No. 2 Malang | Telp: 0341-362101
</span>
</v-col>
<v-col cols="12" md="6" class="text-right">
<span class="text-caption text-grey-darken-2">
ITIKom Antrian RSSA Ver. 0.2
</span>
</v-col>
</v-row>
</v-container>
</v-footer>
</v-app>
</template>
<script setup>
import { ref, computed } from "vue";
import { useRoute } from 'vue-router';
// NOTE: Make sure to check if LazyAppBar is still needed,
// as its original purpose might have been to handle the rail toggle.
import AppBar from "../components/AppBar.vue";
// Use auth composable
// Assuming useAuth() is defined elsewhere
// const { user, isAuthenticated, isLoading, checkAuth, logout } = useAuth()
// console.log(user)
// State
const drawer = ref(true);
const rail = ref(false);
const darkMode = ref(false);
// Menu Items
const items = ref([
{ title: "Dashboard", icon: "mdi-view-dashboard-outline", to: "/dashboard" },
{
title: "Setting",
icon: "mdi-cog-outline",
children: [
{ title: "Hak Akses", to: "/setting/hak-akses" },
{ title: "User Login", to: "/setting/user-login" },
{ title: "Master Loket", to: "/setting/master-loket" },
{ title: "Master Klinik", to: "/setting/master-klinik" },
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
{ title: "Screen", to: "/setting/screen" },
],
},
{ title: "Loket Admin", icon: "mdi-account-supervisor-outline", to: "/loket-admin" },
{ title: "Ranap Admin", icon: "mdi-bed-outline", to: "/ranap-admin" },
{
title: "Anjungan",
icon: "mdi-account-box-multiple-outline",
children: [
{ title: "Anjungan", to: "/anjungan/anjungan" },
{ title: "Klinik Ruang", to: "/anjungan/AntrianKlinik"},
],
},
{ title: "Data Pasien", icon: "mdi-account-multiple-outline", to: "/data-pasien" },
]);
const currentRoute = useRoute();
const currentActiveMenu = computed(() => {
const currentItem = items.value.find(item => item.to === currentRoute.path);
if (currentItem) {
return currentItem.title;
}
for (const item of items.value) {
if (item.children) {
const childItem = item.children.find(child => child.to === currentRoute.path);
if (childItem) {
return item.title;
}
}
}
return '';
});
</script>
<style scoped>
/* Added cursor style for the new clickable header */
.cursor-pointer {
cursor: pointer;
}
.v-list-item--active {
background-color: #e8f5e9 !important;
color: #2e7d32 !important;
}
.v-list-item--active :deep(.v-list-item__prepend) {
color: #2e7d32 !important;
}
.v-list-item {
transition: all 0.2s ease;
}
.v-list-item:hover {
background-color: #f5f5f5;
}
.v-navigation-drawer {
border-right: 1px solid #e0e0e0 !important;
}
</style>
+25 -27
View File
@@ -1,37 +1,35 @@
<template>
<v-app>
<Sidebar />
<v-app id="inspire">
<SideBar
:items="navItemsStore.navItems"
v-model:drawer="drawer"
:rail="rail"
@toggle-rail="rail = !rail"
/>
<v-main>
<v-container fluid class="bg-grey-lighten-4 pa-6">
<slot></slot>
</v-container>
<v-main app>
<slot />
</v-main>
<v-footer app class="bg-grey-lighten-4">
<v-container fluid class="py-2">
<v-row no-gutters align="center">
<v-col cols="12" md="6">
<span class="text-caption text-grey-darken-2">
RSUD Dr. Saiful Anwar Malang | Jl. Jaksa Agung Suprapto No. 2
Malang | Telp: 0341-362101
</span>
</v-col>
<v-col cols="12" md="6" class="text-right">
<span class="text-caption text-grey-darken-2">
ITIKom Antrian RSSA Ver. 0.2
</span>
</v-col>
</v-row>
</v-container>
</v-footer>
</v-app>
</template>
<script setup>
import Sidebar from "@/components/Sidebar.vue";
<script setup lang="ts">
import { ref } from "vue";
import SideBar from "../components/SideBar.vue";
// Ensure this path matches your store location
import { useNavItemsStore } from '@/stores/navItems';
definePageMeta({
middleware: 'auth'
})
// State for controlling the sidebar
const drawer = ref(true);
const rail = ref(true);
const navItemsStore = useNavItemsStore();
</script>
<style scoped>
/* Global styles for layout */
</style>
-37
View File
@@ -1,37 +0,0 @@
<!-- layouts/default.vue -->
<template>
<v-app id="inspire">
<AppBar @toggle-rail="rail = !rail" />
<SideBar :items="navItemsStore.navItems" v-model:drawer="drawer" :rail="rail" />
<v-main app>
<slot />
</v-main>
</v-app>
</template>
<script setup lang="ts">
import { ref, watchEffect } from "vue";
import AppBar from "../components/AppBar.vue";
import SideBar from "../components/SideBar.vue";
import { useNavItemsStore } from '@/stores/navItems'; // Import the new store
definePageMeta({
middleware: 'auth'
})
const drawer = ref(true);
const rail = ref(true);
const navItemsStore = useNavItemsStore();
// Your logic to check user access and filter the menu can go here
// For example:
// const filteredItems = computed(() => {
// return navItemsStore.navItems.filter(item => userHasAccess(item.path));
// });
</script>
<style scoped>
/* Global styles for layout */
</style>
-45
View File
@@ -1,48 +1,3 @@
// export default defineNuxtRouteMiddleware(async (to) => {
// console.log('🛡️ Auth middleware triggered for:', to.path)
// // Skip middleware on server-side during build/generation
// if (process.server && process.env.NODE_ENV === 'development') {
// console.log('⏭️ Skipping auth check on server-side during development')
// return
// }
// // Allow the login page to handle its own checks
// if (to.path === '/LoginPage') {
// console.log('⏭️ Allowing access to LoginPage')
// return
// }
// // This is the crucial change: check for the authentication signal
// const isAuthRedirect = to.query.authenticated === 'true';
// // If this is a redirect from a successful login, we need to let the route load
// if (isAuthRedirect) {
// console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
// // We navigate to a clean URL to remove the query parameter
// return navigateTo({ path: to.path, query: {} }, { replace: true });
// }
// try {
// console.log('🔍 Checking authentication status...')
// const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null)
// if (session && session.user) {
// console.log('✅ User is authenticated:', session.user.name || session.user.email)
// return
// } else {
// console.log('❌ No valid session found, redirecting to login')
// return navigateTo('/LoginPage')
// }
// } catch (error) {
// console.error('❌ Auth middleware error:', error)
// console.log('🔄 Redirecting to login due to error')
// return navigateTo('/LoginPage')
// }
// })
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
import type { RouteLocationNormalized } from 'vue-router';
+2 -1
View File
@@ -15,6 +15,7 @@ export default defineNuxtConfig({
'@nuxt/test-utils',
'@nuxt/ui',
'@pinia/nuxt',
'nuxt-qrcode',
// Remove '@sidebase/nuxt-auth' completely
(_options, nuxt) => {
nuxt.hooks.hook('vite:extendConfig', (config) => {
@@ -33,7 +34,7 @@ export default defineNuxtConfig({
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
public: {
authUrl: process.env.AUTH_ORIGIN || 'http://localhost:3001' || 'http://localhost:3000'
authUrl: process.env.AUTH_ORIGIN || 'http://localhost:3001'
}
},
+2212 -3874
View File
File diff suppressed because it is too large Load diff
+2
View File
@@ -24,7 +24,9 @@
"dayjs": "^1.11.18",
"eslint": "^9.32.0",
"nuxt": "^3.17.7",
"nuxt-qrcode": "^0.4.8",
"pinia": "^3.0.3",
"qrcode.vue": "^3.6.0",
"typescript": "^5.8.3",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
+3 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="pa-6 bg-grey-lighten-4">
<v-container fluid class="pa-6 bg-white-lighten-4">
<div class="d-flex justify-space-between align-center mb-6">
<div>
<h1 class="text-h4 font-weight-bold">Dashboard</h1>
@@ -473,4 +473,6 @@ const pieOptions = ref({
.cursor-pointer {
cursor: pointer;
}
</style>
+109 -506
View File
@@ -7,7 +7,7 @@
<v-app-bar class="navbar" flat>
<v-toolbar-title class="brand-logo">
<v-icon class="mr-2" color="white">mdi-hospital-building</v-icon>
<span class="font-weight-bold text-blue-darken-3">ANTREAN</span> <span class="font-weight-bold" >RSSA</span>
<span class="font-weight-bold text-blue-darken-3">ANTREAN</span> <span class="font-weight-bold">RSSA</span>
</v-toolbar-title>
<v-spacer></v-spacer>
@@ -92,118 +92,123 @@
<v-icon size="114">mdi-bandage</v-icon>
</div>
<!-- Centered White Box with Both Sections -->
<v-row class="fill-height align-center justify-center">
<!-- Left Content -->
<v-col cols="12" md="6" class="text-section">
<div class="hero-content">
<div class="logo-section mb-6">
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
class="mt-3 hospital-logo"
/>
<h1 class="hero-title">Sistem Terbaik</h1>
<h1 class="hero-title">Untuk Pelayanan</h1>
<h1 class="hero-title">Kesehatan</h1>
</div>
<p class="hero-description">
Tingkatkan efisiensi layanan rumah sakit dengan sistem antrean RSSA yang canggih dan intuitif.
Dirancang dengan kesederhanaan, keamanan, dan kecepatan, memastikan perjalanan
Anda ke platform kami semudah mungkin. Mari kita buat inovasi menjadi sederhana!
</p>
</div>
</v-col>
<v-col cols="12" class="d-flex justify-center align-center">
<v-card class="main-card white-card rounded-xl pa-0" max-width="1100" width="100%">
<v-row class="ma-0">
<!-- Left Content Section -->
<v-col cols="12" md="6" class="text-section-box d-flex align-center pa-8">
<div class="hero-content">
<div class="logo-section mb-6">
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
class="hospital-logo-large mb-6"
/>
<h1 class="hero-title-box">Sistem Terbaik</h1>
<h1 class="hero-title-box">Untuk Pelayanan</h1>
<h1 class="hero-title-box">Kesehatan</h1>
</div>
<p class="hero-description-box">
Tingkatkan efisiensi layanan rumah sakit dengan sistem antrean RSSA yang canggih dan intuitif.
Dirancang dengan kesederhanaan, keamanan, dan kecepatan, memastikan perjalanan
Anda ke platform kami semudah mungkin. Mari kita buat inovasi menjadi sederhana!
</p>
</div>
</v-col>
<!-- Right Login Card -->
<v-col cols="12" md="6" class="d-flex justify-center">
<v-card class="login-card white-card rounded-xl pa-8" max-width="450" width="100%">
<!-- Header -->
<div class="text-center mb-6">
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
</div>
<!-- Right Login Section -->
<v-col cols="12" md="6" class="login-section-box d-flex align-center pa-8">
<div class="login-content w-100">
<!-- Header -->
<div class="text-center mb-6">
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
</div>
<!-- Logo Section -->
<div class="d-flex flex-column align-center text-center mb-6">
<span class="text-h5 font-weight-bold app-title-dark text-blue-darken-3">Antrean RSSA</span>
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
class="mt-3 hospital-logo"
/>
</div>
<!-- Logo Section -->
<div class="d-flex flex-column align-center text-center mb-6">
<span class="text-h5 font-weight-bold app-title-dark text-blue-darken-3">Antrean RSSA</span>
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
class="mt-3 hospital-logo"
/>
</div>
<!-- Alert Messages -->
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
{{ errorMessage }}
</v-alert>
<!-- Alert Messages -->
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
{{ errorMessage }}
</v-alert>
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
<!-- SSO Info -->
<div class="text-center mb-6">
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
</div>
<!-- SSO Info -->
<div class="text-center mb-6">
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
</div>
<!-- Keycloak Login Button -->
<v-btn
@click="handleLogin"
class="login-btn"
block
rounded="lg"
size="large"
:loading="isLoading"
:disabled="isLoading"
>
<v-icon left>mdi-shield-key</v-icon>
<span class="font-weight-bold">
{{ isLoading ? 'Connecting to Keycloak...' : 'Login dengan Keycloak' }}
</span>
<v-icon right>mdi-arrow-right</v-icon>
</v-btn>
<!-- Keycloak Login Button -->
<v-btn
@click="handleLogin"
class="login-btn"
block
rounded="lg"
size="large"
:loading="isLoading"
:disabled="isLoading"
>
<v-icon left>mdi-shield-key</v-icon>
<span class="font-weight-bold">
{{ isLoading ? 'Connecting to Keycloak...' : 'Login dengan Keycloak' }}
</span>
<v-icon right>mdi-arrow-right</v-icon>
</v-btn>
<!-- Registration Section -->
<v-divider class="my-6 custom-divider-dark"></v-divider>
<div class="text-center">
<v-btn
@click="showRegistrationDialog = true"
class="register-btn-dark"
variant="outlined"
block
rounded="lg"
size="large"
>
<v-icon left>mdi-account-plus</v-icon>
<span class="font-weight-bold">Daftar Akun Baru</span>
</v-btn>
<!-- Registration Section -->
<v-divider class="my-6 custom-divider-dark"></v-divider>
<div class="text-center">
<v-btn
@click="showRegistrationDialog = true"
class="register-btn-dark"
variant="outlined"
block
rounded="lg"
size="large"
>
<v-icon left>mdi-account-plus</v-icon>
<span class="font-weight-bold">Daftar Akun Baru</span>
</v-btn>
<div class="text-center mt-4">
<span class="help-text-dark">
Belum memiliki akun?
</span>
<br>
<v-btn
@click="showAdminContact = true"
variant="text"
color="#0053AD"
size="small"
class="contact-link-dark mt-1"
>
Hubungi Administrator
</v-btn>
</div>
</div>
<!-- Password Help -->
<div class="text-center mt-4">
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
</div>
<div class="text-center mt-4">
<span class="help-text-dark">
Belum memiliki akun?
</span>
<br>
<v-btn
@click="showAdminContact = true"
variant="text"
color="#0053AD"
size="small"
class="contact-link-dark mt-1"
>
Hubungi Administrator
</v-btn>
</div>
</div>
<!-- Password Help -->
<div class="text-center mt-4">
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
</div>
</div>
</v-col>
</v-row>
</v-card>
</v-col>
</v-row>
@@ -463,406 +468,4 @@ Jam Operasional: Senin - Jumat, 08:00 - 16:00
}
</script>
<style scoped>
/* Main Background */
.login-background {
background: linear-gradient(135deg, #f1b464 0%, #faa22e 25%, #e49458 50%, #e46f30 75%, #e26450 100%);
min-height: 100vh;
position: relative;
overflow: hidden;
}
/* Navigation Bar */
.navbar {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.brand-logo {
color: white;
font-size: 1.2rem;
}
.nav-link {
color: white !important;
text-transform: none;
font-weight: 500;
}
/* Floating Medical Icons */
.floating-medical-icon {
position: absolute;
animation: dvdBounce 20s linear infinite;
opacity: 0.8;
transition: opacity 0.3s ease;
z-index: 1;
width: fit-content;
height: fit-content;
}
.floating-medical-icon:hover {
opacity: 1;
}
.floating-medical-icon .v-icon {
color: rgba(255, 255, 255, 0.15) !important;
}
.icon-1 {
top: 0%;
left: 0%;
animation-delay: 0s;
animation-duration: 25s;
animation-name: dvdBounce1;
}
.icon-1 .v-icon {
color: rgba(6, 37, 83, 0.15) !important;
}
.icon-2 {
top: 0%;
right: 0%;
animation-delay: 0s;
animation-duration: 30s;
animation-name: dvdBounce2;
}
.icon-2 .v-icon {
color: rgba(15, 180, 70, 0.12) !important;
}
.icon-3 {
bottom: 0%;
left: 0%;
animation-delay: 0s;
animation-duration: 22s;
animation-name: dvdBounce3;
}
.icon-3 .v-icon {
color: rgba(23, 178, 206, 0.18) !important;
}
.icon-4 {
top: 50%;
left: 0%;
animation-delay: 0s;
animation-duration: 28s;
animation-name: dvdBounce4;
}
.icon-4 .v-icon {
color: rgba(223, 8, 8, 0.14) !important;
}
.icon-5 {
bottom: 0%;
right: 0%;
animation-delay: 0s;
animation-duration: 26s;
animation-name: dvdBounce5;
}
.icon-5 .v-icon {
color: rgba(148, 15, 236, 0.16) !important;
}
.icon-6 {
top: 25%;
left: 0%;
animation-delay: 0s;
animation-duration: 24s;
animation-name: dvdBounce6;
}
.icon-6 .v-icon {
color: rgba(87, 48, 5, 0.13) !important;
}
/* DVD Bounce Animation 1 - Top Left to Bottom Right */
@keyframes dvdBounce1 {
0% { transform: translate(0, 0); }
25% { transform: translate(80vw, 70vh); }
50% { transform: translate(20vw, 10vh); }
75% { transform: translate(70vw, 80vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 2 - Top Right to Bottom Left */
@keyframes dvdBounce2 {
0% { transform: translate(0, 0); }
25% { transform: translate(-75vw, 60vh); }
50% { transform: translate(-30vw, 20vh); }
75% { transform: translate(-85vw, 75vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 3 - Bottom Left to Top Right */
@keyframes dvdBounce3 {
0% { transform: translate(0, 0); }
25% { transform: translate(70vw, -60vh); }
50% { transform: translate(40vw, -80vh); }
75% { transform: translate(90vw, -30vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 4 - Middle Left across screen */
@keyframes dvdBounce4 {
0% { transform: translate(0, 0); }
16.6% { transform: translate(60vw, -30vh); }
33.3% { transform: translate(90vw, 20vh); }
50% { transform: translate(50vw, 40vh); }
66.6% { transform: translate(10vw, -20vh); }
83.3% { transform: translate(80vw, -40vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 5 - Bottom Right to Top Left */
@keyframes dvdBounce5 {
0% { transform: translate(0, 0); }
25% { transform: translate(-60vw, -70vh); }
50% { transform: translate(-90vw, -20vh); }
75% { transform: translate(-40vw, -80vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 6 - Complex zigzag pattern */
@keyframes dvdBounce6 {
0% { transform: translate(0, 0); }
14.3% { transform: translate(50vw, 30vh); }
28.6% { transform: translate(85vw, -20vh); }
42.9% { transform: translate(30vw, 60vh); }
57.1% { transform: translate(70vw, 10vh); }
71.4% { transform: translate(15vw, 70vh); }
85.7% { transform: translate(80vw, 40vh); }
100% { transform: translate(0, 0); }
}
/* Hero Section */
.text-section {
padding-left: 4rem;
z-index: 2;
}
.hero-content {
max-width: 600px;
}
.hero-title {
color: white;
font-size: 3rem;
font-weight: 900;
line-height: 1.1;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.hero-description {
color: rgba(255, 255, 255, 0.9);
font-size: 1.1rem;
line-height: 1.6;
margin-top: 2rem;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
}
/* Login Card - White Background */
.login-card {
z-index: 3;
max-width: 450px;
margin: 2rem;
}
.white-card {
background: white !important;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 20px 50px rgba(0, 0, 0, 0.15),
0 8px 25px rgba(0, 0, 0, 0.1);
}
/* White Dialog Styles */
.white-dialog {
background: white !important;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
}
/* Card Header - Dark Text */
.welcome-title-dark {
color: #37474F;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.login-instruction-dark {
color: #546E7A;
font-size: 0.8rem;
letter-spacing: 0.3px;
}
/* App Title - Dark */
.app-title-dark {
color: #0053AD;
text-shadow: none;
}
.hospital-logo {
height: 64px;
width: auto;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
}
.sso-text-dark {
color: #546E7A;
font-size: 0.95rem;
font-weight: 500;
}
/* Buttons */
.login-btn {
background: linear-gradient(135deg, #0053AD 0%, #0663C7 50%, #0671E0 100%) !important;
color: white !important;
border: none;
box-shadow:
0 8px 25px rgba(0, 83, 173, 0.3),
0 4px 12px rgba(0, 83, 173, 0.2);
transition: all 0.3s ease;
text-transform: none;
font-size: 1rem;
height: 56px;
}
.login-btn:hover {
transform: translateY(-2px);
background: linear-gradient(135deg, #004A9B 0%, #0558B0 50%, #0661CA 100%) !important;
box-shadow:
0 12px 30px rgba(0, 83, 173, 0.4),
0 6px 15px rgba(0, 83, 173, 0.3);
}
.register-btn-dark {
color: #0053AD !important;
border: 2px solid #0053AD !important;
background: transparent !important;
transition: all 0.3s ease;
text-transform: none;
height: 48px;
}
.register-btn-dark:hover {
background: rgba(0, 83, 173, 0.05) !important;
border-color: #0663C7 !important;
transform: translateY(-1px);
}
/* Custom Divider - Dark */
.custom-divider-dark {
border-color: rgba(0, 0, 0, 0.12) !important;
opacity: 1 !important;
}
/* Help Text and Links - Dark */
.help-text-dark {
color: #546E7A;
font-size: 0.9rem;
}
.contact-link-dark {
color: #0053AD !important;
text-decoration: underline;
text-transform: none;
font-size: 0.9rem;
}
.help-link-dark {
color: #78909C;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.help-link-dark:hover {
color: #0053AD;
}
/* Transparent Background */
.transparent {
background: transparent !important;
}
/* Navigation Dropdown Styles */
.nav-dropdown {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
min-width: 220px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dropdown-item {
color: #FF9B1B;
transition: all 0.3s ease;
border-radius: 8px;
margin: 4px 8px;
padding: 8px 12px;
}
.dropdown-item:hover {
background: linear-gradient(135deg, #FF9B1B 0%, #FF8F00 100%);
color: white;
transform: translateX(4px);
}
.dropdown-item .v-icon {
transition: all 0.3s ease;
}
.dropdown-item:hover .v-icon {
transform: scale(1.1);
}
.nav-link {
text-transform: none;
font-weight: 500;
transition: all 0.3s ease;
}
.nav-link:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Responsive Design */
@media (max-width: 960px) {
.text-section {
padding-left: 2rem;
text-align: center;
margin-bottom: 2rem;
}
.hero-title {
font-size: 2.5rem;
}
.hero-description {
font-size: 1rem;
}
.login-card {
margin: 1rem;
}
}
@media (max-width: 600px) {
.hero-title {
font-size: 2rem;
}
.text-section {
padding-left: 1rem;
}
}
</style>
<style scoped src="~/assets/styles/loginpage/login.css"></style>
+347
View File
@@ -0,0 +1,347 @@
<template>
<v-app>
<v-card
class="elevation-8 rounded-xl header-banner mx-6 mt-6 mb-n6"
style="position: relative; z-index: 10;"
>
<v-container fluid class="py-4 px-6 d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon size="36" color="white" class="mr-3">mdi-shield-account-outline</v-icon>
<h1 class="text-h5 font-weight-bold text-white">Verifikasi Akun Pasien</h1>
</div>
<v-chip color="light-blue-lighten-4" class="text-blue-darken-4 font-weight-bold" variant="flat" rounded="lg">
<v-icon start>mdi-account-circle</v-icon>
Admin Aktif
</v-chip>
</v-container>
</v-card>
<v-main class="bg-white">
<v-container fluid class="pa-6 pt-12 pb-12">
<v-card class="pa-0 mb-6 elevation-4 rounded-lg">
<v-row class="ma-0 pa-4 align-center bg-white border-b-sm">
<v-col cols="12" sm="6" class="py-1">
<v-text-field
v-model="search"
density="comfortable"
label="Pencarian Pasien..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
hide-details
single-line
></v-text-field>
</v-col>
<v-col cols="12" sm="6" class="py-1">
<v-tabs
v-model="filterStatus"
color="orange-darken-1"
align-tabs="end"
class="pt-0"
>
<v-tab :value="0" class="font-weight-bold text-caption text-sm-button">
SEMUA PASIEN <v-chip size="small" color="blue-grey-darken-1" class="ml-2">{{ patients.length }}</v-chip>
</v-tab>
<v-tab :value="1" class="font-weight-bold text-caption text-sm-button">
BELUM VERIFIKASI <v-chip size="small" color="orange-darken-1" class="ml-2">{{ unverifiedPatients.length }}</v-chip>
</v-tab>
<v-tab :value="2" class="font-weight-bold text-caption text-sm-button">
TERVERIFIKASI <v-chip size="small" color="blue-darken-1" class="ml-2">{{ verifiedPatients.length }}</v-chip>
</v-tab>
</v-tabs>
</v-col>
</v-row>
<v-card-text class="pa-0">
<v-list lines="two" class="pa-0">
<v-list-item
v-for="(patient, index) in filteredPatients"
:key="patient.rm"
class="border-b"
:class="{ 'bg-blue-grey-lighten-5': patient.status === 'Terverifikasi', 'hover:bg-orange-lighten-5': patient.status !== 'Terverifikasi' }"
>
<v-list-item-title class="text-h6 font-weight-bold mb-1 d-flex align-center">
<v-icon :color="patient.status === 'Terverifikasi' ? 'blue' : 'orange'" class="mr-3" size="28">
{{ patient.status === 'Terverifikasi' ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ patient.nama }}
</v-list-item-title>
<v-list-item-subtitle class="mt-1 ml-9">
<v-row dense class="text-caption text-md-body-2">
<v-col cols="12" md="3" class="py-0 text-grey-darken-1">
RM: <span class="font-weight-medium text-blue-darken-2">{{ patient.rm }}</span>
</v-col>
<v-col cols="12" md="4" class="py-0 text-grey-darken-1">
Alamat: {{ patient.alamat }}
</v-col>
<v-col cols="12" md="3" class="py-0 text-grey-darken-1">
Telp: <span class="text-blue-darken-2">{{ patient.telepon || '-' }}</span>
</v-col>
</v-row>
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
v-if="patient.status === 'Belum Terverifikasi'"
color="orange-darken-1"
size="large"
@click="openDaftarModal(patient)"
prepend-icon="mdi-account-plus"
variant="flat"
class="font-weight-bold elevation-3"
>
DAFTARKAN
</v-btn>
<v-chip
v-else
color="blue"
label
size="large"
variant="tonal"
class="font-weight-bold"
>
VERIFIED
</v-chip>
</template>
</v-list-item>
<v-list-item v-if="filteredPatients.length === 0">
<v-list-item-title class="text-center py-6 text-h6 text-grey-darken-1">
{{ noDataText }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
<v-dialog v-model="isModalOpen" max-width="450" transition="slide-y-transition">
<v-card class="pa-6 border-t-lg border-t-blue-darken-3">
<v-card-title class="text-h5 font-weight-bold text-center text-blue-darken-2">
Aktivasi Akun Pasien
</v-card-title>
<v-card-subtitle class="text-center mb-4">
{{ selectedPatient.nama }} (RM: {{ selectedPatient.rm }})
</v-card-subtitle>
<v-card-text>
<v-text-field
v-model="tempTelepon"
:disabled="qrCodeGenerated"
label="No. Telepon Pasien"
placeholder="08xxxxxxxxxx"
variant="outlined"
density="comfortable"
:color="qrCodeGenerated ? 'grey' : 'orange-darken-1'"
:rules="[v => v.length >= 8 || 'Min. 8 digit']"
></v-text-field>
<v-btn
v-if="!qrCodeGenerated"
:disabled="!isTeleponValid"
color="orange-darken-1"
block
size="large"
class="mt-1 font-weight-bold elevation-3"
@click="generateQrCode"
>
<v-icon left>mdi-qrcode-scan</v-icon>
Lanjutkan & Generate QR
</v-btn>
<div v-else class="text-center mt-4 pa-4 bg-blue-grey-lighten-5 rounded-lg border-sm border-blue-darken-1">
<v-icon color="orange-darken-1" size="48">mdi-cellphone-check</v-icon>
<div class="text-h6 font-weight-bold mb-3 text-blue-darken-2">PINDAI UNTUK VERIFIKASI</div>
<div class="d-flex justify-center mb-4">
<qrcode-vue
:value="qrCodeData"
:size="170"
level="H"
:foreground="'#F57C00'"
:background="'#FFFFFF'"
class="border-4 rounded-lg border-orange-darken-1"
/>
</div>
</div>
</v-card-text>
<v-card-actions v-if="qrCodeGenerated" class="d-flex justify-space-between pt-0">
<v-btn
color="orange-darken-1"
variant="tonal"
@click="reloadQr"
prepend-icon="mdi-reload"
>
Reload
</v-btn>
<v-btn
color="green-darken-1"
variant="elevated"
@click="completeVerification"
prepend-icon="mdi-check-circle"
size="large"
class="font-weight-bold elevation-3"
>
Selesai
</v-btn>
</v-card-actions>
<v-card-actions v-else>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="closeModal">Batal</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</v-main>
</v-app>
</template>
<script setup>
import { ref, computed } from 'vue';
// PERUBAHAN DI SINI: Import QrcodeVue
import QrcodeVue from 'qrcode.vue';
// --- MOCK DATA SIMRS ---
const patients = ref([
{ nama: 'Budi Santoso', rm: '00123456', alamat: 'Jl. Melati No. 10', telepon: '', status: 'Belum Terverifikasi' },
{ nama: 'Siti Aminah', rm: '00123457', alamat: 'Perum. Indah Blok A', telepon: '081234567890', status: 'Terverifikasi' },
{ nama: 'Joko Susilo', rm: '00123458', alamat: 'Jl. Pahlawan 5', telepon: '', status: 'Belum Terverifikasi' },
{ nama: 'Dewi Lestari', rm: '00123459', alamat: 'Gang Mawar 12', telepon: '089876543210', status: 'Terverifikasi' },
{ nama: 'Rina Wijaya', rm: '00123460', alamat: 'Jl. Sudirman 21', telepon: '', status: 'Belum Terverifikasi' },
]);
// --- STATE DAN FILTERING ---
const filterStatus = ref(0);
const search = ref('');
const noDataText = computed(() => search.value ? 'Tidak ada data pasien yang cocok dengan pencarian.' : 'Tidak ada pasien dengan status ini.');
const unverifiedPatients = computed(() =>
patients.value.filter((p) => p.status === 'Belum Terverifikasi')
);
const verifiedPatients = computed(() =>
patients.value.filter((p) => p.status === 'Terverifikasi')
);
const filteredPatients = computed(() => {
let statusFiltered = patients.value;
if (filterStatus.value === 1) { // Belum Terverifikasi
statusFiltered = unverifiedPatients.value;
} else if (filterStatus.value === 2) { // Terverifikasi
statusFiltered = verifiedPatients.value;
}
if (!search.value) return statusFiltered;
const searchTerm = search.value.toLowerCase();
return statusFiltered.filter(p =>
p.nama.toLowerCase().includes(searchTerm) ||
p.rm.includes(searchTerm)
);
});
// --- STATE MODAL QR CODE ---
const isModalOpen = ref(false);
const selectedPatient = ref({});
const tempTelepon = ref('');
const qrCodeGenerated = ref(false);
const isTeleponValid = computed(() => tempTelepon.value.length >= 8);
// Data yang akan di-encode dalam QR Code
const qrCodeData = computed(() => {
return qrCodeGenerated.value
? JSON.stringify({
rm: selectedPatient.value.rm,
phone: tempTelepon.value,
action: 'verify-account',
timestamp: Date.now()
})
: '';
});
// --- LOGIC MODAL ---
const openDaftarModal = (patient) => {
selectedPatient.value = patient;
tempTelepon.value = patient.telepon || '';
qrCodeGenerated.value = false;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
selectedPatient.value = {};
tempTelepon.value = '';
qrCodeGenerated.value = false;
};
const generateQrCode = () => {
if (isTeleponValid.value) {
console.log(`[API CALL] Generating QR for ${selectedPatient.value.nama}. QR Data: ${qrCodeData.value}`);
setTimeout(() => {
qrCodeGenerated.value = true;
}, 200);
}
};
const reloadQr = () => {
console.log('[API CALL] Reloading QR Code...');
qrCodeGenerated.value = false;
setTimeout(() => {
qrCodeGenerated.value = true;
console.log('QR Code reloaded.');
}, 500);
};
const completeVerification = () => {
const patientIndex = patients.value.findIndex(
(p) => p.rm === selectedPatient.value.rm
);
if (patientIndex !== -1) {
patients.value[patientIndex].status = 'Terverifikasi';
patients.value[patientIndex].telepon = tempTelepon.value;
}
filterStatus.value = 2;
closeModal();
};
</script>
<style scoped>
/* Custom style untuk Vuetify */
.border-t-lg {
border-top-width: 8px !important;
}
.border-t-blue-darken-3 {
border-top-color: var(--v-theme-blue-darken-3) !important;
}
/* Style untuk Header Card (Floating & Rounded) */
.header-banner {
background: linear-gradient(90deg, #1565C0, #1976D2);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
}
/* Memastikan elemen list item memiliki efek hover yang halus */
.hover\:bg-orange-lighten-5:hover {
background-color: var(--v-theme-orange-lighten-5) !important;
}
/* Memastikan container di v-main memiliki padding atas yang cukup */
.v-main .v-container {
padding-top: 5rem !important;
}
/* Menghapus padding default dari v-card header agar v-container di dalamnya yang mengatur padding */
.header-banner .v-container {
padding-left: 1.5rem !important;
padding-right: 1.5rem !important;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
// /stores/permissionStore.ts
import { defineStore } from "pinia";
export const usePermissionStore = defineStore("permission", {
state: () => ({
data: null as any,
}),
actions: {
async load(path: string) {
const parts = path.split("/").filter(Boolean);
const group = parts[1] || "";
const role = parts.at(-1)?.toLowerCase() || "";
const url = `http://10.10.150.131:8080/api/permission?roles=${role}&groups=${group}`;
const { data } = await useFetch(url);
this.data = data.value;
},
can(action: string) {
return this.data?.[action] === true;
},
},
});