push update login, dashboard, check in, monitoring, profilpop, verifikasi, dll

This commit is contained in:
Fanrouver
2025-10-30 09:08:40 +07:00
parent 0351f9116d
commit 4960d4659f
25 changed files with 6827 additions and 2524 deletions

No files matched your search

+71 -310
View File
@@ -1,60 +1,43 @@
/* assets/styles/loginpage/login.css */
/* Main Background */
/* --- COLOR DEFINITIONS --- */
/* Base Orange: #FF9B1B (New) */
/* Hover/Darker Orange: #E68A00 (Calculated darker shade for hover effect) */
/* --- END COLOR DEFINITIONS --- */
/* Main Background - Now Orange */
.login-background {
background: linear-gradient(135deg, #FFC857 0%, #FF9B1B 25%, #FF8F00 50%, #FF6F00 75%, #E65100 100%);
background: #FF9B1B; /* Solid Orange Background: #FF9B1B */
min-height: 100vh;
position: relative;
overflow: hidden;
padding-top: 64px;
}
.login-background::before {
/* No radial gradients */
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;
background: 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 Icons - Subtle on Orange Background */
.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;
top: auto;
left: auto;
bottom: auto;
right: auto;
}
.floating-medical-icon:hover {
@@ -62,210 +45,36 @@
}
.floating-medical-icon .v-icon {
color: rgba(255, 255, 255, 0.15) !important;
color: rgba(255, 255, 255, 0.15) !important; /* Light white for icons on orange background */
}
.icon-1 {
top: 0%;
left: 0%;
animation-delay: 0s;
animation-duration: 25s;
animation-name: dvdBounce1;
}
/* Specific icon positioning - now static */
.icon-1 { top: 15%; left: 5%; }
.icon-2 { top: 20%; right: 10%; }
.icon-3 { bottom: 10%; left: 15%; }
.icon-4 { top: 60%; left: 10%; }
.icon-5 { bottom: 20%; right: 5%; }
.icon-6 { top: 35%; left: 20%; }
.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 - White */
.main-card {
z-index: 3;
max-width: 1100px;
/* max-width is now set in template to 450px */
margin: 2rem;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); /* Adding a subtle shadow back */
}
.white-card {
background: white !important;
background: white !important; /* Main card background is white */
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);
/* box-shadow is now managed by .main-card */
}
/* 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 */
/* Right Login Section (White) */
.login-section-box {
background: white;
min-height: 600px;
background: white; /* Changed to white */
min-height: 500px; /* Reduced min-height since there is no side-by-side comparison */
}
.login-content {
@@ -279,9 +88,9 @@
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
}
/* Card Header - Dark Text */
/* Card Header - Dark Text for white background */
.welcome-title-dark {
color: #37474F;
color: #333; /* Dark text on white background */
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 1px;
@@ -289,14 +98,14 @@
}
.login-instruction-dark {
color: #546E7A;
color: #777; /* Grey text for instruction on white background */
font-size: 0.8rem;
letter-spacing: 0.3px;
}
/* App Title - Dark */
/* App Title - Dark for white background */
.app-title-dark {
color: #0053AD;
color: #FF9B1B; /* Orange app title: #FF9B1B */
text-shadow: none;
}
@@ -307,19 +116,19 @@
}
.sso-text-dark {
color: #546E7A;
color: #555; /* Dark grey for SSO text */
font-size: 0.95rem;
font-weight: 500;
}
/* Buttons */
/* Buttons (Orange theme) */
.login-btn {
background: linear-gradient(135deg, #0053AD 0%, #0663C7 50%, #0671E0 100%) !important;
color: white !important;
background: #FF9B1B !important; /* Solid Orange button: #FF9B1B */
color: white !important; /* White text on orange button */
border: none;
box-shadow:
0 8px 25px rgba(0, 83, 173, 0.3),
0 4px 12px rgba(0, 83, 173, 0.2);
box-shadow:
0 8px 25px rgba(255, 155, 27, 0.3), /* Orange shadow: #FF9B1B */
0 4px 12px rgba(255, 155, 27, 0.2);
transition: all 0.3s ease;
text-transform: none;
font-size: 1rem;
@@ -328,15 +137,15 @@
.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);
background: #E68A00 !important; /* Slightly darker orange on hover */
box-shadow:
0 12px 30px rgba(255, 155, 27, 0.4),
0 6px 15px rgba(255, 155, 27, 0.3);
}
.register-btn-dark {
color: #0053AD !important;
border: 2px solid #0053AD !important;
color: #FF9B1B !important; /* Orange text for register button: #FF9B1B */
border: 2px solid #FF9B1B !important; /* Orange border: #FF9B1B */
background: transparent !important;
transition: all 0.3s ease;
text-transform: none;
@@ -344,39 +153,39 @@
}
.register-btn-dark:hover {
background: rgba(0, 83, 173, 0.05) !important;
border-color: #0663C7 !important;
background: rgba(255, 155, 27, 0.05) !important; /* Light orange hover background */
border-color: #E68A00 !important; /* Darker orange border on hover */
transform: translateY(-1px);
}
/* Custom Divider - Dark */
/* Custom Divider - Dark for white background */
.custom-divider-dark {
border-color: rgba(0, 0, 0, 0.12) !important;
border-color: rgba(0, 0, 0, 0.1) !important; /* Light grey divider */
opacity: 1 !important;
}
/* Help Text and Links - Dark */
/* Help Text and Links - Dark for white background */
.help-text-dark {
color: #546E7A;
color: #555; /* Dark grey help text */
font-size: 0.9rem;
}
.contact-link-dark {
color: #0053AD !important;
color: #FF9B1B !important; /* Orange link: #FF9B1B */
text-decoration: underline;
text-transform: none;
font-size: 0.9rem;
}
.help-link-dark {
color: #78909C;
color: #777; /* Grey link */
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.help-link-dark:hover {
color: #0053AD;
color: #FF9B1B; /* Orange on hover: #FF9B1B */
}
/* Transparent Background */
@@ -384,90 +193,42 @@
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);
/* Dialog Accents */
.v-card-title .v-icon,
.v-list-item .v-icon {
color: #FF9B1B !important; /* Orange accents: #FF9B1B */
}
.dropdown-item {
color: #FF9B1B;
transition: all 0.3s ease;
border-radius: 8px;
margin: 4px 8px;
padding: 8px 12px;
.v-btn[color="#FF9B1B"] {
background-color: #FF9B1B !important;
color: white !important;
}
.dropdown-item:hover {
background: linear-gradient(135deg, #FF9B1B 0%, #FF8F00 100%);
color: white;
transform: translateX(4px);
.v-alert[color="orange"] {
border-left: 8px solid #FF9B1B !important;
color: #333 !important;
}
.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 */
/* Responsive Design Adjustments for single column */
@media (max-width: 960px) {
.login-background {
padding-top: 56px;
}
.main-card {
margin: 1rem;
}
.text-section-box {
min-height: auto;
text-align: center;
max-width: 450px !important; /* Enforce max-width on smaller screens */
}
.login-section-box {
min-height: auto;
}
.hero-title-box {
font-size: 2rem;
}
.hero-description-box {
font-size: 0.95rem;
padding: 2rem !important;
}
}
@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;
}
+130
View File
@@ -0,0 +1,130 @@
<template>
<v-card
class="pa-4 rounded-lg elevation-2 d-flex flex-column"
color="white"
style="border-top: 8px solid #FFB95F;"
height="100%"
>
<div class="d-flex align-center justify-center mb-4">
<v-icon size="36" color="orange-lighten-2" class="mr-2">mdi-hospital-box-outline</v-icon>
<div class="text-h6 font-weight-bold text-orange-lighten-2">{{ title }}</div>
</div>
<v-divider class="mb-4"></v-divider>
<div class="timeline-scroll-container">
<v-timeline density="compact" align="start" line-inset="12" line-color="#FFB95F">
<v-timeline-item
v-for="(step, index) in steps"
:key="index"
:dot-color="getStepColor(index)"
:icon="getStepIcon(index)"
size="small"
icon-color="white"
>
<div class="d-flex flex-column align-start">
<span
class="font-weight-bold"
:class="index === currentStepIndex ? 'text-orange-lighten-1' : index < currentStepIndex ? 'text-success' : 'text-grey-darken-1'"
>
{{ step.label }}
</span>
<span class="text-caption text-grey-darken-1">
{{ step.date !== '-' ? `${step.date}, ${step.time}` : 'Menunggu' }}
</span>
</div>
</v-timeline-item>
</v-timeline>
</div>
<v-divider class="mt-4 mb-4"></v-divider>
<v-btn
size="large"
variant="tonal"
color="#FFA532"
class="mt-auto font-weight-bold"
prepend-icon="mdi-printer"
block
>
Cetak Ulang Tiket
</v-btn>
</v-card>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
title: String,
steps: Array,
color: String,
currentStepLabel: String,
});
const currentStepIndex = computed(() => {
return props.steps.findIndex(step => step.label === props.currentStepLabel);
});
const getStepIcon = (index) => {
if (index < currentStepIndex.value) {
return 'mdi-check';
} else if (index === currentStepIndex.value) {
return 'mdi-progress-check';
} else {
return 'mdi-circle-outline';
}
};
const getStepColor = (index) => {
if (index < currentStepIndex.value) {
return 'success';
} else if (index === currentStepIndex.value) {
return 'orange-lighten-1';
}
return 'grey-lighten-1';
};
</script>
<style scoped>
/* =============================================== */
/* SCROLLABLE TIMELINE STYLES (Menggunakan Tinggi Tetap) */
/* =============================================== */
.timeline-scroll-container {
/* Tinggi Tetap: Memastikan tinggi card konsisten di semua skenario responsive */
height: 320px;
/* Membuat konten scrollable di sumbu Y jika melebihi height */
overflow-y: auto;
/* Memberi jarak internal di bagian scrollable */
padding-right: 8px;
}
/* PENTING: Menghilangkan padding bawah dari V-Timeline bawaan */
.v-timeline {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
}
/* Kustomisasi scrollbar untuk tampilan yang lebih bersih (Opsional) */
.timeline-scroll-container::-webkit-scrollbar {
width: 6px;
}
.timeline-scroll-container::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
/* =============================================== */
/* V-TIMELINE KUSTOMISASI */
/* =============================================== */
.v-timeline-item :deep(.v-timeline-item__body) {
padding-inline-start: 16px !important;
}
.v-timeline-item :deep(.v-timeline-item__opposite) {
display: none;
}
</style>
-176
View File
@@ -1,176 +0,0 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
location="top end"
origin="bottom right"
transition="slide-y-transition"
open-on-hover
>
<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'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
</v-btn>
</template>
<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'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="ml-4">
<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">
{{ user?.email || 'No email' }}
</div>
<div class="text-caption text-grey-darken-2">
ID: {{ user?.id?.substring(0, 8) }}...
</div>
</div>
</div>
<v-divider class="my-2"></v-divider>
<v-list dense>
<v-list-item link class="rounded-lg text-blue-grey-darken-4" @click="handleAction('account')">
<template v-slot:prepend>
<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 text-blue-grey-darken-4" @click="handleAction('darkMode')">
<template v-slot:prepend>
<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>
<v-switch
v-model="darkMode"
hide-details
density="compact"
color="orange-darken-2"
></v-switch>
</template>
</v-list-item>
<v-list-item link class="rounded-lg text-blue-grey-darken-4" @click="handleAction('profile')">
<template v-slot:prepend>
<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"
@click="signOut"
:disabled="isLoggingOut"
>
<template v-slot:prepend>
<v-icon color="red">mdi-logout</v-icon>
</template>
<v-list-item-title>
{{ isLoggingOut ? 'Logging out...' : 'Keluar' }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</template>
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
const props = defineProps({
user: {
type: Object,
required: true
},
rail: {
type: Boolean,
required: true
}
})
const menu = ref(false);
const darkMode = ref(false);
const isLoggingOut = ref(false);
const emit = defineEmits(['logout']);
const signOut = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false;
try {
// 🚨 Emits event up to SideBar to trigger useAuth().logout()
emit('logout');
} finally {
isLoggingOut.value = false;
}
};
const handleAction = (action) => {
switch(action) {
case 'account':
navigateTo('/settings/account')
break;
case 'profile':
navigateTo('/profile')
break;
}
if (action !== 'darkMode') {
menu.value = false;
}
};
</script>
<style scoped>
.text-red {
color: rgb(244, 67, 54) !important;
}
</style>
+416
View File
@@ -0,0 +1,416 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
location="top end"
origin="bottom right"
transition="scale-transition"
open-on-hover
>
<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-3 ma-1 rounded-xl profile-activator"
link
>
<template v-slot:prepend>
<div class="avatar-wrapper">
<v-avatar size="44" class="avatar-glow">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-dot"></div>
</div>
</template>
<template v-slot:append>
<v-icon size="20" class="chevron-icon">mdi-menu-down</v-icon>
</template>
</v-list-item>
<v-btn
v-else
icon
v-bind="menuProps"
size="large"
variant="flat"
class="avatar-btn"
:title="user?.name || 'Profile'"
>
<div class="avatar-wrapper">
<v-avatar size="44" class="avatar-glow">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-dot"></div>
</div>
</v-btn>
</template>
<v-card class="profile-card rounded-xl elevation-12" width="340">
<!-- Decorative Header -->
<div class="card-header">
<div class="header-pattern"></div>
<div class="header-content pa-6 text-center">
<div class="avatar-container mb-4">
<v-avatar size="90" class="profile-avatar">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-badge">
<v-icon size="12" color="white">mdi-check</v-icon>
</div>
</div>
<h3 class="text-h6 font-weight-bold text-white mb-1">
{{ user?.name || user?.preferred_username || 'User' }}
</h3>
<p class="text-body-2 text-white opacity-90 mb-2">
{{ user?.email || 'No email' }}
</p>
<v-chip
size="small"
class="glass-chip"
prepend-icon="mdi-identifier"
>
{{ user?.id?.substring(0, 10) }}...
</v-chip>
</div>
</div>
<!-- Menu Items -->
<v-card-text class="pa-4">
<div class="menu-grid">
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('profile')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" color="blue-darken-2">mdi-account-circle</v-icon>
</div>
<div class="text-caption font-weight-medium">Profil</div>
</div>
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('account')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" color="orange-darken-2">mdi-cog-outline</v-icon>
</div>
<div class="text-caption font-weight-medium">Pengaturan</div>
</div>
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('darkMode')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" :color="darkMode ? 'deep-purple-darken-2' : 'amber-darken-2'">
{{ darkMode ? 'mdi-moon-waning-crescent' : 'mdi-white-balance-sunny' }}
</v-icon>
</div>
<div class="text-caption font-weight-medium">
{{ darkMode ? 'Gelap' : 'Terang' }}
</div>
</div>
</div>
<v-divider class="my-4"></v-divider>
<!-- Quick Actions -->
<div class="quick-actions">
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('notifications')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-bell-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Notifikasi
</v-list-item-title>
<template v-slot:append>
<v-badge color="orange-darken-2" content="3" inline></v-badge>
</template>
</v-list-item>
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('help')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-help-circle-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Bantuan & Dukungan
</v-list-item-title>
</v-list-item>
</div>
<v-divider class="my-4"></v-divider>
<!-- Logout Button -->
<v-btn
block
class="rounded-lg logout-btn"
variant="flat"
color="error"
prepend-icon="mdi-logout"
@click="signOut"
:disabled="isLoggingOut"
:loading="isLoggingOut"
>
{{ isLoggingOut ? 'Keluar...' : 'Keluar' }}
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</template>
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
const props = defineProps({
user: {
type: Object,
required: true
},
rail: {
type: Boolean,
required: true
}
})
const menu = ref(false);
const darkMode = ref(false);
const isLoggingOut = ref(false);
const emit = defineEmits(['logout']);
const signOut = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false;
try {
emit('logout');
} finally {
isLoggingOut.value = false;
}
};
const handleAction = (action) => {
switch(action) {
case 'account':
navigateTo('/Profile/Pengaturan')
break;
case 'profile':
navigateTo('/Profile/Profil')
break;
case 'notifications':
navigateTo('/notifications')
break;
case 'help':
navigateTo('/help')
break;
case 'darkMode':
darkMode.value = !darkMode.value;
return;
}
menu.value = false;
};
</script>
<style scoped>
.profile-activator {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
border: 1px solid rgba(25, 118, 210, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-activator:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.15) 0%, rgba(245, 124, 0, 0.15) 100%);
border-color: rgba(25, 118, 210, 0.3);
transform: translateY(-2px);
}
.avatar-wrapper {
position: relative;
}
.avatar-glow {
border: 3px solid white;
box-shadow: 0 0 20px rgba(25, 118, 210, 0.4);
}
.status-dot {
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
border: 2px solid white;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
.chevron-icon {
transition: transform 0.3s ease;
}
.profile-activator:hover .chevron-icon {
transform: translateY(2px);
}
.avatar-btn {
background: transparent !important;
}
.profile-card {
overflow: hidden;
border: 1px solid rgba(25, 118, 210, 0.1);
}
.card-header {
position: relative;
background: linear-gradient(135deg, #1976d2 0%, #fb8c00 100%);
overflow: hidden;
}
.header-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
}
.header-content {
position: relative;
z-index: 1;
}
.avatar-container {
position: relative;
display: inline-block;
}
.profile-avatar {
border: 4px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.status-badge {
position: absolute;
bottom: 0;
right: 0;
width: 28px;
height: 28px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
border: 3px solid white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.glass-chip {
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px);
color: white !important;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.menu-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.menu-tile {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
border: 1px solid rgba(25, 118, 210, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.menu-tile:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
border-color: rgba(25, 118, 210, 0.3);
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.tile-icon-wrapper {
width: 48px;
height: 48px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.action-item {
transition: all 0.2s ease;
background: transparent;
}
.action-item:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
transform: translateX(4px);
}
.logout-btn {
text-transform: none;
font-weight: 600;
letter-spacing: 0.5px;
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3);
}
.logout-btn:hover {
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4);
}
.cursor-pointer {
cursor: pointer;
}
.opacity-90 {
opacity: 0.9;
}
</style>
-75
View File
@@ -1,75 +0,0 @@
<!-- <template>
<v-dialog v-model="dialog" max-width="500px">
<v-card>
<v-card-title class="text-h6 font-weight-bold">
Atur Urutan Menu
</v-card-title>
<v-card-text>
<v-list dense>
<draggable v-model="localMenus" item-key="title" @end="onDragEnd">
<template #item="{ element }">
<v-list-item class="reorder-item">
<v-list-item-content>
<v-list-item-title>{{ element.title }}</v-list-item-title>
</v-list-item-content>
<v-list-item-icon>
<v-icon>mdi-drag</v-icon>
</v-list-item-icon>
</v-list-item>
</template>
</draggable>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey-darken-1" text @click="dialog = false">Batal</v-btn>
<v-btn color="blue" text @click="saveOrder">Simpan Urutan</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, watch } from 'vue';
import draggable from 'vuedraggable';
import navigationItems from '~/data/menu.js';
const dialog = ref(false);
const localMenus = ref([]);
// Watch for changes in the dialog's visibility
watch(dialog, (val) => {
if (val) {
// Make a deep copy to avoid mutating the original data
localMenus.value = JSON.parse(JSON.stringify(navigationItems));
}
});
const onDragEnd = (event) => {
// Logic to handle the end of a drag event
};
const saveOrder = () => {
// Emit an event with the new menu order
// You would then handle this in the parent component
// to update the ~/data/menu.js file (or your state management)
// and trigger a UI refresh.
dialog.value = false;
};
const openDialog = () => {
dialog.value = true;
};
defineExpose({ openDialog });
</script>
<style scoped>
.reorder-item {
cursor: grab;
border-bottom: 1px solid #eee;
}
.reorder-item:active {
cursor: grabbing;
}
</style> -->
@@ -23,7 +23,8 @@
</v-list-item>
<v-divider></v-divider>
<v-list density="compact" nav class="mt-2" v-model:opened="openedGroups"> <template v-for="item in items" :key="item.name">
<v-list density="compact" nav class="mt-2" v-model:opened="openedGroups">
<template v-for="item in items" :key="item.name">
<v-list-group
v-if="item.children"
@@ -99,12 +100,13 @@
</template>
<script setup lang="ts">
import { defineProps, defineEmits, onMounted, ref, watch } from 'vue'; // IMPORT WATCH
import { navigateTo } from '#app';
import ProfilePopup from './ProfilePopup.vue';
import { useAuth } from '~/composables/useAuth';
import { defineProps, defineEmits, onMounted, ref, watch } from 'vue';
import { navigateTo, useRoute } from '#app';
import ProfilePopup from './ProfilePopup1.vue';
import { useAuth } from '~/composables/useAuth'; // Ensure this path is correct
const { user, logout, checkAuth } = useAuth();
const route = useRoute(); // Access the current route
interface NavItem {
id: number;
@@ -131,22 +133,38 @@ const props = defineProps({
const emit = defineEmits(['update:drawer', 'toggle-rail']);
// --- NEW STATE FOR DROPDOWN GROUPS ---
// This holds the names of the currently open list groups.
const openedGroups = ref<string[]>([]);
const isHovering = ref(false);
// --- NEW WATCHER TO CLOSE DROPDOWNS ---
// --- WATCHERS FOR DROPDOWN AND HIGHLIGHTING ---
// 1. WATCHER: Close dropdowns when sidebar collapses (rail = true)
watch(() => props.rail, (newRailState) => {
if (newRailState === true) {
// If the sidebar is collapsing (rail is true), close all groups
openedGroups.value = [];
}
});
// ------------------------------------
// 2. WATCHER: Open the parent group when a child route is active
watch(() => route.path, (newPath) => {
// Find the parent item whose children contain the current path
const activeParent = props.items.find(item =>
item.children && item.children.some(child =>
child.path === newPath
)
);
if (activeParent) {
// If a parent is active, ensure it's in the openedGroups array
if (!openedGroups.value.includes(activeParent.name)) {
openedGroups.value.push(activeParent.name);
}
// Optional: Keep only the active parent open, closing others
// openedGroups.value = [activeParent.name];
}
}, { immediate: true });
// Local state for hover-to-expand rail feature
const isHovering = ref(false);
// --- HOVER LOGIC for Rail Expansion ---
@@ -164,11 +182,12 @@ const handleMouseLeave = () => {
}
};
// --- AUTH LOGIC (Kept the same) ---
// --- AUTH LOGIC ---
const handleLogout = async () => {
console.log('🚪 SideBar logout initiated...');
try {
// Calls the external logout function (from useAuth.ts)
await logout();
} catch (error) {
console.error('❌ SideBar logout error:', error);
@@ -180,6 +199,7 @@ const redirectToLogin = () => {
};
onMounted(async () => {
// Fetches real user data on mount
await checkAuth();
});
</script>
+1
View File
@@ -1,3 +1,4 @@
// /composables/usePermissions.ts
export const usePermission = () => {
/**
* Extract group and role from path
+2 -2
View File
@@ -15,9 +15,9 @@
<script setup lang="ts">
import { ref } from "vue";
import SideBar from "../components/SideBar.vue";
import SideBar from "../components/SideBar1.vue";
// Ensure this path matches your store location
import { useNavItemsStore } from '@/stores/navItems';
import { useNavItemsStore } from '~/stores/navItems1';
definePageMeta({
middleware: 'auth'
+19 -28
View File
@@ -1,56 +1,49 @@
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
import type { RouteLocationNormalized } from 'vue-router';
// Import the useAuth composable (it will be auto-imported, but this helps TypeScript/VS Code)
import { useAuth } from '~/composables/useAuth';
// Define the shape of the user object returned by your authentication API.
// This provides type safety for session.user.
interface User {
name?: string | null;
email: string;
// Add other properties from your user object as needed.
}
// Define the shape of the full session object returned by the API.
interface Session {
user: User;
}
// NOTE: We don't need the interfaces here anymore, as useAuth handles the typing.
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
console.log('🛡️ Auth middleware triggered for:', to.path);
// Skip middleware on server-side during development 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 without redirection loops
if (to.path === '/LoginPage') {
console.log('⏭️ Allowing access to LoginPage');
return;
}
// Skip auth check if it's the development server side render pass.
// Although we still recommend using the checkAuth() composable for better SSR handling.
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping intensive check on server-side during development');
// Using the composable here ensures it is registered correctly, even if we skip the main check
useAuth();
return;
}
// Check for the authentication signal from a successful login redirect
const isAuthRedirect: boolean = to.query.authenticated === 'true';
// If this is a redirect from a successful login, allow the route to load.
// We navigate to a clean URL to remove the query parameter.
if (isAuthRedirect) {
console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
return navigateTo({ path: to.path, query: {} }, { replace: true });
}
try {
console.log('🔍 Checking authentication status...');
const { checkAuth } = useAuth();
console.log('🔍 Checking authentication status using useAuth...');
// Use the defined Session interface to type the fetch response
const session: Session | null = await $fetch<Session>('/api/auth/session').catch(() => null);
// Use the composable's function to check the session
const user = await checkAuth();
// Check if a valid session and user exist using optional chaining
if (session?.user) {
console.log('✅ User is authenticated:', session.user.name || session.user.email);
if (user) {
console.log('✅ User is authenticated:', user.name || user.preferred_username || user.email);
return;
} else {
console.log('❌ No valid session found, redirecting to login');
// If checkAuth fails, it already cleared the user state. Redirect.
return navigateTo('/LoginPage');
}
} catch (error) {
@@ -59,5 +52,3 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
return navigateTo('/LoginPage');
}
});
+7 -1
View File
@@ -3,7 +3,13 @@ import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
export default defineNuxtConfig({
compatibilityDate: '2025-05-15',
devtools: { enabled: true },
devtools: {
enabled: true,
timeline: {
enabled: true
}
},
modules: [
'@nuxt/content',
+1410 -1177
View File
File diff suppressed because it is too large Load diff
+4 -1
View File
@@ -4,7 +4,9 @@
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"_command_dev": "nuxt dev -o --host --port 3001",
"_command_dev2": "nuxt dev -o --port 3001",
"dev": "nuxt dev -o --port 3001",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
@@ -34,6 +36,7 @@
"vue-router": "^4.5.1"
},
"devDependencies": {
"@types/node": "^24.7.2",
"sass-embedded": "^1.89.2",
"vite-plugin-vuetify": "^2.1.2",
"vuetify": "^3.9.3"
File diff suppressed because it is too large Load diff
+414 -201
View File
@@ -1,114 +1,153 @@
<template>
<v-container fluid class="pa-6 bg-white-lighten-4">
<div class="d-flex justify-space-between align-center mb-6">
<v-container fluid class="pa-8 dashboard-bg">
<div class="d-flex justify-space-between align-center mb-10">
<div>
<h1 class="text-h4 font-weight-bold">Dashboard</h1>
<p v-if="user" class="text-subtitle-1 text-grey-darken-1 mt-1">
Selamat Datang, {{ user.name || user.preferred_username }}!
<h1 class="text-h3 font-weight-black primary-text">
Antrean Dashboard
</h1>
<p v-if="user" class="text-subtitle-1 secondary-text mt-2">
Selamat Datang,
<span class="font-weight-bold primary-accent">
{{ user.name || user.preferred_username }}
</span>! 🍊
</p>
</div>
<div class="d-flex align-center">
<v-chip color="green-lighten-1" class="mr-2 pa-3 font-weight-bold">
<v-icon start icon="mdi-calendar"></v-icon>
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
color="#FF9B1B"
variant="flat"
class="text-caption font-weight-bold rounded-lg elevation-2 mr-4 export-btn"
>
<v-icon start icon="mdi-download-box-outline"></v-icon>
Export Data ({{ exportType }})
<v-icon end>mdi-menu-down</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(item, index) in exportOptions"
:key="index"
@click="handleExport(item.type)"
>
<v-list-item-title>
<v-icon start :icon="item.icon"></v-icon>
{{ item.title }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-chip color="white" variant="flat" class="pa-4 font-weight-bold text-caption secondary-text rounded-lg date-chip">
<v-icon start icon="mdi-calendar-check-outline" class="primary-accent"></v-icon>
{{ currentDate }}
</v-chip>
</div>
</div>
<v-row class="mb-6">
<v-row class="mb-10">
<v-col cols="12" sm="6" md="3">
<v-card class="pa-4 rounded-xl elevation-6" color="blue-lighten-1" theme="dark">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #D17A4A;">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-group</v-icon>
<v-icon size="48" class="mr-4 primary-accent">mdi-tablet-dashboard</v-icon>
<div>
<div class="text-h4 font-weight-black">2635</div>
<div class="text-subtitle-1">Total Visitors</div>
<div class="text-h4 font-weight-black primary-accent">150</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Total Antrean Online</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-4 rounded-xl elevation-6" color="cyan-lighten-1" theme="dark">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #4A5F7A;">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-multiple-plus</v-icon>
<v-icon size="48" class="mr-4 secondary-accent">mdi-account-hard-hat</v-icon>
<div>
<div class="text-h4 font-weight-black">759</div>
<div class="text-subtitle-1">Offline Registrants</div>
<div class="text-h4 font-weight-black secondary-accent">100</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Total Antrean MJKN</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-4 rounded-xl elevation-6" color="green-lighten-1" theme="dark">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #D17A4A;">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-multiple-plus-outline</v-icon>
<v-icon size="48" class="mr-4 primary-accent">mdi-account-group</v-icon>
<div>
<div class="text-h4 font-weight-black">1876</div>
<div class="text-subtitle-1">Online Registrants</div>
<div class="text-h4 font-weight-black primary-accent">500</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Total Kunjungan</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-4 rounded-xl elevation-6" color="orange-lighten-1" theme="dark">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #4A5F7A;">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-ticket</v-icon>
<v-icon size="48" class="mr-4 secondary-accent">mdi-timer-sand</v-icon>
<div>
<div class="text-h4 font-weight-black">248</div>
<div class="text-subtitle-1">Total Tickets Printed</div>
<div class="text-h4 font-weight-black secondary-accent">7.5s</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Avg. Check-in Time</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
<v-row>
<v-row class="mb-4">
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h6 font-weight-bold">Grafik Jumlah Antrean</span>
<v-btn-toggle v-model="queueTimePeriod" mandatory divided variant="outlined" color="primary" class="text-caption">
<v-btn size="small" value="day">Hari</v-btn>
<v-btn size="small" value="week">Minggu</v-btn>
<v-btn size="small" value="month">Bulan</v-btn>
<v-btn size="small" value="year">Tahun</v-btn>
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="d-flex justify-space-between align-center px-0 pt-0 mb-4">
<span class="text-h6 font-weight-bold primary-text">Registration Trend</span>
<v-btn-toggle v-model="queueTimePeriod" mandatory variant="outlined" color="#D17A4A" class="text-caption rounded-lg">
<v-btn size="small" value="day">Day</v-btn>
</v-btn-toggle>
</v-card-title>
<v-card-text>
<v-card-text class="pa-0">
<Bar
:data="queueChartData"
:options="queueChartOptions"
style="height: 300px"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h6 font-weight-bold">Monthly Visitor Data</span>
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="d-flex justify-space-between align-center px-0 pt-0 mb-4">
<span class="text-h6 font-weight-bold primary-text">Total Registrasi Perbulan</span>
<div>
<v-chip
:color="activeYear === '2024' ? 'green-lighten-1' : 'grey-lighten-2'"
class="mr-2 text-caption font-weight-bold cursor-pointer"
:color="activeYear === '2024' ? '#D17A4A' : '#F5F5F5'"
:text-color="activeYear === '2024' ? 'white' : '#FF9B1B'"
variant="flat"
class="mr-2 text-caption font-weight-bold cursor-pointer transition-chip rounded-pill"
@click="changeYear('2024')"
>
2024
</v-chip>
<v-chip
:color="activeYear === '2025' ? 'green-lighten-1' : 'grey-lighten-2'"
class="text-caption font-weight-bold cursor-pointer"
:color="activeYear === '2025' ? '#D17A4A' : '#F5F5F5'"
:text-color="activeYear === '2025' ? 'white' : '#FF9B1B'"
variant="flat"
class="text-caption font-weight-bold cursor-pointer transition-chip rounded-pill"
@click="changeYear('2025')"
>
2025
</v-chip>
</div>
</v-card-title>
<v-card-text>
<v-card-text class="pa-0">
<Bar
:data="barData"
:options="barOptions"
style="height: 300px"
style="height: 350px"
/>
</v-card-text>
</v-card>
@@ -117,35 +156,41 @@
<v-row>
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="text-h6 font-weight-bold">Realtime Ticket Queue</v-card-title>
<v-card-text>
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="text-h6 font-weight-bold primary-text px-0 pt-0 mb-4">
Realtime Check-in Velocity
<v-chip size="small" color="success" class="ml-2">
<v-icon start size="small">mdi-circle</v-icon>
Live
</v-chip>
</v-card-title>
<v-card-text class="pa-0">
<Line
ref="realtimeChart"
:data="initialLineData"
:data="realtimeLineData"
:options="lineOptions"
style="height: 300px"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="text-h6 font-weight-bold">Registrant Breakdown</v-card-title>
<v-card-text>
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="text-h6 font-weight-bold primary-text px-0 pt-0 mb-4">Registrant Type Breakdown</v-card-title>
<v-card-text class="d-flex justify-center align-center pa-0" style="height: 350px;">
<Pie
:data="pieData"
:options="pieOptions"
style="height: 300px"
style="max-height: 100%; max-width: 100%;"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-overlay v-model="isLoading" contained class="align-center justify-center">
<v-progress-circular indeterminate size="64" color="primary"></v-progress-circular>
<p class="mt-4">Loading dashboard...</p>
<v-overlay v-model="isLoading" contained class="align-center justify-center dashboard-bg">
<v-progress-circular indeterminate size="64" color="#D17A4A"></v-progress-circular>
<p class="mt-4 primary-text">Loading dashboard...</p>
</v-overlay>
</v-container>
</template>
@@ -154,12 +199,13 @@
import { ref, onMounted, computed, onUnmounted } from 'vue';
import { Bar, Pie, Line } from 'vue-chartjs';
import dayjs from 'dayjs';
// Tambahkan plugin Dayjs
import weekday from 'dayjs/plugin/weekday';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import 'dayjs/locale/id';
dayjs.extend(weekday);
dayjs.extend(weekOfYear);
dayjs.locale('id');
import {
Chart as ChartJS,
@@ -178,128 +224,120 @@ definePageMeta({
middleware:['auth']
})
// Register necessary Chart.js elements
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
// Use the auth composable
const { user, isLoading, checkAuth, logout } = useAuth()
const isLoggingOut = ref(false)
// --- KEYCLOAK/AUTH INTEGRATION ---
// const { user, isLoading, checkAuth, logout } = useAuth()
// const navigateTo = (path) => { console.log('Navigating to', path); };
// Mock user for demo purposes - replace with your actual auth
const user = ref({ name: 'Admin User', preferred_username: 'admin' });
const isLoading = ref(false);
const checkAuth = async () => user.value;
const navigateTo = (path) => console.log('Navigate to:', path);
// ---------------------------------
// Dashboard data
// --- EXPORT STATE ---
const exportType = ref('JSON');
const exportOptions = ref([
{ title: 'JSON (.json)', type: 'json', icon: 'mdi-code-json' },
{ title: 'CSV (.csv)', type: 'csv', icon: 'mdi-file-delimited' },
{ title: 'Excel (.xlsx) - Server Only', type: 'excel', icon: 'mdi-file-excel' },
{ title: 'PDF (.pdf) - Placeholder', type: 'pdf', icon: 'mdi-file-pdf' },
]);
// --------------------
// Dashboard state
const currentDate = ref('');
const activeYear = ref('2025');
const queueTimePeriod = ref('month');
const queueTimePeriod = ref('day');
// --- MOCK DATA DINAMIS ANTREEAN (Tetap) ---
// --- MOCK DATA FOR CHARTS ---
const mockQueueData = ref([
// ... (data tetap sama)
{ date: '2025-09-22', count: 120 },
{ date: '2025-09-23', count: 150 },
{ date: '2025-09-24', count: 135 },
{ date: '2025-09-25', count: 160 },
{ date: '2025-09-26', count: 145 },
{ date: '2025-09-27', count: 170 },
{ date: '2025-09-28', count: 180 },
{ date: '2025-08-10', count: 300 },
{ date: '2025-08-20', count: 350 },
{ date: '2025-09-01', count: 400 },
{ date: '2025-09-15', count: 450 },
{ date: '2024-01-01', count: 1200 },
{ date: '2024-06-01', count: 1800 },
{ date: '2025-01-01', count: 2000 },
{ date: '2025-06-01', count: 2500 },
{ date: '2025-10-13', registrants: 300, attendees: 250 },
{ date: '2025-10-14', registrants: 450, attendees: 400 },
{ date: '2025-10-15', registrants: 500, attendees: 420 },
{ date: '2025-10-16', registrants: 400, attendees: 350 },
{ date: '2025-10-17', registrants: 550, attendees: 500 },
{ date: '2025-10-18', registrants: 435, attendees: 380 },
]);
// --- AKHIR MOCK DATA ANTREEAN ---
// --- REFACTORED REALTIME LOGIC ---
const realtimeChart = ref(null); // Ref untuk mengakses komponen Line
// --- REALTIME CHART LOGIC - FIXED ---
const realtimeChart = ref(null);
const maxDataPoints = 10;
let realtimeInterval = null;
// Initial data structure (non-reactive for update function)
const initialLineData = {
labels: Array.from({ length: maxDataPoints }, (_, i) =>
dayjs().subtract((maxDataPoints - 1 - i) * 5, 'second').format('HH:mm:ss')
),
datasets: [
{
label: 'Tickets Processed',
backgroundColor: '#FF5722',
borderColor: '#FF5722',
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 50) + 100),
fill: false,
tension: 0.1,
},
],
};
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Crucial: Disable animation for smooth realtime scrolling
const realtimeLineData = ref({
labels: Array.from({ length: maxDataPoints }, (_, i) =>
dayjs().subtract((maxDataPoints - 1 - i) * 5, 'second').format('HH:mm:ss')
),
datasets: [
{
label: 'Check-ins/min',
backgroundColor: 'rgba(74, 95, 122, 0.15)',
borderColor: '#5FB7FF',
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 20) + 40),
fill: true,
tension: 0.3,
borderWidth: 3,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: '#4A5F7A',
},
plugins: {
legend: { display: true },
title: { display: false }
],
});
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
plugins: {
legend: { display: true },
title: { display: false }
},
scales: {
y: {
beginAtZero: true,
suggestedMax: 80,
title: { display: true, text: 'Check-ins per Minute' },
grid: { color: 'rgba(0, 0, 0, 0.05)' }
},
scales: {
y: {
beginAtZero: true,
suggestedMax: 200,
title: { display: true, text: 'Count' },
grid: { display: true }
},
x: {
title: { display: true, text: 'Time (HH:MM:SS)' },
grid: { display: false }
}
x: {
title: { display: true, text: 'Time (HH:MM:SS)' },
grid: { display: false }
}
}
});
// Function to simulate realtime data update using chart.update()
const updateRealtimeData = () => {
const chart = realtimeChart.value?.chart;
if (!chart) return;
// 1. Get the current data arrays
const dataArray = chart.data.datasets[0].data;
const labelArray = chart.data.labels;
// 2. Shift (remove) the oldest data point and label
dataArray.shift();
labelArray.shift();
// 3. Generate new data point and time label
const newDataPoint = Math.floor(Math.random() * 50) + 100;
const newTimeLabel = dayjs().format('HH:mm:ss');
// 4. Push the new data and label
dataArray.push(newDataPoint);
labelArray.push(newTimeLabel);
// 5. CRUCIAL: Tell Chart.js to redraw itself without destroying the instance
chart.update();
const data = realtimeLineData.value.datasets[0].data;
const labels = realtimeLineData.value.labels;
// Remove first data point
data.shift();
labels.shift();
// Add new data point
const newDataPoint = Math.floor(Math.random() * 20) + 40;
const newTimeLabel = dayjs().format('HH:mm:ss');
data.push(newDataPoint);
labels.push(newTimeLabel);
console.log('Realtime chart updated:', newTimeLabel, newDataPoint);
};
// --- END REFACTORED REALTIME LOGIC ---
// --- END REALTIME CHART LOGIC ---
// Example data for both years (Tetap)
const visitorData2024 = [150, 200, 350, 400, 380, 500, 550, 600, 520, 480, 650, 700];
const visitorData2025 = [200, 250, 400, 450, 420, 550, 600, 650, 570, 520, 700, 750];
// Check authentication and setup on page load
onMounted(async () => {
try {
const sessionUser = await checkAuth()
if (sessionUser) {
// Set current date
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
currentDate.value = new Date().toLocaleDateString('id-ID', options);
// Start realtime data update interval
// Pastikan chart instance sudah siap sebelum memulai interval
realtimeInterval = setInterval(updateRealtimeData, 5000);
// Set date with Indonesian locale
currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
console.log('Current date set:', currentDate.value);
// Start realtime updates every 5 seconds
realtimeInterval = setInterval(updateRealtimeData, 5000);
console.log('Realtime interval started');
} else {
await navigateTo('/LoginPage');
}
@@ -309,34 +347,87 @@ onMounted(async () => {
}
});
// Clear the interval when the component is unmounted
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
console.log('Realtime interval cleared');
}
});
// Updated logout handler (Tetap)
const handleLogout = async () => {
if (isLoggingOut.value) return
try {
isLoggingOut.value = true
console.log('🚪 Dashboard logout initiated...')
await logout()
} catch (error) {
console.error('❌ Dashboard logout error:', error)
} finally {
isLoggingOut.value = false
}
};
// Function to change the active year (Tetap)
const changeYear = (year) => {
activeYear.value = year;
};
// --- LOGIKA UTAMA UNTUK GRAFIK ANTREEAN (Tetap) ---
// --- DATA UTILITY FUNCTIONS ---
const getKpiData = () => {
return [
{ name: 'Antrean Online', value: 150 },
{ name: 'Antrean MJKN', value: 100 },
{ name: 'Total Kunjungan', value: 500 },
{ name: 'Avg. Check-in Time', value: '7.5s' },
];
};
const convertJsonToCsv = (jsonData, fields) => {
if (!jsonData.length) return '';
const header = fields.join(',');
const csv = jsonData.map(row =>
fields.map(fieldName => {
let value = row[fieldName];
if (typeof value === 'string') {
value = `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
).join('\n');
return header + '\n' + csv;
};
// --- END DATA UTILITY FUNCTIONS ---
// --- EXPORT FUNCTION HANDLER ---
const downloadFile = (data, filename, mimeType) => {
const dataStr = `data:${mimeType};charset=utf-8,` + encodeURIComponent(data);
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", filename);
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
const handleExport = (type) => {
exportType.value = type.toUpperCase();
const filenamePrefix = "antrean_data_" + dayjs().format('YYYYMMDD');
const dataToExport = mockQueueData.value;
if (type === 'json') {
downloadFile(JSON.stringify(dataToExport, null, 2), `${filenamePrefix}.json`, 'application/json');
alert("Data Antrean berhasil diexport sebagai JSON! ✅");
} else if (type === 'csv') {
const fields = ['date', 'registrants', 'attendees'];
const csvContent = convertJsonToCsv(dataToExport, fields);
const kpiData = getKpiData().map(kpi => `# ${kpi.name}: ${kpi.value}`).join('\n');
const finalCsvContent = kpiData + '\n' + csvContent;
downloadFile(finalCsvContent, `${filenamePrefix}.csv`, 'text/csv');
alert("Data Antrean berhasil diexport sebagai CSV! 📊");
} else if (type === 'excel') {
alert("Excel (.xlsx) export requires a dedicated server endpoint. Simulating download... ⚠️");
} else if (type === 'pdf') {
alert("PDF (.pdf) export requires client-side libraries (like jsPDF) or a server service. Simulating download... 📝");
}
};
// --- END EXPORT FUNCTION HANDLER ---
// --- CHART DATA LOGIC ---
const processQueueData = (data, period) => {
const grouped = {};
const sortedDates = data.map(item => ({
@@ -347,37 +438,49 @@ const processQueueData = (data, period) => {
sortedDates.forEach(item => {
let key;
let label;
if (period === 'day') {
key = item.date.format('YYYY-MM-DD');
label = item.date.format('DD/MM');
label = item.date.format('ddd, DD/MM');
} else if (period === 'week') {
key = item.date.format('YYYY-WW');
label = `Wk ${item.date.week()} ${item.date.year()}`;
label = `Wk ${item.date.week()}`;
} else if (period === 'month') {
key = item.date.format('YYYY-MM');
label = item.date.format('MMM YYYY');
} else if (period === 'year') {
key = item.date.format('YYYY');
label = item.date.format('YYYY');
}
if (!grouped[key]) {
grouped[key] = { label: label, count: 0 };
grouped[key] = { label: label, registrants: 0, attendees: 0 };
}
grouped[key].count += item.count;
grouped[key].registrants += item.registrants;
grouped[key].attendees += item.attendees;
});
const finalLabels = Object.values(grouped).map(g => g.label);
const finalCounts = Object.values(grouped).map(g => g.count);
const finalRegistrants = Object.values(grouped).map(g => g.registrants);
const finalAttendees = Object.values(grouped).map(g => g.attendees);
return {
labels: finalLabels,
datasets: [
{
label: `Total Antrean per ${period}`,
backgroundColor: '#FFB300',
data: finalCounts,
label: `Registrants`,
backgroundColor: '#FFB95F',
data: finalRegistrants,
yAxisID: 'y1',
type: 'bar',
borderRadius: 6,
},
{
label: `Attendees`,
backgroundColor: 'transparent',
borderColor: '#5FB7FF',
data: finalAttendees,
yAxisID: 'y1',
type: 'line',
pointRadius: 5,
pointBackgroundColor: '#4A5F7A',
tension: 0.4,
borderWidth: 3,
},
],
};
@@ -392,66 +495,94 @@ const queueChartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true },
legend: { display: true, position: 'top' },
},
scales: {
y: {
y1: {
beginAtZero: true,
grid: { display: true }
title: { display: true, text: 'Count' },
grid: { display: true, color: 'rgba(0, 0, 0, 0.05)' }
},
x: {
grid: { display: false }
}
}
});
// --- AKHIR LOGIKA GRAFIK ANTREEAN ---
const visitorData2024 = [150, 200, 350, 400, 380, 500, 550, 600, 520, 480, 650, 700];
const visitorData2025 = [200, 250, 400, 450, 420, 550, 600, 650, 570, 520, 700, 750];
const conversionRate2025 = [2.5, 3.1, 4.0, 4.5, 4.2, 5.5, 6.0, 5.8, 5.7, 5.2, 6.5, 7.5];
// Computed property Monthly Visitor (Tetap)
const barData = computed(() => {
const dataForYear = activeYear.value === '2024' ? visitorData2024 : visitorData2025;
const conversionData = activeYear.value === '2024' ? [2.0, 2.5, 3.5, 3.8, 3.6, 4.5, 5.0, 5.2, 4.8, 4.5, 5.5, 6.0] : conversionRate2025;
return {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'],
datasets: [
{
label: `Total Visitors ${activeYear.value}`,
backgroundColor: '#2196F3',
label: `Total Registrants`,
backgroundColor: '#FFB95F',
data: dataForYear,
yAxisID: 'y1',
type: 'bar',
borderRadius: 6,
},
{
label: `Conv. Rate (%)`,
borderColor: '#5FB7FF',
backgroundColor: 'transparent',
data: conversionData,
yAxisID: 'y2',
type: 'line',
pointRadius: 5,
pointBackgroundColor: '#4A5F7A',
tension: 0.4,
borderWidth: 3,
}
],
};
});
// Bar chart options (Tetap)
const barOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
legend: { display: true, position: 'top' },
},
scales: {
y: {
y1: {
position: 'left',
beginAtZero: true,
title: { display: true, text: 'Registrants' },
grid: { display: false }
},
y2: {
position: 'right',
beginAtZero: true,
suggestedMax: 10,
title: { display: true, text: 'Conv. Rate (%)' },
grid: { drawOnChartArea: false }
},
x: {
grid: { display: false }
}
}
});
// Pie chart data (Tetap)
const pieData = ref({
labels: ['Offline Registrants', 'Online Registrants'],
datasets: [
{
backgroundColor: ['#2196F3', '#4CAF50'],
backgroundColor: ['#5FB7FF', '#FFB95F'],
hoverBackgroundColor: ['#1B98FF ', '#FF9B1B'],
data: [759, 1876],
borderWidth: 3,
borderColor: '#ffffff',
},
],
});
// Pie chart options (Tetap)
const pieOptions = ref({
responsive: true,
maintainAspectRatio: false,
@@ -461,7 +592,9 @@ const pieOptions = ref({
label: function(context) {
const label = context.label || '';
const value = context.parsed || 0;
return `${label}: ${value}`;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} (${percentage}%)`;
}
}
}
@@ -470,9 +603,89 @@ const pieOptions = ref({
</script>
<style scoped>
/* Color Palette */
.dashboard-bg {
background-color: #FAFAFA;
}
.card-style {
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.04);
}
/* Text Colors */
.primary-text {
color: #2C3E50 !important;
}
.secondary-text {
color: #7B8794 !important;
}
.secondary-light {
color: #7B8794 !important;
}
.primary-accent {
color: #FF9B1B !important;
}
.secondary-accent {
color: #1B98FF !important;
}
/* Buttons & Interactive Elements */
.export-btn {
transition: all 0.3s ease;
text-transform: none;
}
.export-btn:hover {
background-color: #E0916E !important;
transform: translateY(-1px);
}
.cursor-pointer {
cursor: pointer;
}
/* Card Hover Effect - Subtle and Clean */
.hover-effect {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.hover-effect:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08) !important;
}
/* Chip Transition */
.transition-chip {
transition: all 0.25s ease;
}
.transition-chip:hover {
transform: scale(1.05);
}
/* Date Chip Styling */
.date-chip {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.04);
}
/* Chart Card Fixed Height */
.chart-card {
min-height: 480px;
display: flex;
flex-direction: column;
}
.chart-card .v-card-text {
flex: 1;
display: flex;
flex-direction: column;
}
</style>
+65 -162
View File
@@ -1,78 +1,6 @@
<!-- // Pages/LoginPage.vue -->
<template>
<v-container fluid fill-height class="login-background">
<!-- Navigation Bar -->
<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>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Navigation with Dropdown Menus -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
variant="text"
color="white"
class="nav-link"
v-bind="props"
>
Tentang
<v-icon right small>mdi-chevron-down</v-icon>
</v-btn>
</template>
<v-list class="nav-dropdown">
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-hospital-building</v-icon>
<v-list-item-title>Profil Rumah Sakit</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
variant="text"
color="white"
class="nav-link"
v-bind="props"
>
Kontak
<v-icon right small>mdi-chevron-down</v-icon>
</v-btn>
</template>
<v-list class="nav-dropdown">
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-phone</v-icon>
<v-list-item-title>Hubungi Kami</v-list-item-title>
</v-list-item>
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-map-marker</v-icon>
<v-list-item-title>Alamat & Lokasi</v-list-item-title>
</v-list-item>
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-email</v-icon>
<v-list-item-title>Email</v-list-item-title>
</v-list-item>
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-help-circle</v-icon>
<v-list-item-title>Bantuan</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn icon color="white" class="ml-4">
<v-icon>mdi-menu</v-icon>
</v-btn>
</v-app-bar>
<!-- Floating Medical Icons Background -->
<div class="floating-medical-icon icon-1">
<v-icon size="144">mdi-heart-pulse</v-icon>
</div>
@@ -92,53 +20,26 @@
<v-icon size="114">mdi-bandage</v-icon>
</div>
<!-- Centered White Box with Both Sections -->
<v-row class="fill-height align-center justify-center">
<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-card class="main-card white-card rounded-xl pa-0" max-width="450" 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 Section -->
<v-col cols="12" md="6" class="login-section-box d-flex align-center pa-8">
<v-col cols="12" 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>
<span class="text-h5 font-weight-bold app-title-dark">Antrean RSSA</span>
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
src="/AntreanLogobg.png"
alt="Antrean Logo"
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>
@@ -147,12 +48,10 @@
{{ successMessage }}
</v-alert>
<!-- 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"
@@ -169,7 +68,6 @@
<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">
@@ -193,8 +91,7 @@
<v-btn
@click="showAdminContact = true"
variant="text"
color="#0053AD"
size="small"
color="#FF9B1B" size="small"
class="contact-link-dark mt-1"
>
Hubungi Administrator
@@ -202,7 +99,6 @@
</div>
</div>
<!-- Password Help -->
<div class="text-center mt-4">
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
</div>
@@ -213,53 +109,47 @@
</v-col>
</v-row>
<!-- Registration Information Dialog -->
<v-dialog v-model="showRegistrationDialog" max-width="500">
<v-card class="white-dialog rounded-xl">
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#0053AD">mdi-account-plus</v-icon>
Pendaftaran Akun Baru
<v-card-title class="text-h5 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#FF9B1B">mdi-account-plus</v-icon> Pendaftaran Akun Baru
</v-card-title>
<v-card-text class="text-grey-darken-2 pa-6">
<div class="text-center mb-4">
<v-icon size="64" color="#0053AD" class="mb-4">mdi-information</v-icon>
</div>
<v-icon size="64" color="#FF9B1B" class="mb-4">mdi-information</v-icon> </div>
<p class="text-body-1 mb-4">
Untuk mendaftar akun baru pada sistem Antrean RSSA, silakan ikuti langkah berikut:
</p>
<v-list class="transparent">
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-numeric-1-circle</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-numeric-1-circle</v-icon> </template>
<v-list-item-title class="text-grey-darken-2">
Hubungi Administrator IT Rumah Sakit
</v-list-item-title>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-numeric-2-circle</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-numeric-2-circle</v-icon> </template>
<v-list-item-title class="text-grey-darken-2">
Siapkan dokumen identitas dan surat penugasan
</v-list-item-title>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-numeric-3-circle</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-numeric-3-circle</v-icon> </template>
<v-list-item-title class="text-grey-darken-2">
Tunggu proses verifikasi dan aktivasi akun
</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="pa-6 bg-grey-lighten-5">
<v-spacer></v-spacer>
<v-btn
@@ -272,8 +162,7 @@
</v-btn>
<v-btn
@click="showRegistrationDialog = false; showAdminContact = true"
color="#0053AD"
rounded
color="#FF9B1B" rounded
>
<v-icon left>mdi-phone</v-icon>
Hubungi Admin
@@ -282,24 +171,20 @@
</v-card>
</v-dialog>
<!-- Admin Contact Dialog -->
<v-dialog v-model="showAdminContact" max-width="500">
<v-card class="white-dialog rounded-xl">
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#0053AD">mdi-account-tie</v-icon>
Kontak Administrator
<v-card-title class="text-h5 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#FF9B1B">mdi-account-tie</v-icon> Kontak Administrator
</v-card-title>
<v-card-text class="text-grey-darken-2 pa-6">
<div class="text-center mb-4">
<v-icon size="64" color="#0053AD" class="mb-4">mdi-phone-settings</v-icon>
</div>
<v-icon size="64" color="#FF9B1B" class="mb-4">mdi-phone-settings</v-icon> </div>
<v-list class="transparent">
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-office-building</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-office-building</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2 font-weight-bold">
IT Support RSSA
@@ -309,11 +194,10 @@
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-phone</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-phone</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2">
(0341) 343343 ext. 1234
@@ -323,11 +207,10 @@
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-email</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-email</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2">
it-support@rssa.malang.go.id
@@ -337,11 +220,10 @@
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-clock</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-clock</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2">
Senin - Jumat: 07:00 - 15:00
@@ -352,19 +234,18 @@
</div>
</v-list-item>
</v-list>
<v-alert
type="info"
variant="tonal"
class="mt-4"
color="blue"
>
color="orange" >
<div class="text-grey-darken-2">
<strong>Catatan:</strong> Pendaftaran akun memerlukan verifikasi dokumen dan dapat memakan waktu 1-2 hari kerja.
</div>
</v-alert>
</v-card-text>
<v-card-actions class="pa-6 bg-grey-lighten-5">
<v-spacer></v-spacer>
<v-btn
@@ -377,8 +258,7 @@
</v-btn>
<v-btn
@click="copyContactInfo"
color="#0053AD"
rounded
color="#FF9B1B" rounded
>
<v-icon left>mdi-content-copy</v-icon>
Salin Info
@@ -406,6 +286,14 @@ const successMessage = ref<string>('')
const showRegistrationDialog = ref<boolean>(false)
const showAdminContact = ref<boolean>(false)
// Hospital Profile URL
const HOSPITAL_PROFILE_URL = 'https://rsusaifulanwar.jatimprov.go.id/v2/'
// Function to navigate to external hospital profile site
const goToHospitalProfile = (): void => {
window.open(HOSPITAL_PROFILE_URL, '_blank')
}
// Check URL parameters for errors
const route = useRoute()
onMounted(() => {
@@ -414,7 +302,7 @@ onMounted(() => {
}
})
// Custom login handler
// Custom login handler (pointing to Keycloak SSO logic)
const handleLogin = async (): Promise<void> => {
isLoading.value = true
errorMessage.value = ''
@@ -422,7 +310,8 @@ const handleLogin = async (): Promise<void> => {
try {
console.log('Starting login process...')
// Call API route to initiate Keycloak login process
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
method: 'POST'
})
@@ -430,7 +319,8 @@ const handleLogin = async (): Promise<void> => {
if (response?.success && response?.data?.authUrl) {
console.log('Redirecting to Keycloak...')
successMessage.value = 'Redirecting to Keycloak...'
// Redirect the user to the Keycloak authorization URL
setTimeout(() => {
window.location.href = response.data!.authUrl
}, 500)
@@ -439,31 +329,44 @@ const handleLogin = async (): Promise<void> => {
}
} catch (error: any) {
console.error('Login error:', error)
// Display the error message
errorMessage.value = `Login failed: ${error.message || 'Please try again.'}`
} finally {
isLoading.value = false
// Only set isLoading back to false if no redirect is happening
if (!successMessage.value.includes('Redirecting')) {
isLoading.value = false
}
}
}
// Copy contact information to clipboard
const copyContactInfo = async (): Promise<void> => {
// Updated contact info block from user's provided code
const contactInfo = `
IT Support RSSA
Telepon: (0341) 343343 ext. 1234
Email: [email protected]
Jam Operasional: Senin - Jumat, 08:00 - 16:00
Jam Operasional: Senin - Jumat, 07:00 - 15:00
`.trim()
try {
// Use the Clipboard API
await navigator.clipboard.writeText(contactInfo)
successMessage.value = 'Informasi kontak berhasil disalin!'
showAdminContact.value = false
// Clear the success message after a short delay
setTimeout(() => {
successMessage.value = ''
}, 3000)
} catch (error) {
console.error('Failed to copy contact info:', error)
// Fallback or simple alert for error
errorMessage.value = 'Gagal menyalin informasi kontak. Silakan coba salin manual.'
setTimeout(() => {
errorMessage.value = ''
}, 3000)
}
}
</script>
+252
View File
@@ -0,0 +1,252 @@
<template>
<v-container fluid class="pa-0">
<div class="pa-4 pb-0">
<v-toolbar flat color="#B3E5FC" class="floating-toolbar custom-padding"> <v-toolbar-title class="text-h6 font-weight-bold text-blue-darken-3">
<v-icon left class="mr-2" color="blue-darken-3">mdi-heart-pulse</v-icon>
Monitoring Pasien
</v-toolbar-title>
<v-spacer></v-spacer>
<v-chip color="blue-darken-1" text-color="white" class="font-weight-medium admin-chip"> <v-icon start>mdi-account-tie</v-icon>
Admin
</v-chip>
</v-toolbar>
</div>
<v-container class="mt-6">
<v-card flat class="mb-4">
<v-tabs
v-model="activeTab"
color="primary"
align-tabs="start"
show-arrows
class="mb-4"
>
<v-tab value="all" @click="filterByStatus('all')">
Semua Pasien <v-badge color="grey-lighten-1" :content="totalCount" inline class="ml-2"></v-badge>
</v-tab>
<v-tab value="Menunggu Poli" @click="filterByStatus('Menunggu Poli')">
Menunggu Poli <v-badge color="orange-lighten-1" :content="waitingCount" inline class="ml-2"></v-badge>
</v-tab>
<v-tab value="Diperiksa Dokter" @click="filterByStatus('Diperiksa Dokter')">
Diperiksa Dokter <v-badge color="green-lighten-1" :content="examiningCount" inline class="ml-2"></v-badge>
</v-tab>
<v-tab value="Selesai Pelayanan" @click="filterByStatus('Selesai Pelayanan')">
Selesai Pelayanan <v-badge color="blue-lighten-1" :content="doneCount" inline class="ml-2"></v-badge>
</v-tab>
</v-tabs>
</v-card>
<v-card class="mb-6 pa-4 elevation-1">
<v-row class="align-center">
<v-col cols="12" sm="3">
<v-text-field label="No. RM" v-model="filters.rm_number" variant="outlined" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="3">
<v-text-field label="No. Kode QR" v-model="filters.qr_code" variant="outlined" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="2">
<v-text-field label="No. Antrean" v-model="filters.queue_number" variant="outlined" density="compact" hide-details type="number"></v-text-field>
</v-col>
<v-col cols="12" sm="2">
<v-select label="Layanan" v-model="filters.service" :items="['Klinik Jiwa', 'Radiologi', 'Fisioterapi', 'Klinik Umum']" variant="outlined" density="compact" hide-details clearable></v-select>
</v-col>
<v-col cols="12" sm="2">
<v-btn color="blue-lighten-1" block height="40" @click="applyFilters">Cari</v-btn>
</v-col>
</v-row>
</v-card>
<v-data-table
:headers="headers"
:items="paginatedPatients"
:items-per-page="10"
class="elevation-1"
:search="currentSearchTerm"
>
<template v-slot:item.status="{ item }">
<v-chip :color="getStatusColor(item.status)" size="small" class="font-weight-medium" label>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.aksi="{ item }">
<v-btn size="small" color="orange-darken-1" dark @click="viewPatient(item.id)">
Lihat
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="info" icon="mdi-information-outline">
Tidak ada data pasien untuk ditampilkan.
</v-alert>
</template>
</v-data-table>
</v-container>
</v-container>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
definePageMeta({
middleware:['auth']
})
const router = useRouter();
// === STATE MANAGEMENT ===
const activeTab = ref('all');
// Filter Model: Menampung input dari form pencarian
const filters = ref({
rm_number: '',
qr_code: '',
queue_number: '', // Filter baru
service: null, // Filter Layanan
});
// State untuk menyimpan hasil filtering
const filteredPatientList = ref([]);
const currentSearchTerm = ref('');
// Table Headers
const headers = [
{ title: 'No. RM', key: 'rm_number' },
{ title: 'Nama', key: 'name' },
{ title: 'No. Antrean', key: 'queue_number' },
{ title: 'Layanan', key: 'service' },
{ title: 'Status', key: 'status' },
{ title: 'Aksi', key: 'aksi', sortable: false },
];
// Mock Patient Data
const ALL_PATIENTS_DATA = [
{ id: '11111111', rm_number: '11111111', name: 'Ragil Bayu N.', queue_number: 1, service: 'Klinik Jiwa', status: 'Diperiksa Dokter', qr_code: 'QR123' },
{ id: '22222222', rm_number: '22222222', name: 'Siti Aisyah', queue_number: 5, service: 'Radiologi', status: 'Menunggu Poli', qr_code: 'QR456' },
{ id: '33333333', rm_number: '33333333', name: 'Ahmad Dani', queue_number: 2, service: 'Klinik Jiwa', status: 'Selesai Pelayanan', qr_code: 'QR789' },
{ id: '44444444', rm_number: '44444444', name: 'Dewi Sartika', queue_number: 6, service: 'Fisioterapi', status: 'Menunggu Poli', qr_code: 'QR101' },
{ id: '55555555', rm_number: '55555555', name: 'Joko Susilo', queue_number: 3, service: 'Klinik Umum', status: 'Diperiksa Dokter', qr_code: 'QR112' },
{ id: '66666666', rm_number: '66666666', name: 'Budi Santoso', queue_number: 4, service: 'Radiologi', status: 'Menunggu Poli', qr_code: 'QR131' },
];
// Initialize the filtered list with all data
filteredPatientList.value = [...ALL_PATIENTS_DATA];
// === FILTERING LOGIC ===
// 1. Logic Pencarian Gabungan saat tombol "Cari" ditekan
const applyFilters = () => {
// 1. Filter berdasarkan teks di semua kolom yang relevan (RM, QR, Antrean, Nama)
let textSearch = '';
// Gabungkan semua nilai filter menjadi satu string pencarian
if (filters.value.rm_number) textSearch += filters.value.rm_number.toLowerCase() + ' ';
if (filters.value.qr_code) textSearch += filters.value.qr_code.toLowerCase() + ' ';
if (filters.value.queue_number) textSearch += filters.value.queue_number.toString().toLowerCase() + ' ';
// Vuetify v-data-table memiliki fitur pencarian bawaan (di properti `search`).
// Kita gunakan fitur ini untuk pencarian berbasis teks (No. RM, QR, Antrean).
currentSearchTerm.value = textSearch.trim();
// 2. Filter berdasarkan Layanan (Service), yang tidak didukung oleh `v-data-table` search
let results = ALL_PATIENTS_DATA;
if (filters.value.service) {
results = results.filter(p => p.service === filters.value.service);
}
// Terapkan hasil filter Layanan
filteredPatientList.value = results;
// Reset status tab (penting agar hasil pencarian tampil terlepas dari tab yang aktif)
activeTab.value = 'all';
};
// 2. Logic Filter Status (saat tab ditekan)
const filterByStatus = (status) => {
// Pastikan status tab diperbarui
activeTab.value = status;
// Clear the text search term when switching tabs
currentSearchTerm.value = '';
if (status === 'all') {
filteredPatientList.value = ALL_PATIENTS_DATA;
} else {
filteredPatientList.value = ALL_PATIENTS_DATA.filter(p => p.status === status);
}
// Clear form filters (opsional, tetapi membuat UX lebih jelas)
filters.value.rm_number = '';
filters.value.qr_code = '';
filters.value.queue_number = '';
filters.value.service = null;
};
// === COMPUTED PROPERTIES ===
// Hitungan untuk Badge Tab
const listForCounts = computed(() => {
// Gunakan ALL_PATIENTS_DATA untuk hitungan badge agar hitungan selalu stabil
return ALL_PATIENTS_DATA;
});
const totalCount = computed(() => listForCounts.value.length);
const waitingCount = computed(() => listForCounts.value.filter(p => p.status === 'Menunggu Poli').length);
const examiningCount = computed(() => listForCounts.value.filter(p => p.status === 'Diperiksa Dokter').length);
const doneCount = computed(() => listForCounts.value.filter(p => p.status === 'Selesai Pelayanan').length);
// Data yang ditampilkan di tabel adalah data yang sudah difilter
const paginatedPatients = computed(() => {
// Jika ada filter status yang aktif (setelah menekan tab)
if (activeTab.value !== 'all') {
return filteredPatientList.value.filter(p => p.status === activeTab.value);
}
// Jika tidak ada filter status yang aktif, tampilkan hasil dari applyFilters()
return filteredPatientList.value;
});
// === UTILITIES & NAVIGATION ===
// Utility function to set color based on status
const getStatusColor = (status) => {
if (status === 'Diperiksa Dokter') return 'green-darken-1';
if (status === 'Menunggu Poli') return 'orange-darken-1';
if (status === 'Selesai Pelayanan') return 'blue-darken-2';
return 'grey';
};
// Function to navigate to the detailed patient information page
const viewPatient = (patientId) => {
router.push(`/MonitoringPasien/Pasien/${patientId}`);
};
</script>
<style scoped>
.floating-toolbar {
border-radius: 9999px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15);
height: 64px !important;
}
/* Jarak PENTING: Tambahkan padding horizontal ke toolbar */
.floating-toolbar.custom-padding {
padding: 0 16px; /* Memberi jarak 16px di kiri dan kanan */
}
/* Penyesuaian agar teks dan ikon kontras dengan soft blue background */
.v-toolbar-title {
margin-left: 0 !important; /* Reset default margin */
}
/* Pastikan chip Admin tidak mepet */
.admin-chip {
margin-right: 0 !important; /* Reset default margin */
}
.v-tab.v-tab--selected {
font-weight: 600;
}
</style>
+298
View File
@@ -0,0 +1,298 @@
<template>
<v-container fluid class="pa-0">
<div class="pa-4 pb-0">
<v-toolbar flat color="#B3E5FC" class="floating-toolbar custom-padding">
<v-toolbar-title class="text-h6 font-weight-bold text-blue-darken-3">
<v-icon left class="mr-2" color="blue-darken-3">mdi-account-details</v-icon>
Informasi Pasien
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="orange-darken-1"
dark
variant="flat"
size="small"
@click="$router.go(-1)"
prepend-icon="mdi-arrow-left"
class="admin-chip"
>
Kembali
</v-btn>
</v-toolbar>
</div>
<v-container class="mt-6">
<v-card class="mb-6 pa-6 elevation-4 rounded-lg" color="white">
<v-row class="align-center mb-4">
<v-col cols="12" md="8">
<div class="d-flex align-center mb-2">
<v-icon size="36" color="blue-darken-2" class="mr-3">mdi-account-circle</v-icon>
<div>
<div class="text-caption text-uppercase text-grey-darken-1">Nama Pasien</div>
<div class="text-h5 font-weight-black text-blue-darken-4">{{ patient.name }}</div>
</div>
</div>
<div class="d-flex align-center">
<v-icon size="24" color="grey-darken-1" class="mr-3">mdi-numeric</v-icon>
<div>
<div class="text-caption text-uppercase text-grey-darken-1">Nomor Rekam Medis</div>
<v-chip color="orange-darken-1" size="large" class="font-weight-bold">{{ patient.rm_number }}</v-chip>
</div>
</div>
</v-col>
<v-col cols="12" md="4" class="d-flex justify-md-end">
<v-chip :color="patient.status === 'Aktif' ? 'green-darken-1' : 'grey-darken-1'" size="x-large" class="font-weight-bold px-6 py-2">
<v-icon start>mdi-progress-check</v-icon>
Status: {{ patient.status }}
</v-chip>
</v-col>
</v-row>
<v-divider class="my-4"></v-divider>
<v-row>
<v-col cols="12" sm="6" md="4" class="py-1">
<div class="d-flex align-center">
<v-icon class="mr-3" color="light-blue-darken-2">mdi-calendar</v-icon>
<div>
<div class="text-caption text-uppercase text-grey-darken-1">Tanggal Lahir</div>
<div class="font-weight-medium text-subtitle-1">{{ patient.dob }}</div>
</div>
</div>
</v-col>
<v-col cols="12" sm="6" md="4" class="py-1">
<div class="d-flex align-center">
<v-icon class="mr-3" color="light-blue-darken-2">mdi-phone</v-icon>
<div>
<div class="text-caption text-uppercase text-grey-darken-1">No Telepon</div>
<div class="font-weight-medium text-subtitle-1">{{ patient.phone }}</div>
</div>
</div>
</v-col>
<v-col cols="12" sm="12" md="4" class="py-1">
<div class="d-flex align-center">
<v-icon class="mr-3" color="light-blue-darken-2">mdi-map-marker</v-icon>
<div>
<div class="text-caption text-uppercase text-grey-darken-1">Alamat</div>
<div class="font-weight-medium text-subtitle-1">{{ patient.address }}</div>
</div>
</div>
</v-col>
</v-row>
</v-card>
<h2 class="text-h5 mb-4 font-weight-bold text-blue-darken-3">Tiket Pelayanan Aktif</h2>
<v-row class="ticket-row">
<v-col
cols="12"
sm="6"
md="4"
lg="3"
v-for="ticket in patient.activeTickets"
:key="ticket.title"
>
<MonitorPasienTicketCard
:title="ticket.title"
:color="ticket.color"
:steps="ticket.steps"
:current-step-label="ticket.currentStepLabel"
/>
</v-col>
<v-col v-if="!patient.activeTickets || patient.activeTickets.length === 0" cols="12">
<v-card class="pa-6 text-center" variant="tonal" color="grey-lighten-2">
<v-icon size="48" color="grey">mdi-ticket-off-outline</v-icon>
<div class="text-subtitle-1 text-grey-darken-1 mt-2">Pasien ini tidak memiliki tiket pelayanan aktif saat ini.</div>
</v-card>
</v-col>
</v-row>
</v-container>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
definePageMeta({
middleware:['auth']
})
// ===============================================
// JSON MOCK DATA STRUCTURE (Tetap sama)
// ===============================================
const mockPatientData = [
{
id: '11111111',
name: 'Ragil Bayu N. Wibowo',
rm_number: '11111111',
dob: '1 Juni 1967',
phone: '0851-4563-2145',
address: 'Jalan Sumpah Pemuda No. 12, Jakarta',
status: 'Aktif',
activeTickets: [
// 1. TIKET JIWA
{
title: 'Klinik JIWA (Antrean: 1)',
color: 'orange-darken-1',
currentStepLabel: 'Klinik Jiwa',
steps: [
{ label: 'Daftar', date: '15-8-2023', time: '15.01' },
{ label: 'Check in', date: '15-8-2023', time: '07.58' },
{ label: 'Loket 1/3', date: '15-8-2023', time: '08.51' },
{ label: 'Klinik Jiwa', date: '15-8-2023', time: '09.45' },
{ label: 'Apotek', date: '-', time: '-' },
{ label: 'Selesai', date: '-', time: '-' },
]
},
// 2. TIKET RADIOLOGI
{
title: 'Penunjang RADIOLOGI (Antrean: 5)',
color: 'blue-darken-2',
currentStepLabel: 'Loket 5/6/7',
steps: [
{ label: 'Daftar', date: '15-8-2023', time: '15.01' },
{ label: 'Check in', date: '15-8-2023', time: '07.58' },
{ label: 'Loket 5/6/7', date: '15-8-2023', time: '08.51' },
{ label: 'Tunggu Radiologi', date: '-', time: '-' },
{ label: 'Selesai', date: '-', time: '-' },
]
},
// 3. TIKET GIZI
{
title: 'Klinik GIZI (Antrean: 12)',
color: 'purple-darken-1',
currentStepLabel: 'Konsultasi Gizi',
steps: [
{ label: 'Daftar', date: '16-8-2023', time: '07.00' },
{ label: 'Tunggu Panggilan', date: '16-8-2023', time: '07.20' },
{ label: 'Konsultasi Gizi', date: '16-8-2023', time: '07.45' },
{ label: 'Selesai', date: '-', time: '-' },
]
},
// 4. TIKET FISIOTERAPI
{
title: 'Fisioterapi (Antrean: 2)',
color: 'teal-darken-1',
currentStepLabel: 'Daftar',
steps: [
{ label: 'Daftar', date: '16-8-2023', time: '10.00' },
{ label: 'Fisioterapi', date: '-', time: '-' },
{ label: 'Selesai', date: '-', time: '-' },
]
},
// 5. TIKET LABORATORIUM
{
title: 'Penunjang LABORATORIUM (Antrean: 7)',
color: 'red-darken-2',
currentStepLabel: 'Pemeriksaan Sampel',
steps: [
{ label: 'Daftar', date: '17-8-2023', time: '06.30' },
{ label: 'Administrasi', date: '17-8-2023', time: '06.40' },
{ label: 'Tunggu Panggilan', date: '17-8-2023', time: '06.50' },
{ label: 'Pengambilan Sampel', date: '17-8-2023', time: '07.10' },
{ label: 'Pemeriksaan Sampel', date: '17-8-2023', time: '07.30' },
{ label: 'Input Hasil', date: '-', time: '-' },
{ label: 'Verifikasi Dokter', date: '-', time: '-' },
{ label: 'Ambil Hasil', date: '-', time: '-' },
{ label: 'Selesai', date: '-', time: '-' },
]
},
]
},
{
id: '33333333',
name: 'Joko Susilo',
rm_number: '33333333',
dob: '20 Desember 1995',
phone: '0878-1122-3344',
address: 'Jl. Pahlawan No. 5, Surabaya',
status: 'Tidak Aktif',
activeTickets: []
},
];
// ===============================================
// MAIN SCRIPT LOGIC
// ===============================================
const route = useRoute();
const patientId = route.params.id;
const patient = ref({
name: 'Memuat...',
address: 'Memuat...',
phone: 'Memuat...',
rm_number: patientId,
dob: 'Memuat...',
status: 'Memuat',
activeTickets: []
});
const fetchPatientData = () => {
const data = mockPatientData.find(p => p.id === patientId);
if (data) {
patient.value = data;
} else {
patient.value = {
name: 'Data Pasien Tidak Ditemukan',
address: '-',
phone: '-',
rm_number: patientId,
dob: '-',
status: 'Error',
activeTickets: []
};
}
};
onMounted(fetchPatientData);
</script>
<style scoped>
/* =============================================== */
/* FLOATING TOOLBAR STYLES */
/* =============================================== */
.floating-toolbar {
border-radius: 9999px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15);
height: 64px !important;
}
.floating-toolbar.custom-padding {
padding: 0 16px;
}
.admin-chip {
margin-right: 0 !important;
}
/* =============================================== */
/* TICKET CARD ALIGNMENT STYLES (Fix Tinggi Card) */
/* =============================================== */
.ticket-row {
/* Mengaktifkan flex container pada row */
display: flex;
/* Agar item (v-col) menyesuaikan tinggi item tertinggi */
align-items: stretch;
}
/* Pastikan v-col children juga menggunakan full height */
.ticket-row > .v-col {
display: flex;
flex-direction: column;
}
/* =============================================== */
/* TIMELINE STYLES (Jika ada) */
/* =============================================== */
.v-timeline-item {
padding-bottom: 8px !important;
}
</style>
File diff suppressed because it is too large Load diff
+800
View File
@@ -0,0 +1,800 @@
<template>
<v-container fluid class="pa-0">
<!-- Hero Header Section -->
<div class="hero-header">
<v-container class="py-8">
<div class="d-flex align-center">
<v-icon color="white" size="40" class="mr-3">mdi-account-circle</v-icon>
<div>
<h1 class="text-h4 font-weight-bold text-white mb-1">Profil Saya</h1>
<p class="text-body-1 text-white opacity-90">Kelola informasi profil dan pengaturan akun Anda</p>
</div>
</div>
</v-container>
</div>
<v-container class="content-container pb-12">
<v-row>
<!-- Left Sidebar - Profile Card -->
<v-col cols="12" lg="4">
<v-card class="rounded-xl elevation-8 sticky-card profile-card">
<div class="text-center profile-content">
<div class="profile-avatar-container">
<v-avatar size="140" class="profile-avatar elevation-8">
<v-img
:src="profileData.picture || 'https://i.pravatar.cc/300?img=68'"
alt="Profile Picture"
></v-img>
</v-avatar>
<v-btn
icon
size="small"
color="orange-darken-2"
class="avatar-edit-btn elevation-4"
@click="openPhotoDialog"
>
<v-icon size="18">mdi-camera</v-icon>
</v-btn>
</div>
<h2 class="text-h5 font-weight-bold mb-2">{{ profileData.name }}</h2>
<p class="text-body-2 text-grey-darken-1 mb-1">@{{ profileData.username }}</p>
<p class="text-body-2 text-grey mb-4">{{ profileData.email }}</p>
<v-chip
color="success"
variant="flat"
size="small"
prepend-icon="mdi-check-circle"
class="mb-4"
>
Akun Terverifikasi
</v-chip>
<v-divider class="my-4"></v-divider>
<!-- Quick Info -->
<div class="text-left px-6">
<div class="info-item mb-3">
<v-icon size="20" color="blue-darken-2" class="mr-2">mdi-identifier</v-icon>
<span class="text-body-2 text-grey-darken-1">ID: {{ profileData.id }}</span>
</div>
<div class="info-item mb-3">
<v-icon size="20" color="orange-darken-2" class="mr-2">mdi-calendar-check</v-icon>
<span class="text-body-2 text-grey-darken-1">Bergabung: {{ profileData.joinDate }}</span>
</div>
<div class="info-item">
<v-icon size="20" color="green-darken-2" class="mr-2">mdi-clock-outline</v-icon>
<span class="text-body-2 text-grey-darken-1">Login: {{ profileData.lastLogin }}</span>
</div>
</div>
</div>
<v-card-actions class="pa-4">
<v-btn
color="blue-darken-2"
variant="outlined"
block
class="rounded-lg"
prepend-icon="mdi-cog"
@click="navigateTo('/Profile/Pengaturan')"
>
Pengaturan Akun
</v-btn>
</v-card-actions>
</v-card>
</v-col>
<!-- Right Content - Profile Details -->
<v-col cols="12" lg="8">
<!-- Personal Information -->
<v-card class="rounded-xl elevation-4 mb-4">
<v-card-title class="d-flex align-center pa-6 pb-4">
<v-icon color="blue-darken-2" class="mr-2">mdi-account-details</v-icon>
<span class="font-weight-bold">Informasi Pribadi</span>
<v-spacer></v-spacer>
<v-btn
v-if="!isEditing"
color="orange-darken-2"
variant="flat"
size="small"
class="rounded-lg"
prepend-icon="mdi-pencil"
@click="startEdit"
>
Edit
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6">
<v-form ref="profileForm">
<v-row>
<v-col cols="12" md="6">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">NAMA LENGKAP</label>
<v-text-field
v-model="profileData.name"
placeholder="Masukkan nama lengkap"
prepend-inner-icon="mdi-account"
variant="outlined"
density="comfortable"
color="blue-darken-2"
:readonly="!isEditing"
hide-details="auto"
class="mb-4"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">USERNAME</label>
<v-text-field
v-model="profileData.username"
placeholder="Masukkan username"
prepend-inner-icon="mdi-at"
variant="outlined"
density="comfortable"
color="blue-darken-2"
:readonly="!isEditing"
hide-details="auto"
class="mb-4"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">EMAIL</label>
<v-text-field
v-model="profileData.email"
placeholder="Masukkan email"
prepend-inner-icon="mdi-email"
variant="outlined"
density="comfortable"
color="blue-darken-2"
:readonly="!isEditing"
hide-details="auto"
class="mb-4"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">NOMOR TELEPON</label>
<v-text-field
v-model="profileData.phone"
placeholder="Masukkan nomor telepon"
prepend-inner-icon="mdi-phone"
variant="outlined"
density="comfortable"
color="blue-darken-2"
:readonly="!isEditing"
hide-details="auto"
class="mb-4"
></v-text-field>
</v-col>
<v-col cols="12">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">BIO</label>
<v-textarea
v-model="profileData.bio"
placeholder="Ceritakan tentang diri Anda"
prepend-inner-icon="mdi-text"
variant="outlined"
color="blue-darken-2"
rows="3"
:readonly="!isEditing"
hide-details="auto"
></v-textarea>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-divider v-if="isEditing"></v-divider>
<v-card-actions v-if="isEditing" class="pa-6 pt-4">
<v-spacer></v-spacer>
<v-btn
variant="outlined"
color="grey-darken-1"
class="rounded-lg px-6"
@click="cancelEdit"
>
Batal
</v-btn>
<v-btn
color="blue-darken-2"
variant="flat"
class="rounded-lg px-8"
prepend-icon="mdi-check"
@click="saveProfile"
:loading="isSaving"
>
Simpan Perubahan
</v-btn>
</v-card-actions>
</v-card>
<!-- Security Section -->
<v-card class="rounded-xl elevation-4 mb-4">
<v-card-title class="d-flex align-center pa-6 pb-4">
<v-icon color="orange-darken-2" class="mr-2">mdi-shield-check</v-icon>
<span class="font-weight-bold">Keamanan</span>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6">
<v-list class="transparent">
<v-list-item
class="rounded-lg mb-2 px-4"
prepend-icon="mdi-lock-reset"
@click="openPasswordDialog"
>
<v-list-item-title class="font-weight-medium">Ubah Password</v-list-item-title>
<v-list-item-subtitle class="text-caption">Perbarui password akun Anda</v-list-item-subtitle>
<template v-slot:append>
<v-icon color="grey">mdi-chevron-right</v-icon>
</template>
</v-list-item>
<v-list-item
class="rounded-lg mb-2 px-4"
prepend-icon="mdi-two-factor-authentication"
>
<v-list-item-title class="font-weight-medium">Autentikasi Dua Faktor</v-list-item-title>
<v-list-item-subtitle class="text-caption">Tingkatkan keamanan akun</v-list-item-subtitle>
<template v-slot:append>
<v-switch
v-model="twoFactorEnabled"
color="blue-darken-2"
hide-details
inset
@change="toggleTwoFactor"
></v-switch>
</template>
</v-list-item>
<v-list-item
class="rounded-lg px-4"
prepend-icon="mdi-devices"
@click="openDevicesDialog"
>
<v-list-item-title class="font-weight-medium">Perangkat Aktif</v-list-item-title>
<v-list-item-subtitle class="text-caption">Kelola perangkat yang terhubung</v-list-item-subtitle>
<template v-slot:append>
<v-chip size="small" color="success" variant="flat">{{ activeSessions.length }} Perangkat</v-chip>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
<!-- Preferences Section -->
<v-card class="rounded-xl elevation-4">
<v-card-title class="d-flex align-center pa-6 pb-4">
<v-icon color="blue-darken-2" class="mr-2">mdi-tune</v-icon>
<span class="font-weight-bold">Preferensi</span>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6">
<v-list class="transparent">
<v-list-item class="rounded-lg mb-2 px-4" prepend-icon="mdi-bell">
<v-list-item-title class="font-weight-medium">Notifikasi Email</v-list-item-title>
<v-list-item-subtitle class="text-caption">Terima update via email</v-list-item-subtitle>
<template v-slot:append>
<v-switch
v-model="emailNotifications"
color="orange-darken-2"
hide-details
inset
></v-switch>
</template>
</v-list-item>
<v-list-item class="rounded-lg mb-2 px-4" prepend-icon="mdi-theme-light-dark">
<v-list-item-title class="font-weight-medium">Tema Gelap</v-list-item-title>
<v-list-item-subtitle class="text-caption">Aktifkan mode gelap</v-list-item-subtitle>
<template v-slot:append>
<v-switch
v-model="darkMode"
color="blue-darken-2"
hide-details
inset
></v-switch>
</template>
</v-list-item>
<v-list-item class="rounded-lg px-4" prepend-icon="mdi-web">
<v-list-item-title class="font-weight-medium">Bahasa</v-list-item-title>
<v-list-item-subtitle class="text-caption">Indonesia</v-list-item-subtitle>
<template v-slot:append>
<v-icon color="grey">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
Change Password Dialog
<v-dialog v-model="passwordDialog" max-width="500" persistent>
<v-card class="rounded-xl">
<v-card-title class="pa-6 pb-4">
<div class="d-flex align-center">
<v-icon color="orange-darken-2" class="mr-2">mdi-lock-reset</v-icon>
<span class="font-weight-bold">Ubah Password</span>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6">
<v-form ref="passwordForm">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">PASSWORD SAAT INI</label>
<v-text-field
v-model="passwordData.current"
type="password"
prepend-inner-icon="mdi-lock"
variant="outlined"
density="comfortable"
color="blue-darken-2"
hide-details="auto"
class="mb-4"
></v-text-field>
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">PASSWORD BARU</label>
<v-text-field
v-model="passwordData.new"
type="password"
prepend-inner-icon="mdi-lock-plus"
variant="outlined"
density="comfortable"
color="blue-darken-2"
hide-details="auto"
class="mb-4"
></v-text-field>
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">KONFIRMASI PASSWORD</label>
<v-text-field
v-model="passwordData.confirm"
type="password"
prepend-inner-icon="mdi-lock-check"
variant="outlined"
density="comfortable"
color="blue-darken-2"
hide-details="auto"
></v-text-field>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-6 pt-4">
<v-spacer></v-spacer>
<v-btn
variant="outlined"
color="grey-darken-1"
class="rounded-lg"
@click="passwordDialog = false"
>
Batal
</v-btn>
<v-btn
color="orange-darken-2"
variant="flat"
class="rounded-lg px-6"
@click="changePassword"
:loading="isChangingPassword"
>
Ubah Password
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Active Devices Dialog -->
<v-dialog v-model="devicesDialog" max-width="700" scrollable>
<v-card class="rounded-xl">
<v-card-title class="pa-6 pb-4">
<div class="d-flex align-center">
<v-icon color="blue-darken-2" class="mr-2">mdi-devices</v-icon>
<span class="font-weight-bold">Perangkat Aktif</span>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-0" style="max-height: 500px;">
<v-list class="py-0">
<v-list-item
v-for="(session, index) in activeSessions"
:key="index"
class="py-4 px-6"
>
<template v-slot:prepend>
<v-avatar :color="session.current ? 'success' : 'blue-grey-lighten-4'" size="48">
<v-icon :color="session.current ? 'white' : 'blue-grey-darken-2'">
{{ session.icon }}
</v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold mb-1">
{{ session.device }}
<v-chip v-if="session.current" size="x-small" color="success" class="ml-2">
Sesi Ini
</v-chip>
</v-list-item-title>
<v-list-item-subtitle class="text-caption">
<div>{{ session.location }}</div>
<div class="text-grey-darken-1 mt-1">
<v-icon size="12">mdi-clock-outline</v-icon>
{{ session.lastActive }}
</div>
</v-list-item-subtitle>
<template v-slot:append v-if="!session.current">
<v-btn
icon="mdi-close-circle"
size="small"
variant="text"
color="error"
@click="removeSession(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-6 pt-4">
<v-btn
color="error"
variant="outlined"
class="rounded-lg"
prepend-icon="mdi-logout-variant"
@click="logoutAllDevices"
>
Keluar dari Semua Perangkat
</v-btn>
<v-spacer></v-spacer>
<v-btn
variant="text"
class="rounded-lg"
@click="devicesDialog = false"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Photo Upload Dialog -->
<v-dialog v-model="photoDialog" max-width="400">
<v-card class="rounded-xl">
<v-card-title class="pa-6 pb-4">
<div class="d-flex align-center">
<v-icon color="blue-darken-2" class="mr-2">mdi-camera</v-icon>
<span class="font-weight-bold">Ubah Foto Profil</span>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6 text-center">
<v-avatar size="150" class="mb-4">
<v-img :src="profileData.picture"></v-img>
</v-avatar>
<v-file-input
label="Pilih foto baru"
variant="outlined"
prepend-icon=""
prepend-inner-icon="mdi-image"
accept="image/*"
hide-details
></v-file-input>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-6 pt-4">
<v-btn
color="error"
variant="text"
class="rounded-lg"
>
Hapus Foto
</v-btn>
<v-spacer></v-spacer>
<v-btn
variant="outlined"
color="grey-darken-1"
class="rounded-lg"
@click="photoDialog = false"
>
Batal
</v-btn>
<v-btn
color="blue-darken-2"
variant="flat"
class="rounded-lg px-6"
>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Success Snackbar -->
<v-snackbar
v-model="snackbar"
:color="snackbarColor"
location="top"
timeout="3000"
class="snackbar-custom"
>
<div class="d-flex align-center">
<v-icon class="mr-2">{{ snackbarIcon }}</v-icon>
<span>{{ snackbarMessage }}</span>
</div>
<template v-slot:actions>
<v-btn
variant="text"
size="small"
icon="mdi-close"
@click="snackbar = false"
></v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, reactive, computed } from 'vue';
import { navigateTo } from '#app';
definePageMeta({
middleware: 'auth'
});
const isEditing = ref(false);
const isSaving = ref(false);
const isChangingPassword = ref(false);
const snackbar = ref(false);
const snackbarMessage = ref('');
const snackbarColor = ref('success');
const passwordDialog = ref(false);
const devicesDialog = ref(false);
const photoDialog = ref(false);
const twoFactorEnabled = ref(false);
const emailNotifications = ref(true);
const darkMode = ref(false);
const snackbarIcon = computed(() => {
return snackbarColor.value === 'success' ? 'mdi-check-circle' : 'mdi-alert-circle';
});
const profileData = reactive({
id: 'USR-12345678',
name: 'John Doe',
username: 'johndoe',
email: '[email protected]',
phone: '+62 812 3456 7890',
bio: 'Passionate developer and tech enthusiast. Love building beautiful and functional web applications.',
picture: 'https://i.pravatar.cc/300?img=68',
joinDate: '15 Januari 2024',
lastLogin: '16 Oktober 2025, 10:30 AM'
});
const passwordData = reactive({
current: '',
new: '',
confirm: ''
});
const activeSessions = ref([
{
device: 'Chrome on Windows 11',
location: 'Sidoarjo, Indonesia',
lastActive: 'Sekarang',
icon: 'mdi-laptop',
current: true
},
{
device: 'Mobile App on Android',
location: 'Surabaya, Indonesia',
lastActive: '2 jam yang lalu',
icon: 'mdi-cellphone',
current: false
},
{
device: 'Safari on macOS',
location: 'Jakarta, Indonesia',
lastActive: '1 hari yang lalu',
icon: 'mdi-laptop',
current: false
}
]);
const originalData = ref({});
const startEdit = () => {
originalData.value = { ...profileData };
isEditing.value = true;
};
const cancelEdit = () => {
Object.assign(profileData, originalData.value);
isEditing.value = false;
};
const saveProfile = async () => {
isSaving.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 1000));
// TODO: Replace with actual API call
// await $fetch('/api/profile/update', {
// method: 'PUT',
// body: profileData
// });
originalData.value = { ...profileData };
isEditing.value = false;
snackbarMessage.value = 'Profil berhasil diperbarui!';
snackbarColor.value = 'success';
snackbar.value = true;
} catch (error) {
snackbarMessage.value = 'Gagal memperbarui profil!';
snackbarColor.value = 'error';
snackbar.value = true;
} finally {
isSaving.value = false;
}
};
const openPhotoDialog = () => {
photoDialog.value = true;
};
const openPasswordDialog = () => {
passwordDialog.value = true;
passwordData.current = '';
passwordData.new = '';
passwordData.confirm = '';
};
const changePassword = async () => {
if (passwordData.new !== passwordData.confirm) {
snackbarMessage.value = 'Password baru tidak cocok!';
snackbarColor.value = 'error';
snackbar.value = true;
return;
}
isChangingPassword.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 1000));
// TODO: Replace with actual API call
passwordDialog.value = false;
snackbarMessage.value = 'Password berhasil diubah!';
snackbarColor.value = 'success';
snackbar.value = true;
} catch (error) {
snackbarMessage.value = 'Gagal mengubah password!';
snackbarColor.value = 'error';
snackbar.value = true;
} finally {
isChangingPassword.value = false;
}
};
const openDevicesDialog = () => {
devicesDialog.value = true;
};
const removeSession = (index) => {
activeSessions.value.splice(index, 1);
snackbarMessage.value = 'Perangkat berhasil dihapus dari sesi aktif';
snackbarColor.value = 'success';
snackbar.value = true;
};
const logoutAllDevices = () => {
// Keep only current session
activeSessions.value = activeSessions.value.filter(s => s.current);
devicesDialog.value = false;
snackbarMessage.value = 'Berhasil keluar dari semua perangkat lain';
snackbarColor.value = 'success';
snackbar.value = true;
};
const toggleTwoFactor = () => {
const status = twoFactorEnabled.value ? 'diaktifkan' : 'dinonaktifkan';
snackbarMessage.value = `Autentikasi dua faktor ${status}`;
snackbarColor.value = 'info';
snackbar.value = true;
};
// Initialize original data
originalData.value = { ...profileData };
</script>
<style scoped>
.hero-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 50%, #f57c00 100%);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
position: relative;
z-index: 1;
}
.content-container {
margin-top: 40px;
}
.sticky-card {
position: sticky;
top: 80px;
}
.profile-card {
overflow: visible;
}
.profile-content {
padding-top: 0;
padding-bottom: 20px;
}
.profile-avatar-container {
position: relative;
display: inline-block;
margin-top: -70px;
margin-bottom: 20px;
}
.profile-avatar {
border: 6px solid white;
background: white;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.avatar-edit-btn {
position: absolute;
bottom: 8px;
right: 8px;
width: 32px;
height: 32px;
}
.stat-item {
text-align: center;
}
.info-item {
display: flex;
align-items: center;
}
label {
letter-spacing: 0.5px;
}
.v-list-item {
transition: background-color 0.2s;
}
.v-list-item:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.snackbar-custom {
font-weight: 500;
}
@media (max-width: 1280px) {
.sticky-card {
position: relative;
top: 0;
}
}
</style>
+1 -1
View File
@@ -289,7 +289,7 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useLocalStorage, useSessionStorage } from '@vueuse/core';
import { VueDraggableNext } from 'vue-draggable-next';
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
import { useNavItemsStore } from '@/stores/navItems';
import { useNavItemsStore } from '~/stores/navItems1';
definePageMeta({
middleware: ['auth']
+287 -186
View File
@@ -1,11 +1,30 @@
<template>
<div class="user-login-page pa-4">
<!-- Display Authentication Info for context -->
<div class="mb-4 pa-3 bg-blue-grey-lighten-5 rounded-lg text-caption">
User Status:
<v-chip :color="isAuthenticated ? 'green' : 'red'" size="small" class="ml-2 mr-1">
{{ isAuthenticated ? 'Authenticated' : 'Unauthenticated' }}
</v-chip>
(Username: <strong>{{ user?.username || 'N/A' }}</strong>)
</div>
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<v-alert
v-if="fetchError"
type="error"
variant="tonal"
class="mb-4"
icon="mdi-alert-circle-outline"
>
Failed to load initial data: {{ fetchError.message }}. Check your API connections.
</v-alert>
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
{{ isEditMode ? 'Edit Pengguna' : readOnly ? 'Detail Pengguna' : 'Tambah Pengguna' }}
@@ -31,7 +50,9 @@
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
:readonly="readOnly || isEditMode"
:hint="isEditMode ? 'Username tidak dapat diubah' : ''"
persistent-hint
></v-text-field>
</v-col>
</v-row>
@@ -91,9 +112,9 @@
></v-select>
</v-col>
</v-row>
<v-row v-if="!readOnly && !isEditMode">
<v-row v-if="!readOnly">
<v-col cols="12">
<v-label class="font-weight-bold">Password</v-label>
<v-label class="font-weight-bold">{{ isEditMode ? 'Ganti Password (Kosongkan jika tidak diubah)' : 'Password' }}</v-label>
<v-text-field
v-model="editedItem.password"
placeholder="Masukkan Password"
@@ -120,6 +141,8 @@
variant="flat"
class="text-capitalize rounded-lg"
@click="saveItem"
:loading="isSaving"
:disabled="isSaving"
>
Submit
</v-btn>
@@ -127,10 +150,10 @@
</v-card>
<div v-else>
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card class="d-flex flex-wrap justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-account-group-outline" class="mr-2" size="40"></v-icon>
<span>User Login</span>
<span>User Login Management</span>
</v-card-title>
<v-btn
color="success"
@@ -138,6 +161,7 @@
rounded
class="text-capitalize"
@click="showAddForm"
:disabled="isPending"
>
Tambah User
</v-btn>
@@ -173,16 +197,18 @@
></v-text-field>
</div>
</div>
<v-data-table
:headers="headers"
:items="allUserData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
:loading="isPending || isSaving"
loading-text="Memuat data pengguna..."
class="rounded-lg elevation-0 custom-table"
>
<template v-slot:[`item.roles`]="{ item }">
<template v-slot:item.roles="{ item }">
<v-chip
v-for="role in item.roles"
:key="role"
@@ -194,7 +220,7 @@
</v-chip>
</template>
<template v-slot:[`item.groups`]="{ item }">
<template v-slot:item.groups="{ item }">
<v-chip
v-for="group in item.groups"
:key="group"
@@ -206,7 +232,7 @@
</v-chip>
</template>
<template v-slot:[`item.actions`]="{ item }">
<template v-slot:item.actions="{ item }">
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
@@ -250,92 +276,134 @@
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data **{{ itemToDelete?.namaLengkap }}**?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog" :disabled="isSaving">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete" :loading="isSaving">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
>
{{ snackbar.message }}
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
>
{{ snackbar.message }}
</v-snackbar>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
// ----------------------------------------------------------------------
// FIX: Explicitly import useAuth from its source path (~/composables/useAuth.ts)
// This resolves the 'useAuth is not defined' runtime error during SSR/build.
import { useAuth } from '~/composables/useAuth';
// ----------------------------------------------------------------------
// Set page metadata (optional, but good practice for a Nuxt page)
definePageMeta({
middleware:['auth']
})
middleware: ['auth'] // Example: requires user to be logged in
});
// --- NEW DATA LISTS FOR SELECTION ---
const availableRoles = [
'User_Standard', 'User_Report', 'User_Klinik', 'User_Farmasi',
'Admin_UserManagement', 'Admin_System', 'Manager_View'
];
const availableGroups = [
'/RS/Klinik', '/RS/Loket', '/RS/Farmasi', '/RS/IT', '/RS/Management'
];
// ------------------------------------
// Define the expected structure of user data
interface UserManagementItem {
id: string | number; // Keycloak UUID
namaLengkap: string;
namaUser: string; // Keycloak username
tipeUser: string; // Custom attribute mapping
keterangan: string;
roles: string[];
groups: string[];
password?: string;
}
// Define the core user data structure (without IDs initially)
const rawUserData = [
{ namaLengkap: 'LOKET 1', namaUser: 'loket1', tipeUser: 'Loket', keterangan: 'Loket 1', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'LOKET 2', namaUser: 'loket2', tipeUser: 'Loket', keterangan: 'Loket 2', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'LOKET 3', namaUser: 'loket3', tipeUser: 'Loket', keterangan: 'Loket 3', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'ANAK', namaUser: 'anak', tipeUser: 'Klinik', keterangan: 'ANAK', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
{ namaLengkap: 'ADMIN PRAM', namaUser: 'adminpram', tipeUser: 'Admin', keterangan: 'Administrator Utama', roles: ['Admin_UserManagement', 'User_Report'], groups: ['/RS/IT'] },
{ namaLengkap: 'Report Only', namaUser: 'laporan', tipeUser: 'Report Only', keterangan: 'Hanya melihat laporan', roles: ['User_Report'], groups: ['/RS/Management'] },
{ namaLengkap: 'Farmasi Utama', namaUser: 'farmasi_utama', tipeUser: 'Farmasi', keterangan: 'Apoteker Penanggung Jawab', roles: ['User_Farmasi'], groups: ['/RS/Farmasi'] },
{ namaLengkap: 'Super Admin User', namaUser: 'superadmin', tipeUser: 'Super Admin', keterangan: 'Full Control System', roles: ['Admin_System', 'Admin_UserManagement', 'User_Standard'], groups: ['/RS/IT', '/RS/Management'] },
// Adding more generic data to make the list longer and sequential
{ namaLengkap: 'Klinik Umum', namaUser: 'klinik_umum', tipeUser: 'Klinik', keterangan: 'Dokter Umum', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
{ namaLengkap: 'Manajer Keuangan', namaUser: 'manager_keu', tipeUser: 'Manager', keterangan: 'Pengelola Anggaran', roles: ['Manager_View', 'User_Report'], groups: ['/RS/Management'] },
];
// Data structure for initial API response
interface InitialData {
users: UserManagementItem[];
roles: string[];
groups: string[];
}
// Map over the raw data to assign sequential IDs starting from 1
const allUserData = ref(rawUserData.map((item, index) => ({
...item,
id: index + 1
})));
// State untuk menampilkan/menyembunyikan formulir
const showForm = ref(false);
const readOnly = ref(false);
// --- AUTHENTICATION & INITIAL DATA FETCHING (SSR Compatible) ---
// 1. Get Auth State
const { user, isAuthenticated } = useAuth();
// 2. Use useAsyncData for Server-Side Data Fetching
const { data: initialData, pending: isPending, error: fetchError, refresh } = await useAsyncData<InitialData>('user-management-data', async () => {
// Note: $fetch is used here and will work on both server and client
const [users, roles, groups] = await Promise.all([
// API endpoint to fetch all users (accessible by admin/manager)
$fetch('/api/users/list'),
// API endpoint to fetch available Keycloak roles
$fetch('/api/keycloak/roles'),
// API endpoint to fetch available Keycloak groups
$fetch('/api/keycloak/groups'),
]);
// Ensure data is formatted correctly before returning
return {
users: (users as UserManagementItem[]).map((u: any) => ({ ...u, id: u.id || 'N/A' })),
roles: roles as string[],
groups: groups as string[],
};
}, {
// Refresh interval for live data (optional)
// server: false // Uncomment if you want this block to run only on client side
});
// State synchronization: Initialize local refs from useAsyncData result
const allUserData = ref<UserManagementItem[]>(initialData.value?.users || []);
const availableRoles = ref<string[]>(initialData.value?.roles || []);
const availableGroups = ref<string[]>(initialData.value?.groups || []);
const isSaving = ref(false); // Tracks client-side CRUD operations (Add/Edit/Delete)
// Watch the Nuxt data ref and pending state to update local data and loading status
watch(initialData, (newData) => {
if (newData) {
allUserData.value = newData.users;
availableRoles.value = newData.roles;
availableGroups.value = newData.groups;
}
}, { deep: true });
// --- LOCAL STATE ---
// Table configuration
const headers = ref([
{ title: 'No', key: 'id' },
{ title: 'Nama Lengkap', key: 'namaLengkap', sortable: true },
{ title: 'Nama User', key: 'namaUser', sortable: true },
{ title: 'Tipe User', key: 'tipeUser', sortable: true },
{ title: 'Roles', key: 'roles', sortable: false },
{ title: 'Groups', key: 'groups', sortable: false },
{ title: 'Keterangan', key: 'keterangan', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
{ title: 'ID', key: 'id' },
{ title: 'Nama Lengkap', key: 'namaLengkap', sortable: true },
{ title: 'Nama User', key: 'namaUser', sortable: true },
{ title: 'Tipe User', key: 'tipeUser', sortable: true },
{ title: 'Roles', key: 'roles', sortable: false },
{ title: 'Groups', key: 'groups', sortable: false },
{ title: 'Keterangan', key: 'keterangan', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
]);
// Breadcrumbs
const breadcrumbs = ref([
{ title: 'Dashboard', disabled: false, href: '#/dashboard' },
{ title: 'Setting', disabled: false, href: '#/setting' },
{ title: 'User Login', disabled: false, href: '#/setting/userlogin' },
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '#/setting' },
{ title: 'User Login', disabled: false, href: '#/setting/userlogin' },
]);
// State untuk tabel dan paginasi
// State for table and pagination
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
const itemToDelete = ref(null);
const itemToDelete = ref<UserManagementItem | null>(null);
// State untuk dialog dan form
// State for dialog and form
const showForm = ref(false);
const readOnly = ref(false);
const showDeleteDialog = ref(false);
const isEditMode = ref(false);
const editedIndex = ref(-1);
const snackbar = ref({
show: false,
message: '',
@@ -343,38 +411,9 @@ const snackbar = ref({
timeout: 3000,
});
// Updated `editedItem` structure with new fields
const editedItem = ref({
id: 0,
namaLengkap: '',
namaUser: '',
tipeUser: '',
keterangan: '',
password: '',
roles: [],
groups: []
});
// Computed properties untuk paginasi kustom
const pageCount = computed(() => {
return Math.ceil(allUserData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allUserData.value.length);
const total = allUserData.value.length;
// Handle case where total is 0 (no data)
if (total === 0) return 'Showing 0 to 0 of 0 entries';
return `Showing ${start} to ${end} of ${total} entries`;
});
// FUNCTIONS
const resetForm = () => {
editedItem.value = {
id: 0,
// Edited item structure
const emptyItem: UserManagementItem = {
id: '',
namaLengkap: '',
namaUser: '',
tipeUser: '',
@@ -382,115 +421,177 @@ const resetForm = () => {
password: '',
roles: [],
groups: []
};
};
const editedItem = ref<UserManagementItem>(Object.assign({}, emptyItem));
// --- COMPUTED PROPERTIES ---
const pageCount = computed(() => {
// Using filtered/searched data length for accurate count if search was handled client-side,
// but since we are using v-data-table's built-in search, we use the full length here
// for a simple calculation of total pages based on all data.
return Math.ceil(allUserData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const total = allUserData.value.length;
if (total === 0) return 'Showing 0 to 0 of 0 entries';
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, total);
return `Showing ${start} to ${end} of ${total} entries`;
});
// --- FORM & ACTION FUNCTIONS ---
const resetForm = () => {
editedItem.value = Object.assign({}, emptyItem);
};
const showAddForm = () => {
resetForm();
isEditMode.value = false;
readOnly.value = false;
showForm.value = true;
resetForm();
isEditMode.value = false;
readOnly.value = false;
showForm.value = true;
};
const viewItem = (item) => {
// We copy the item data for view mode
editedItem.value = Object.assign({}, item);
isEditMode.value = false;
readOnly.value = true;
showForm.value = true;
const viewItem = (item: UserManagementItem) => {
editedItem.value = Object.assign({}, item);
isEditMode.value = false;
readOnly.value = true;
showForm.value = true;
};
const editItem = (item) => {
// Find the index of the item in the actual array
editedIndex.value = allUserData.value.findIndex(data => data.id === item.id);
// Copy the item data to the editedItem
editedItem.value = Object.assign({}, item);
// Clear password field for security (will only be set if user types a new one)
editedItem.value.password = '';
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
};
const saveItem = () => {
// Basic validation (can be expanded)
if (!editedItem.value.namaLengkap || !editedItem.value.namaUser || !editedItem.value.tipeUser) {
snackbar.value = { show: true, message: 'Nama Lengkap, Nama User, dan Tipe User wajib diisi!', color: 'error', timeout: 3000 };
return;
}
// Remove password from the final object for display purposes,
// as it should be securely handled in a real backend API.
const { password, ...itemData } = editedItem.value;
if (isEditMode.value) {
// Edit existing item
if (editedIndex.value > -1) {
Object.assign(allUserData.value[editedIndex.value], itemData);
snackbar.value = { show: true, message: 'Data pengguna berhasil diperbarui!', color: 'success', timeout: 3000 };
}
} else {
// Add new item: find the highest current ID and add 1
const maxId = allUserData.value.length > 0 ? Math.max(...allUserData.value.map(item => item.id)) : 0;
itemData.id = maxId + 1;
allUserData.value.push(itemData);
snackbar.value = { show: true, message: 'Pengguna baru berhasil ditambahkan!', color: 'success', timeout: 3000 };
}
cancelForm();
};
const deleteItem = (item) => {
itemToDelete.value = item;
showDeleteDialog.value = true;
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
itemToDelete.value = null;
};
const confirmDelete = () => {
if (itemToDelete.value) {
// Find the index of the item to delete
const index = allUserData.value.findIndex(data => data.id === itemToDelete.value.id);
if (index > -1) {
// Remove the item
allUserData.value.splice(index, 1);
// Re-index the remaining items to keep the 'id' column sequential visually
allUserData.value = allUserData.value.map((item, i) => ({
...item,
id: i + 1
}));
snackbar.value = { show: true, message: 'Data pengguna berhasil dihapus!', color: 'warning', timeout: 3000 };
}
}
closeDeleteDialog();
const editItem = (item: UserManagementItem) => {
editedItem.value = Object.assign({}, item);
editedItem.value.password = ''; // Clear password field for security
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
};
const cancelForm = () => {
showForm.value = false;
resetForm();
editedIndex.value = -1;
showForm.value = false;
resetForm();
};
/**
* Handles form submission (Add or Edit) using Nuxt's $fetch for client-side API call.
*/
const saveItem = async () => {
if (!editedItem.value.namaLengkap || !editedItem.value.namaUser || !editedItem.value.tipeUser) {
snackbar.value = { show: true, message: 'Nama Lengkap, Nama User, dan Tipe User wajib diisi!', color: 'error', timeout: 3000 };
return;
}
if (!isEditMode.value && !editedItem.value.password) {
snackbar.value = { show: true, message: 'Password wajib diisi saat membuat pengguna baru!', color: 'error', timeout: 3000 };
return;
}
isSaving.value = true;
// Prepare payload
const payload = {
namaLengkap: editedItem.value.namaLengkap,
username: editedItem.value.namaUser,
tipeUser: editedItem.value.tipeUser,
keterangan: editedItem.value.keterangan,
roles: editedItem.value.roles,
groups: editedItem.value.groups,
// Only include password if it was set
...(editedItem.value.password && { password: editedItem.value.password })
};
try {
if (isEditMode.value) {
// EDIT: PATCH to update existing user by ID (Keycloak UUID)
await $fetch(`/api/users/${editedItem.value.id}`, {
method: 'PATCH',
body: payload,
});
snackbar.value = { show: true, message: 'Data pengguna berhasil diperbarui!', color: 'success', timeout: 3000 };
} else {
// ADD: POST to create new user
await $fetch('/api/users/create', {
method: 'POST',
body: payload,
});
snackbar.value = { show: true, message: 'Pengguna baru berhasil ditambahkan!', color: 'success', timeout: 3000 };
}
// Re-fetch all initial data to update the table reactively
await refresh();
cancelForm();
} catch (error: any) {
console.error('API Error during save:', error);
// Nuxt's $fetch error handling
const errorMessage = error.data?.message || 'Terjadi kesalahan saat menyimpan data pengguna. Cek log server.';
snackbar.value = { show: true, message: errorMessage, color: 'error', timeout: 5000 };
} finally {
isSaving.value = false;
}
};
const deleteItem = (item: UserManagementItem) => {
itemToDelete.value = item;
showDeleteDialog.value = true;
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
itemToDelete.value = null;
};
/**
* Confirms and executes the user deletion using Nuxt's $fetch.
*/
const confirmDelete = async () => {
if (!itemToDelete.value || !itemToDelete.value.id) {
closeDeleteDialog();
return;
}
isSaving.value = true;
try {
// DELETE request to the backend using the user's ID
await $fetch(`/api/users/${itemToDelete.value.id}`, {
method: 'DELETE',
});
snackbar.value = { show: true, message: `Data pengguna ${itemToDelete.value.namaLengkap} berhasil dihapus!`, color: 'warning', timeout: 3000 };
// Re-fetch data to update the table
await refresh();
} catch (error: any) {
console.error('API Error during delete:', error);
const errorMessage = error.data?.message || 'Gagal menghapus data pengguna.';
snackbar.value = { show: true, message: errorMessage, color: 'error', timeout: 5000 };
} finally {
closeDeleteDialog();
isSaving.value = false;
}
};
</script>
<style scoped>
/* Custom Table Styling for better visual separation */
.custom-table :deep(table) {
border-collapse: collapse;
border-collapse: collapse;
}
.custom-table :deep(th) {
background-color: #f5f5f5 !important;
font-weight: bold;
font-size: 0.875rem; /* text-sm */
background-color: #f5f5f5 !important;
font-weight: bold;
font-size: 0.875rem; /* text-sm */
}
.custom-table :deep(td) {
padding-top: 12px !important;
padding-bottom: 12px !important;
padding-top: 12px !important;
padding-bottom: 12px !important;
}
</style>
+334 -109
View File
@@ -1,115 +1,138 @@
<template>
<v-app>
<v-card
class="elevation-8 rounded-xl header-banner mx-6 mt-6 mb-n6"
class="elevation-3 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">
<v-container fluid class="py-5 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 class="icon-container">
<v-icon size="44" class="primary-icon mr-4">mdi-shield-check</v-icon>
</div>
<div>
<h1 class="text-h3 font-weight-black primary-text mb-1">Verifikasi Akun</h1>
<p class="text-subtitle-1 secondary-text mb-0">Manajemen Pasien Digital</p>
</div>
</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 color="#5FB7FF" class="text-white font-weight-black px-5" variant="flat" rounded="xl" size="large">
<v-icon start color="white">mdi-account-star</v-icon>
Admin
</v-chip>
</v-container>
</v-card>
<v-main class="bg-white">
<v-container fluid class="pa-6 pt-12 pb-12">
<v-main class="bg-main">
<v-container fluid class="pa-6 pt-8 pb-12">
<v-card class="pa-0 mb-6 elevation-4 rounded-lg">
<v-card class="pa-0 mb-6 elevation-3 rounded-xl overflow-hidden card-style">
<v-row class="ma-0 pa-4 align-center bg-white border-b-sm">
<v-col cols="12" sm="6" class="py-1">
<v-row class="ma-0 pa-6 align-center search-section">
<v-col cols="12" sm="6" class="py-2">
<v-text-field
v-model="search"
density="comfortable"
label="Pencarian Pasien..."
label="Cari pasien berdasarkan nama atau RM..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
hide-details
single-line
color="#D17A4A"
bg-color="white"
class="search-field text-body-1"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" class="py-1">
<v-col cols="12" sm="6" class="py-2">
<v-tabs
v-model="filterStatus"
color="orange-darken-1"
color="#D17A4A"
align-tabs="end"
class="pt-0"
class="pt-0 filter-tabs"
slider-color="#D17A4A"
>
<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 :value="0" class="font-weight-bold text-button">
SEMUA <v-chip size="small" color="#7B8794" class="ml-2 text-white">{{ 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 :value="1" class="font-weight-bold text-button">
PENDING <v-chip size="small" color="#D17A4A" class="ml-2 text-white">{{ 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 :value="2" class="font-weight-bold text-button">
VERIFIED <v-chip size="small" color="#4A5F7A" class="ml-2 text-white">{{ verifiedPatients.length }}</v-chip>
</v-tab>
</v-tabs>
</v-col>
</v-row>
<v-card-text class="pa-0">
<v-card-text class="pa-0 bg-white">
<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' }"
class="patient-item py-5 px-6"
:class="{ 'verified-item': 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' }}
<template v-slot:prepend>
<v-avatar :color="patient.status === 'Terverifikasi' ? '#32A3FF' : '#FFA532'" size="64" class="patient-avatar">
<v-icon size="36" color="white">
{{ patient.status === 'Terverifikasi' ? 'mdi-check-decagram' : 'mdi-clock-alert' }}
</v-icon>
</v-avatar>
</template>
<v-list-item-title class="text-h5 font-weight-black mb-2 primary-text">
{{ 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-list-item-subtitle class="mt-2">
<v-row dense class="text-body-1">
<v-col cols="12" sm="3" md="2" class="py-1">
<v-chip size="default" color="#E3F2FD" class="font-weight-bold chip-rm" variant="flat">
<v-icon start size="18" color="#4A5F7A">mdi-file-document</v-icon>
<span style="color: #4A5F7A;">{{ patient.rm }}</span>
</v-chip>
</v-col>
<v-col cols="12" md="4" class="py-0 text-grey-darken-1">
Alamat: {{ patient.alamat }}
<v-col cols="12" sm="5" md="6" class="py-1 secondary-text d-flex align-center">
<v-icon size="18" class="mr-2" color="#7B8794">mdi-map-marker</v-icon>
<span class="text-body-1">{{ patient.alamat }}</span>
</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 cols="12" sm="4" md="4" class="py-1 secondary-text d-flex align-center">
<v-icon size="18" class="mr-2" color="#7B8794">mdi-phone</v-icon>
<span class="text-body-1">{{ patient.telepon || 'Belum diisi' }}</span>
</v-col>
</v-row>
</v-list-item-subtitle>
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
v-if="patient.status === 'Belum Terverifikasi'"
color="orange-darken-1"
size="large"
color="#FFA532"
size="x-large"
@click="openDaftarModal(patient)"
prepend-icon="mdi-account-plus"
prepend-icon="mdi-qrcode-scan"
variant="flat"
class="font-weight-bold elevation-3"
class="font-weight-black elevation-2 action-btn px-8 text-h6 text-white"
rounded="xl"
>
DAFTARKAN
VERIFIKASI
</v-btn>
<v-chip
v-else
color="blue"
label
size="large"
variant="tonal"
class="font-weight-bold"
color="#32A3FF"
size="x-large"
variant="flat"
class="font-weight-black px-6 text-h6 text-white"
rounded="xl"
>
<v-icon start size="24">mdi-shield-check</v-icon>
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">
<v-list-item-title class="text-center py-8 text-h5 secondary-text">
{{ noDataText }}
</v-list-item-title>
</v-list-item>
@@ -117,81 +140,97 @@
</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-dialog v-model="isModalOpen" max-width="600" transition="dialog-bottom-transition" scrollable>
<v-card class="pa-0 rounded-xl overflow-hidden card-style">
<div class="modal-header pa-7">
<v-icon size="72" color="white" class="mb-3">mdi-qrcode-scan</v-icon>
<h2 class="text-h3 font-weight-black text-white mb-2">Aktivasi Akun</h2>
<p class="text-h6 text-white opacity-90">{{ selectedPatient.nama }}</p>
<v-chip color="white" class="font-weight-bold mt-2" variant="flat" size="default">
<span style="color: #D17A4A;">RM: {{ selectedPatient.rm }}</span>
</v-chip>
</div>
<v-card-text>
<v-card-text class="pa-7">
<v-text-field
v-model="tempTelepon"
:disabled="qrCodeGenerated"
label="No. Telepon Pasien"
label="Nomor Telepon Pasien"
placeholder="08xxxxxxxxxx"
variant="outlined"
density="comfortable"
:color="qrCodeGenerated ? 'grey' : 'orange-darken-1'"
:color="qrCodeGenerated ? 'grey' : '#D17A4A'"
:rules="[v => v.length >= 8 || 'Min. 8 digit']"
prepend-inner-icon="mdi-phone"
class="mb-3 text-body-1"
base-color="grey-darken-1"
></v-text-field>
<v-btn
v-if="!qrCodeGenerated"
:disabled="!isTeleponValid"
color="orange-darken-1"
color="#5FB7FF"
block
size="large"
class="mt-1 font-weight-bold elevation-3"
size="x-large"
class="mt-3 font-weight-black elevation-2 text-h5 text-white"
@click="generateQrCode"
rounded="xl"
>
<v-icon left>mdi-qrcode-scan</v-icon>
Lanjutkan & Generate QR
<v-icon left size="32">mdi-qrcode-plus</v-icon>
Generate QR Code
</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 v-else class="text-center mt-6 pa-4 pa-sm-7 qr-container rounded-xl">
<div class="pulse-icon mb-3 mb-sm-4">
<v-icon color="#4A5F7A" :size="display.xs.value ? 48 : 64">mdi-cellphone-check</v-icon>
</div>
<h3 class="text-h5 text-sm-h4 font-weight-black mb-2 mb-sm-3" style="color: #4A5F7A;">Pindai QR Code</h3>
<p class="text-body-2 text-sm-body-1 secondary-text mb-4 mb-sm-6">Arahkan kamera smartphone ke QR code</p>
<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 class="d-flex justify-center mb-3 mb-sm-4">
<div class="qr-frame">
<qrcode-vue
:value="qrCodeData"
:size="qrSize"
level="H"
:foreground="'#4A5F7A'"
:background="'#FFFFFF'"
class="qr-code"
/>
</div>
</div>
</div>
</v-card-text>
<v-card-actions v-if="qrCodeGenerated" class="d-flex justify-space-between pt-0">
<v-card-actions v-if="qrCodeGenerated" class="pa-7 pt-0">
<v-btn
color="orange-darken-1"
variant="tonal"
color="#4A5F7A"
variant="outlined"
@click="reloadQr"
prepend-icon="mdi-reload"
size="x-large"
class="font-weight-bold flex-grow-1 text-body-1"
rounded="xl"
>
Reload
Reload QR
</v-btn>
<v-btn
color="green-darken-1"
variant="elevated"
color="#D17A4A"
variant="flat"
@click="completeVerification"
prepend-icon="mdi-check-circle"
size="large"
class="font-weight-bold elevation-3"
size="x-large"
class="font-weight-black flex-grow-1 elevation-2 text-body-1"
rounded="xl"
>
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-else class="pa-7 pt-0">
<v-btn color="#7B8794" variant="text" @click="closeModal" size="x-large" block rounded="xl" class="font-weight-bold text-body-1">
Batal
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -202,9 +241,15 @@
<script setup>
import { ref, computed } from 'vue';
// PERUBAHAN DI SINI: Import QrcodeVue
import { useDisplay } from 'vuetify';
import QrcodeVue from 'qrcode.vue';
definePageMeta({
middleware:['auth']
})
const display = useDisplay();
// --- MOCK DATA SIMRS ---
const patients = ref([
{ nama: 'Budi Santoso', rm: '00123456', alamat: 'Jl. Melati No. 10', telepon: '', status: 'Belum Terverifikasi' },
@@ -229,9 +274,9 @@ const verifiedPatients = computed(() =>
const filteredPatients = computed(() => {
let statusFiltered = patients.value;
if (filterStatus.value === 1) { // Belum Terverifikasi
if (filterStatus.value === 1) {
statusFiltered = unverifiedPatients.value;
} else if (filterStatus.value === 2) { // Terverifikasi
} else if (filterStatus.value === 2) {
statusFiltered = verifiedPatients.value;
}
@@ -252,7 +297,6 @@ 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({
@@ -264,9 +308,14 @@ const qrCodeData = computed(() => {
: '';
});
// Adaptive QR code size based on screen size
const qrSize = computed(() => {
if (display.xs.value) return 200; // Extra small screens (mobile)
if (display.sm.value) return 240; // Small screens (large phones)
return 280; // Medium and larger screens
});
// --- LOGIC MODAL ---
const openDaftarModal = (patient) => {
selectedPatient.value = patient;
tempTelepon.value = patient.telepon || '';
@@ -315,31 +364,207 @@ const completeVerification = () => {
</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;
/* Background */
.bg-main {
background-color: #FAFAFA;
min-height: 100vh;
}
/* Style untuk Header Card (Floating & Rounded) */
.card-style {
background-color: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.04);
}
/* Text Colors */
.primary-text {
color: #2C3E50 !important;
}
.secondary-text {
color: #7B8794 !important;
}
.primary-icon {
color: #FF9B1B !important;
}
/* Header Banner */
.header-banner {
background: linear-gradient(90deg, #1565C0, #1976D2);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
background-color: #E8F5FF;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
border: 1px solid rgba(0, 0, 0, 0.04);
}
/* Memastikan elemen list item memiliki efek hover yang halus */
.hover\:bg-orange-lighten-5:hover {
background-color: var(--v-theme-orange-lighten-5) !important;
.header-banner:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
/* Memastikan container di v-main memiliki padding atas yang cukup */
.v-main .v-container {
padding-top: 5rem !important;
.icon-container {
background-color: rgba(209, 122, 74, 0.1);
border-radius: 12px;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
}
/* Search Section */
.search-section {
background-color: #FAFAFA;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.search-field {
border-radius: 12px !important;
}
.filter-tabs {
background: transparent;
}
/* Patient List Items */
.patient-item {
border-bottom: 1px solid #F0F0F0;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.patient-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 4px;
background: #D17A4A;
transform: scaleY(0);
transition: transform 0.3s ease;
}
.patient-item:hover::before {
transform: scaleY(1);
}
.patient-item:hover {
background: linear-gradient(90deg, #FFF5EE 0%, #ffffff 100%) !important;
transform: translateX(4px);
}
.verified-item {
background: linear-gradient(90deg, #F0F4F8 0%, #ffffff 100%) !important;
}
.verified-item::before {
background: #4A5F7A !important;
}
.verified-item:hover {
background: linear-gradient(90deg, #E8EFF5 0%, #ffffff 100%) !important;
}
.patient-avatar {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 3px solid white;
}
.chip-rm {
border: 1px solid rgba(74, 95, 122, 0.2);
}
.action-btn {
text-transform: none !important;
letter-spacing: 0.5px;
transition: all 0.3s ease;
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(209, 122, 74, 0.3) !important;
background-color: #E0916E !important;
}
/* Modal Styling */
.modal-header {
background: linear-gradient(135deg, #4A5F7A 0%, #5D7290 100%);
text-align: center;
box-shadow: 0 4px 12px rgba(74, 95, 122, 0.2);
}
.qr-container {
background: linear-gradient(135deg, #F0F4F8 0%, #E8EFF5 100%);
border: 3px solid #4A5F7A;
box-shadow: 0 6px 20px rgba(74, 95, 122, 0.15);
}
.qr-frame {
padding: 16px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: inline-block;
border: 3px solid #4A5F7A;
}
@media (min-width: 600px) {
.qr-frame {
padding: 24px;
border-radius: 16px;
border: 4px solid #4A5F7A;
}
}
.qr-code {
border-radius: 8px;
display: block;
}
.pulse-icon {
animation: pulse-scale 2s ease-in-out infinite;
}
@keyframes pulse-scale {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.08);
opacity: 0.85;
}
}
/* Responsive */
@media (max-width: 960px) {
.patient-item {
padding: 20px !important;
}
.header-banner h1 {
font-size: 1.75rem;
}
.header-banner p {
font-size: 0.9rem;
}
.action-btn {
font-size: 1rem;
padding: 10px 20px;
}
.patient-avatar {
width: 56px !important;
height: 56px !important;
}
.text-h5 {
font-size: 1.25rem !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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

-82
View File
@@ -1,82 +0,0 @@
// stores/navItems.ts
import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core';
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
// Initial default navigation items
const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
// Add other main menu items
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-supervisor", path: "/verifikasiAkun/VerifikasiAkun" },
{ id: 3, name: "Ranap Admin", icon: "mdi-bed", path: "/RanapAdmin" },
{ id: 4, name: "Klinik Admin", icon: "mdi-hospital-box", path: "/KlinikAdmin" },
{ id: 5, name: "Klinik Ruang Admin", icon: "mdi-hospital-marker", path: "/KlinikRuangAdmin" },
{
id: 6,
name: "Anjungan",
icon: "mdi-account-box-multiple",
path: "",
children: [
{ id: 7, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-account-box" },
{ id: 8, name: "Admin Anjungan", path: "/Anjungan/AdminAnjungan", icon: "mdi-account-cog" },
],
},
{ id: 9, name: "Fast Track", icon: "mdi-clock-fast", path: "/FastTrack" },
{ id: 10, name: "Data Pasien", icon: "mdi-account-multiple", path: "/DataPasien" },
{
id: 11,
name: "Screen",
icon: "mdi-monitor",
path: "",
children: [
{ id: 12, name: "Antrian Masuk 1", path: "/Screen/Antrian Masuk 1", icon: "mdi-monitor" },
{ id: 13, name: "Antrian Masuk 2", path: "/Screen/Antrian Masuk 2", icon: "mdi-monitor" },
// ... more screen pages
],
},
{ id: 14, name: "List Pasien", icon: "mdi-format-list-bulleted", path: "/ListPasien" },
{
id: 15 ,
name: "Setting",
icon: "mdi-cog",
path: "",
children: [
{ id: 16, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-shield-lock-outline" },
{ id: 17, name: "User Login", path: "/setting/UserLogin", icon: "mdi-account-circle" },
{ id: 18, name: "Master Loket", path: "/setting/MasterLoket", icon: "mdi-counter" },
{ id: 19, name: "Master Klinik", path: "/setting/MasterKlinik", icon: "mdi-hospital" },
{ id: 20, name: "Master Klinik Ruang", path: "/setting/MasterKlinikRuang", icon: "mdi-hospital-box" },
{ id: 21, name: "Screen", path: "/setting/Screen", icon: "mdi-monitor" },
],
},
];
export const useNavItemsStore = defineStore('navItems', () => {
const navItems = useLocalStorage<NavItem[]>('navItems', defaultNavItems);
function updateNavItems(newItems: NavItem[]) {
// This will update the local storage and the state
navItems.value = newItems.map((item, index) => ({
...item,
id: index + 1
}));
}
function addNavItem(newItem: Omit<NavItem, 'id'>) {
const newId = navItems.value.length > 0 ? Math.max(...navItems.value.map(item => item.id)) + 1 : 1;
navItems.value.push({ ...newItem, id: newId });
}
return {
navItems,
updateNavItems,
addNavItem,
};
});
+89
View File
@@ -0,0 +1,89 @@
// stores/navItems.ts
import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core';
import { computed } from 'vue'; // Import computed dari Vue
interface NavItem {
id: number;
name: string; // Menggantikan 'title'
path: string; // Menggantikan 'to'
icon: string;
children?: NavItem[];
badge?: string; // Tambahkan properti badge
}
// Initial default navigation items
const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path:"/verifikasiAkun/VerifikasiAkun" },
{
id: 3,
name: "Check In",
icon: "mdi-file-document-edit-outline",
path: "/checkinPasien/checkin"
// badge: "3",
},
{ id: 4, name: "Admin Loket", icon: "mdi-account-supervisor-outline", path: "/AdminLoket" },
{ id: 5, name: "Admin Klinik", icon: "mdi-text-box-plus-outline", path: "/AdminKlinik" },
{ id: 6, name: "Admin Penunjang", icon: "mdi-plus-box-outline", path: "/AdminPenunjang" },
{ id: 7, name: "Buat Antrean", icon: "mdi-account-multiple-plus-outline", path: "/BuatAntrean" },
{ id: 8, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien" },
{
id: 9,
name: "Layar Informasi",
icon: "mdi-monitor-multiple",
path: "",
children: [
{ id: 10, name: "Anjungan", path: "/anjungan/anjungan", icon: "mdi-circle-small" },
{ id: 11, name: "Klinik Ruang", path: "/anjungan/AntrianKlinik", icon: "mdi-circle-small" },
],
},
{
id: 12,
name: "Master Data",
icon: "mdi-cog-outline",
path: "",
children: [
{ id: 13, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-circle-small" },
{ id: 14, name: "User Login", path: "/setting/UserLogin", icon: "mdi-circle-small" },
{ id: 15, name: "Master Loket", path: "/setting/masterloket", icon: "mdi-circle-small" },
{ id: 16, name: "Master Klinik", path: "/setting/masterklinik", icon: "mdi-circle-small" },
{ id: 17, name: "Master Klinik Ruang", path: "/setting/masterklinikruang", icon: "mdi-circle-small" },
{ id: 18, name: "Screen", path: "/setting/screen", icon: "mdi-circle-small" },
],
},
];
export const useNavItemsStore = defineStore('navItems', () => {
const navItems = useLocalStorage<NavItem[]>('navItems', defaultNavItems);
// === GETTER PENTING UNTUK MENGATASI MASALAH 'NULL' DI AWAL ===
const getNavItems = computed(() => {
// Jika navItems.value null, undefined, atau bukan array yang valid,
// kembalikan array default.
if (!Array.isArray(navItems.value) || navItems.value === null || navItems.value === undefined) {
return defaultNavItems;
}
return navItems.value;
});
// =============================================================
function updateNavItems(newItems: NavItem[]) {
navItems.value = newItems.map((item, index) => ({
...item,
id: index + 1
}));
}
function addNavItem(newItem: Omit<NavItem, 'id'>) {
const newId = navItems.value.length > 0 ? Math.max(...navItems.value.map(item => item.id)) + 1 : 1;
navItems.value.push({ ...newItem, id: newId });
}
return {
navItems,
getNavItems, // Diekspor untuk digunakan di Sidebar
updateNavItems,
addNavItem,
};
});