update akbar tampilan login dashboard dan verifikasi

This commit is contained in:
Fanrouver
2025-10-28 10:14:00 +07:00
parent 0351f9116d
commit 35024b4797
20 changed files with 4922 additions and 866 deletions

No files matched your search

+79 -247
View File
@@ -1,60 +1,47 @@
/* 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;
/* padding-top: 64px; - REMOVED */
}
.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;
}
/* Navigation Bar Styles - REMOVED */
.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,141 +49,18 @@
}
.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;
@@ -205,16 +69,14 @@
}
.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: none; /* No shadow for the main white card */
}
/* Left Section Styling */
/* Left Section Styling (White) */
.text-section-box {
background: linear-gradient(135deg, #61cbdd 0%, #4194c4 50%, #2089b9 100%);
background: white; /* Kept as white */
min-height: 600px;
position: relative;
overflow: hidden;
@@ -227,9 +89,7 @@
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%);
background: none;
pointer-events: none;
}
@@ -240,31 +100,31 @@
}
.hero-title-box {
color: white;
color: #FF9B1B; /* Orange title on white background: #FF9B1B */
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);
text-shadow: none;
}
.hero-description-box {
color: rgba(255, 255, 255, 0.95);
color: #555; /* Darker text for readability on white */
font-size: 1rem;
line-height: 1.6;
margin-top: 2rem;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.2);
text-shadow: none;
}
.hospital-logo-large {
height: 80px;
width: auto;
filter: drop-shadow(2px 2px 6px rgba(0, 0, 0, 0.3));
filter: drop-shadow(2px 2px 6px rgba(0, 0, 0, 0.1));
}
/* Right Section Styling */
/* Right Login Section (White) */
.login-section-box {
background: white;
background: white; /* Changed to white */
min-height: 600px;
}
@@ -279,9 +139,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 +149,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 +167,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 +188,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 +204,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,53 +244,27 @@
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);
/* Navigation Dropdown Styles - REMOVED */
/* 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);
}
.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);
.v-alert[color="orange"] {
border-left: 8px solid #FF9B1B !important;
color: #333 !important;
}
/* Responsive Design */
@media (max-width: 960px) {
.login-background {
padding-top: 56px;
}
/* .login-background padding adjustment REMOVED */
.main-card {
margin: 1rem;
@@ -455,9 +289,7 @@
}
@media (max-width: 600px) {
.login-background {
padding-top: 56px;
}
/* .login-background padding adjustment REMOVED */
.main-card {
margin: 0.5rem;
+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>
+322 -82
View File
@@ -4,7 +4,7 @@
:close-on-content-click="false"
location="top end"
origin="bottom right"
transition="slide-y-transition"
transition="scale-transition"
open-on-hover
>
<template v-slot:activator="{ props: menuProps }">
@@ -13,20 +13,22 @@
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"
class="pa-3 ma-1 rounded-xl profile-activator"
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>
<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">mdi-chevron-up</v-icon>
<v-icon size="20" class="chevron-icon">mdi-menu-down</v-icon>
</template>
</v-list-item>
@@ -35,87 +37,142 @@
icon
v-bind="menuProps"
size="large"
variant="text"
color="blue-grey-darken-4"
variant="flat"
class="avatar-btn"
: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>
<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="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">
<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' }}
</div>
<div class="text-caption text-grey-darken-1">
</h3>
<p class="text-body-2 text-white opacity-90 mb-2">
{{ user?.email || 'No email' }}
</div>
<div class="text-caption text-grey-darken-2">
ID: {{ user?.id?.substring(0, 8) }}...
</div>
</p>
<v-chip
size="small"
class="glass-chip"
prepend-icon="mdi-identifier"
>
{{ user?.id?.substring(0, 10) }}...
</v-chip>
</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>
<!-- 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>
<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>
<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>
<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>
<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-2"></v-divider>
<v-divider class="my-4"></v-divider>
<v-list-item
link
class="rounded-lg text-red"
<!-- 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"
>
<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>
{{ isLoggingOut ? 'Keluar...' : 'Keluar' }}
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</template>
@@ -146,7 +203,6 @@ const signOut = async () => {
menu.value = false;
try {
// 🚨 Emits event up to SideBar to trigger useAuth().logout()
emit('logout');
} finally {
isLoggingOut.value = false;
@@ -156,21 +212,205 @@ const signOut = async () => {
const handleAction = (action) => {
switch(action) {
case 'account':
navigateTo('/settings/account')
navigateTo('/Profile/Pengaturan')
break;
case 'profile':
navigateTo('/profile')
navigateTo('/Profile/Profil')
break;
case 'notifications':
navigateTo('/notifications')
break;
case 'help':
navigateTo('/help')
break;
case 'darkMode':
darkMode.value = !darkMode.value;
return;
}
if (action !== 'darkMode') {
menu.value = false;
}
menu.value = false;
};
</script>
<style scoped>
.text-red {
color: rgb(244, 67, 54) !important;
.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> -->
+32 -12
View File
@@ -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 { defineProps, defineEmits, onMounted, ref, watch } from 'vue';
import { navigateTo, useRoute } from '#app';
import ProfilePopup from './ProfilePopup.vue';
import { useAuth } from '~/composables/useAuth';
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
+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',
+18
View File
@@ -31,6 +31,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"
@@ -5123,6 +5124,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.7.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.14.0"
}
},
"node_modules/@types/parse-path": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
@@ -16590,6 +16601,13 @@
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unenv": {
"version": "2.0.0-rc.21",
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.21.tgz",
+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
+359 -161
View File
@@ -1,114 +1,155 @@
<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 size="small" value="week">Week</v-btn>
<v-btn size="small" value="month">Month</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 +158,35 @@
<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-card-title>
<v-card-text class="pa-0">
<Line
ref="realtimeChart"
:data="initialLineData"
: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>
@@ -174,6 +215,7 @@ import {
LineElement,
} from 'chart.js';
// Ensure this middleware setup is correct for your framework (e.g., Nuxt)
definePageMeta({
middleware:['auth']
})
@@ -181,54 +223,59 @@ definePageMeta({
// Register necessary Chart.js elements
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
// Use the auth composable
// --- KEYCLOAK/AUTH INTEGRATION ---
const { user, isLoading, checkAuth, logout } = useAuth()
const navigateTo = (path) => { console.log('Navigating to', path); };
// ---------------------------------
// --- 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' },
]);
// --------------------
const isLoggingOut = ref(false)
// Dashboard data
// 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 ---
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')
dayjs().subtract((maxDataPoints - 1 - i) * 10, '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,
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',
},
],
};
@@ -236,9 +283,7 @@ const initialLineData = {
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Crucial: Disable animation for smooth realtime scrolling
},
animation: { duration: 0 },
plugins: {
legend: { display: true },
title: { display: false }
@@ -246,9 +291,9 @@ const lineOptions = ref({
scales: {
y: {
beginAtZero: true,
suggestedMax: 200,
title: { display: true, text: 'Count' },
grid: { display: true }
suggestedMax: 80,
title: { display: true, text: 'Check-ins per Minute' },
grid: { color: 'rgba(0, 0, 0, 0.05)' }
},
x: {
title: { display: true, text: 'Time (HH:MM:SS)' },
@@ -257,49 +302,28 @@ const lineOptions = ref({
}
});
// 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 newDataPoint = Math.floor(Math.random() * 20) + 40;
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();
};
// --- 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);
realtimeInterval = setInterval(updateRealtimeData, 10000);
} else {
await navigateTo('/LoginPage');
}
@@ -309,34 +333,86 @@ onMounted(async () => {
}
});
// Clear the interval when the component is unmounted
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
}
});
// 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 +423,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 +480,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 +577,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 +588,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>
+66 -141
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,18 +20,16 @@
<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-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"
src="/AntreanLogobg.png"
alt="Antrean Logo"
class="hospital-logo-large mb-6"
/>
<h1 class="hero-title-box">Sistem Terbaik</h1>
@@ -119,26 +45,22 @@
</div>
</v-col>
<!-- Right Login Section -->
<v-col cols="12" md="6" class="login-section-box d-flex align-center pa-8">
<div class="login-content w-100">
<!-- Header -->
<div class="text-center mb-6">
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
</div>
<!-- Logo Section -->
<div class="d-flex flex-column align-center text-center mb-6">
<span class="text-h5 font-weight-bold app-title-dark text-blue-darken-3">Antrean RSSA</span>
<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 +69,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 +89,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 +112,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 +120,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 +130,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 +183,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 +192,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 +215,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 +228,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 +241,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 +255,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 +279,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 +307,15 @@ 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
// NOTE: This function is no longer called in the template since the menu item was removed.
const goToHospitalProfile = (): void => {
window.open(HOSPITAL_PROFILE_URL, '_blank')
}
// Check URL parameters for errors
const route = useRoute()
onMounted(() => {
@@ -414,7 +324,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 +332,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 +341,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 +351,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>
+316 -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..."#FFB95F
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">
<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-7 qr-container rounded-xl">
<div class="pulse-icon mb-4">
<v-icon color="#4A5F7A" size="64">mdi-cellphone-check</v-icon>
</div>
<h3 class="text-h4 font-weight-black mb-3" style="color: #4A5F7A;">Pindai QR Code</h3>
<p class="text-body-1 secondary-text mb-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="qr-frame">
<qrcode-vue
:value="qrCodeData"
:size="280"
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,12 @@
<script setup>
import { ref, computed } from 'vue';
// PERUBAHAN DI SINI: Import QrcodeVue
import QrcodeVue from 'qrcode.vue';
definePageMeta({
middleware:['auth']
})
// --- MOCK DATA SIMRS ---
const patients = ref([
{ nama: 'Budi Santoso', rm: '00123456', alamat: 'Jl. Melati No. 10', telepon: '', status: 'Belum Terverifikasi' },
@@ -229,9 +271,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 +294,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({
@@ -263,10 +304,8 @@ const qrCodeData = computed(() => {
})
: '';
});
// --- LOGIC MODAL ---
const openDaftarModal = (patient) => {
selectedPatient.value = patient;
tempTelepon.value = patient.telepon || '';
@@ -315,31 +354,199 @@ 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: 24px;
background: white;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: inline-block;
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: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+44 -37
View File
@@ -1,59 +1,55 @@
// 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;
path: string;
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" },
// 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: 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: 6,
name: "Anjungan",
icon: "mdi-account-box-multiple",
id: 9,
name: "Layar Informasi",
icon: "mdi-monitor-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: 10, name: "Anjungan", path: "/anjungan/anjungan", icon: "mdi-circle-small" },
{ id: 11, name: "Klinik Ruang", path: "/anjungan/AntrianKlinik", icon: "mdi-circle-small" },
],
},
{ 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",
{
id: 12,
name: "Master Data",
icon: "mdi-cog-outline",
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" },
{ 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" },
],
},
];
@@ -61,8 +57,18 @@ const defaultNavItems: NavItem[] = [
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[]) {
// This will update the local storage and the state
navItems.value = newItems.map((item, index) => ({
...item,
id: index + 1
@@ -76,6 +82,7 @@ export const useNavItemsStore = defineStore('navItems', () => {
return {
navItems,
getNavItems, // Diekspor untuk digunakan di Sidebar
updateNavItems,
addNavItem,
};