akbar first commit

This commit is contained in:
Fanrouver
2025-10-01 11:28:06 +07:00
parent 55d8920a82
commit d735824d57
35 changed files with 6091 additions and 890 deletions

106
components/AppBar.vue Normal file
View File

@@ -0,0 +1,106 @@
<template>
<v-app-bar app color="#ff9248" dark>
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
<v-toolbar-title class="ml-2 font-weight-bold">
<span class="text-blue-darken-2">Antrean</span> RSSA
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Show loading state or user info -->
<div v-if="isLoading" class="d-flex align-center">
<v-progress-circular indeterminate size="20" class="mr-2"></v-progress-circular>
<span class="mr-2">Loading...</span>
</div>
<template v-else-if="isAuthenticated && user">
<ProfilePopup
:user="user"
@logout="handleLogout"
/><template>
<v-app-bar app color="blue-grey-darken-3" dark flat>
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
<v-toolbar-title class="ml-2 font-weight-bold">
<span class="text-orange-darken-2">Antrian</span> RSSA
</v-toolbar-title>
<v-spacer></v-spacer>
<div v-if="isLoading" class="d-flex align-center">
<v-progress-circular indeterminate color="orange-darken-2" size="20" class="mr-2"></v-progress-circular>
<span class="text-caption">Loading...</span>
</div>
<template v-else-if="isAuthenticated && user">
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
variant="flat"
rounded="xl"
color="transparent"
class="pa-2 text-capitalize"
>
<div class="d-flex align-center">
<v-avatar color="orange-darken-2" size="36" class="mr-2">
<span class="text-white font-weight-bold">{{ user.name?.charAt(0) || 'U' }}</span>
</v-avatar>
<span class="text-subtitle-1 font-weight-bold text-white">{{ user.name || 'User' }}</span>
<v-icon right size="20" class="ml-1">mdi-chevron-down</v-icon>
</div>
</v-btn>
</template>
<ProfilePopup
:user="user"
@logout="handleLogout"
/>
</v-menu>
</template>
<template v-else>
<v-btn @click="redirectToLogin" color="orange-darken-2" variant="flat" rounded="lg" class="text-capitalize">
<v-icon left>mdi-login</v-icon>
Login
</v-btn>
</template>
</v-app-bar>
</template>
<span class="mr-2">{{ user.name || user.preferred_username || user.email }}</span>
</template>
<template v-else>
<v-btn @click="redirectToLogin" color="white" text>
Login
</v-btn>
</template>
</v-app-bar>
</template>
<script setup>
import ProfilePopup from './ProfilePopup.vue';
// Emit untuk parent component
const emit = defineEmits(['toggle-rail']);
// Use auth composable
const { user, isAuthenticated, isLoading, checkAuth, logout } = useAuth()
// Handle logout - use the composable's logout method
const handleLogout = async () => {
console.log("🚪 AppBar logout initiated...")
try {
await logout()
} catch (error) {
console.error("❌ AppBar logout error:", error)
}
}
// Redirect to login if not authenticated
const redirectToLogin = () => {
navigateTo('/LoginPage')
}
// Check authentication on mount
onMounted(async () => {
await checkAuth()
})
</script>

View File

@@ -0,0 +1,124 @@
<template>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
Edit Hak Akses Menu | {{ localItem.namaTipeUser }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
<v-table density="compact" class="elevation-1 rounded-lg">
<thead>
<tr>
<th class="text-left">No</th>
<th class="text-left">Menu</th>
<th class="text-center">Akses</th>
<th class="text-center">Lihat</th>
<th class="text-center">Tambah</th>
<th class="text-center">Edit</th>
<th class="text-center">Hapus</th>
</tr>
</thead>
<tbody>
<tr v-for="(menu, index) in localItem.hakAksesMenu" :key="menu.name">
<td>{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center">
<v-checkbox v-model="menu.canAccess" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canView" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canAdd" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canEdit" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canDelete" hide-details></v-checkbox>
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="$emit('cancel')"
>
Batal
</v-btn>
<v-btn
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="$emit('save', localItem)"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
// Define types for better readability and safety
interface HakAksesMenu {
name: string;
canAccess: boolean;
canView: boolean;
canAdd: boolean;
canEdit: boolean;
canDelete: boolean;
}
interface HakAksesData {
id: number;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
}
// Define props with type validation
const props = defineProps({
item: {
type: Object as () => HakAksesData,
required: true,
// Add custom validator for more robust checks
validator: (value: HakAksesData) => {
return 'namaTipeUser' in value && 'hakAksesMenu' in value;
},
},
});
// Define emits for clarity
const emits = defineEmits(['save', 'cancel']);
// Use a local copy to avoid mutating the prop directly
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
// Watch the prop for changes and update the local copy
watch(() => props.item, (newItem) => {
localItem.value = JSON.parse(JSON.stringify(newItem));
});
</script>
<style scoped>
.v-table :deep(th) {
font-weight: bold !important;
background-color: #f9fafb !important;
}
.v-table :deep(td) {
vertical-align: middle;
}
.v-checkbox :deep(.v-selection-control__input) {
color: #2196F3 !important;
}
</style>

View File

@@ -2,43 +2,89 @@
<v-menu <v-menu
v-model="menu" v-model="menu"
:close-on-content-click="false" :close-on-content-click="false"
location="bottom" location="bottom right"
origin="top right" origin="top right"
transition="scale-transition" transition="slide-y-transition"
> >
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-btn <v-btn icon v-bind="props">
icon <v-avatar size="40">
v-bind="props" <v-img
class="ml-auto" :src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
> :alt="`${user?.name || 'User'} Profile`"
<v-icon size="40" color="#000000">mdi-account</v-icon> ></v-img>
</v-avatar>
</v-btn> </v-btn>
</template> </template>
<v-card class="mx-auto" color="#FFA000" dark width="250"> <v-card class="rounded-lg elevation-4 pa-4" width="300">
<v-list-item three-line class="py-4"> <div class="d-flex align-center pb-2">
<v-list-item-title class="text-h6 text-center font-weight-bold"> <v-avatar size="48">
<v-avatar color="#fff" size="60"> <v-img
<v-icon size="40" color="#4CAF50">mdi-account</v-icon> :src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
</v-avatar> :alt="`${user?.name || 'User'} Profile`"
</v-list-item-title> ></v-img>
<v-list-item-subtitle class="text-center mt-2 text-black"> </v-avatar>
<span class="d-block font-weight-bold">Rajal Bayu Nogroho</span> <div class="ml-4">
<span class="d-block">Super Admin</span> <div class="text-subtitle-1 font-weight-bold">
</v-list-item-subtitle> {{ user?.name || user?.preferred_username || 'User' }}
</v-list-item> </div>
<div class="text-caption text-grey-darken-1">
{{ user?.email || 'No email' }}
</div>
<div class="text-caption text-grey-darken-2">
ID: {{ user?.id?.substring(0, 8) }}...
</div>
</div>
</div>
<v-divider class="my-2"></v-divider>
<v-card-actions class="d-flex justify-center pa-2"> <v-list dense>
<v-btn <v-list-item link class="rounded-lg" @click="handleAction('account')">
color="black" <template v-slot:prepend>
variant="text" <v-icon>mdi-cog</v-icon>
class="font-weight-bold" </template>
<v-list-item-title>Pengaturan Akun</v-list-item-title>
</v-list-item>
<v-list-item link class="rounded-lg" @click="handleAction('darkMode')">
<template v-slot:prepend>
<v-icon>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="primary"
></v-switch>
</template>
</v-list-item>
<v-list-item link class="rounded-lg" @click="handleAction('profile')">
<template v-slot:prepend>
<v-icon>mdi-account</v-icon>
</template>
<v-list-item-title>Profil Saya</v-list-item-title>
</v-list-item>
<v-divider class="my-2"></v-divider>
<v-list-item
link
class="rounded-lg text-red"
@click="signOut" @click="signOut"
:disabled="isLoggingOut"
> >
Sign out <template v-slot:prepend>
</v-btn> <v-icon color="red">mdi-logout</v-icon>
</v-card-actions> </template>
<v-list-item-title>
{{ isLoggingOut ? 'Logging out...' : 'Keluar' }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-card> </v-card>
</v-menu> </v-menu>
</template> </template>
@@ -46,15 +92,64 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
const menu = ref(false); // Props
const props = defineProps({
user: {
type: Object,
required: true
}
})
const signOut = () => { const menu = ref(false);
console.log("Sign out button clicked!"); const darkMode = ref(false);
const isLoggingOut = ref(false);
const emit = defineEmits(['logout']);
/**
* Handles the logout action - delegates to parent
*/
const signOut = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false; menu.value = false;
// Implementasi logika logout di sini
try {
console.log('🚪 ProfilePopup signOut called...')
emit('logout');
} finally {
isLoggingOut.value = false;
}
};
const handleAction = (action) => {
console.log('Action triggered:', action);
switch(action) {
case 'account':
// Navigate to account settings
navigateTo('/settings/account')
break;
case 'profile':
// Navigate to profile page
navigateTo('/profile')
break;
case 'darkMode':
// Dark mode toggle is handled by v-model
break;
default:
console.log('Unknown action:', action);
}
// Close menu for navigation actions
if (action !== 'darkMode') {
menu.value = false;
}
}; };
</script> </script>
<style scoped> <style scoped>
/* No specific scoped styles needed for this component as Vuetify classes handle styling */ .text-red {
</style> color: rgb(244, 67, 54) !important;
}
</style>

View File

@@ -0,0 +1,75 @@
<!-- <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> -->

103
components/SideBar.vue Normal file
View File

@@ -0,0 +1,103 @@
<!-- components/sideBar.vue -->
<template>
<v-navigation-drawer
:model-value="drawer"
:rail="rail"
permanent
app
@update:model-value="emit('update:drawer', $event)"
>
<v-list density="compact" nav>
<template v-for="item in items" :key="item.name">
<v-menu
v-if="item.children"
open-on-hover
:location="rail ? 'end' : undefined"
:offset="10"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.name"
:value="item.name"
:to="!rail ? item.path : undefined"
:link="!rail"
></v-list-item>
</template>
<v-card class="py-2" min-width="200">
<v-card-title class="text-subtitle-1 font-weight-bold px-4 py-2">
{{ item.name }}
</v-card-title>
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item
v-for="child in item.children"
:key="child.name"
:to="child.path"
:title="child.name"
:prepend-icon="child.icon"
link
class="px-4"
></v-list-item>
</v-list>
</v-card>
</v-menu>
<v-tooltip
v-else
:disabled="!rail"
open-on-hover
location="end"
:text="item.name"
>
<template #activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.name"
:to="item.path"
link
></v-list-item>
</template>
</v-tooltip>
</template>
</v-list>
</v-navigation-drawer>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
const props = defineProps({
items: {
type: Array as () => NavItem[],
required: true,
},
rail: {
type: Boolean,
required: true,
},
drawer: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['update:drawer']);
</script>
<style scoped>
.v-navigation-drawer__content {
background-color: #ffffff;
}
</style>

135
composables/useAuth.ts Normal file
View File

@@ -0,0 +1,135 @@
// composables/useAuth.ts - Enhanced version with better error handling
import type { User, SessionResponse, LoginResponse, LogoutResponse } from '~/types/auth'
export const useAuth = () => {
const user = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value)
const clearError = () => {
error.value = null
}
const checkAuth = async (): Promise<User | null> => {
try {
isLoading.value = true
clearError()
const response = await $fetch<SessionResponse>('/api/auth/session')
if (response.success === false && response.error) {
error.value = response.error
user.value = null
return null
}
user.value = response.user
return response.user
} catch (fetchError: any) {
console.error('Session check failed:', fetchError)
error.value = 'Failed to check authentication status'
user.value = null
return null
} finally {
isLoading.value = false
}
}
const login = async (): Promise<void> => {
try {
clearError()
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
method: 'POST'
})
if (response?.success && response?.data?.authUrl) {
console.log('🔗 Redirecting to Keycloak login...')
window.location.href = response.data.authUrl
} else {
const errorMsg = response?.error || 'Failed to get authorization URL'
error.value = errorMsg
throw new Error(errorMsg)
}
} catch (loginError: any) {
console.error('❌ Login error:', loginError)
error.value = loginError.message || 'Login failed'
throw loginError
}
}
const logout = async (): Promise<void> => {
try {
isLoading.value = true
clearError()
console.log('🚪 Starting logout process...')
const response = await $fetch<LogoutResponse>('/api/auth/logout', {
method: 'POST'
})
// Clear user immediately regardless of response
user.value = null
if (response?.success && response?.logoutUrl) {
console.log('🔗 Redirecting to Keycloak logout...')
window.location.href = response.logoutUrl
} else {
const warningMsg = response?.error || response?.message || 'No logout URL received'
console.warn('⚠️', warningMsg)
error.value = warningMsg
await navigateTo('/LoginPage')
}
} catch (logoutError: any) {
console.error('❌ Logout error:', logoutError)
error.value = logoutError.message || 'Logout failed'
user.value = null
await navigateTo('/LoginPage')
} finally {
isLoading.value = false
}
}
// Helper function to refresh user data
const refreshUser = async (): Promise<boolean> => {
const userData = await checkAuth()
return !!userData
}
// Helper function to check if user has specific role
const hasRole = (role: string): boolean => {
if (!user.value) return false
// Check in roles array
if (user.value.roles?.includes(role)) return true
// Check in realm_access.roles
if (user.value.realm_access?.roles?.includes(role)) return true
return false
}
// Helper function to check if user has any of the specified roles
const hasAnyRole = (roles: string[]): boolean => {
return roles.some(role => hasRole(role))
}
return {
// State
user: readonly(user),
isAuthenticated,
isLoading: readonly(isLoading),
error: readonly(error),
// Actions
checkAuth,
login,
logout,
refreshUser,
clearError,
// Utilities
hasRole,
hasAnyRole
}
}

View File

@@ -1,133 +1,37 @@
<!-- layouts/default.vue -->
<template> <template>
<v-app> <v-app id="inspire">
<v-layout> <AppBar @toggle-rail="rail = !rail" />
<!-- App Bar Header --> <SideBar :items="navItemsStore.navItems" v-model:drawer="drawer" :rail="rail" />
<v-app-bar app color="#FFA000" dark>
<v-app-bar-nav-icon @click="rail = !rail"></v-app-bar-nav-icon>
<v-toolbar-title class="ml-2">Antrian RSSA</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Profile Popup Component -->
<ProfilePopup />
<span class="mr-2">Rajal Bayu Nogroho</span>
</v-app-bar>
<!-- Navigation Drawer --> <v-main app>
<v-navigation-drawer v-model="drawer" :rail="rail" permanent app>
<v-list density="compact" nav>
<template v-for="item in items" :key="item.title">
<v-menu
v-if="item.children"
open-on-hover
location="end"
:nudge-right="3"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.title"
:value="item.title"
:class="{ 'v-list-item--active': item.title === currentPage }"
>
</v-list-item>
</template>
<v-list>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{
item.title
}}</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item
v-for="child in item.children"
:key="child.title"
:to="child.to"
link
>
<v-list-item-title>{{ child.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-tooltip
v-else
:disabled="!rail"
open-on-hover
location="end"
:text="item.title"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.title"
:to="item.to"
:value="item.title"
:class="{ 'v-list-item--active': item.title === currentPage }"
link
></v-list-item>
</template>
</v-tooltip>
</template>
</v-list>
</v-navigation-drawer>
<!-- Page content will be rendered here -->
<slot /> <slot />
</v-main>
</v-layout>
</v-app> </v-app>
</template> </template>
<script setup> <script setup lang="ts">
import { ref } from "vue"; import { ref, watchEffect } from "vue";
import AppBar from "../components/AppBar.vue";
import SideBar from "../components/SideBar.vue";
import { useNavItemsStore } from '@/stores/navItems'; // Import the new store
// Reactive data definePageMeta({
const drawer = ref(true); middleware: 'auth'
const rail = ref(true); })
const currentPage = ref("Klinik Admin");
// Navigation items const drawer = ref(true);
const items = ref([ const rail = ref(true);
{ title: "Dashboard", icon: "mdi-view-dashboard", to: "/dashboard" },
{ const navItemsStore = useNavItemsStore();
title: "Setting",
icon: "mdi-cog", // Your logic to check user access and filter the menu can go here
children: [ // For example:
{ title: "Hak Akses", to: "/setting/hak-akses" }, // const filteredItems = computed(() => {
{ title: "User Login", to: "/setting/user-login" }, // return navItemsStore.navItems.filter(item => userHasAccess(item.path));
{ title: "Master Loket", to: "/setting/master-loket" }, // });
{ title: "Master Klinik", to: "/setting/master-klinik" },
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
{ title: "Screen", to: "/setting/screen" },
],
},
{ title: "Loket Admin", icon: "mdi-account-supervisor", to: "/LoketAdmin" },
{ title: "Ranap Admin", icon: "mdi-bed" },
{ title: "Klinik Admin", icon: "mdi-hospital-box", to: "/KlinikAdmin" },
{ title: "Klinik Ruang Admin", icon: "mdi-hospital-marker", to: "/KlinikRuangAdmin" },
{
title: "Anjungan",
icon: "mdi-account-box-multiple",
children: [
{ title: "Anjungan", to: "/Anjungan/Anjungan" },
{ title: "Admin Anjungan", to: "/Anjungan/AdminAnjungan" },
],
},
{ title: "Fast Track", icon: "mdi-clock-fast", to: "/FastTrack" },
{ title: "Data Pasien", icon: "mdi-account-multiple" },
{ title: "Screen", icon: "mdi-monitor" },
{ title: "List Pasien", icon: "mdi-format-list-bulleted" },
]);
</script> </script>
<style scoped> <style scoped>
#inspire .v-navigation-drawer__content { /* Global styles for layout */
background-color: #f5f5f5; </style>
}
#inspire .v-app-bar {
background-color: #fff;
}
</style>

14
layouts/empty.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<v-app>
<v-main>
<slot />
</v-main>
</v-app>
</template>
<style>
/* Pastikan v-main mengisi seluruh tinggi halaman untuk mengaktifkan centering */
.v-main {
min-height: 100vh;
}
</style>

108
middleware/auth.ts Normal file
View File

@@ -0,0 +1,108 @@
// export default defineNuxtRouteMiddleware(async (to) => {
// console.log('🛡️ Auth middleware triggered for:', to.path)
// // Skip middleware on server-side during build/generation
// if (process.server && process.env.NODE_ENV === 'development') {
// console.log('⏭️ Skipping auth check on server-side during development')
// return
// }
// // Allow the login page to handle its own checks
// if (to.path === '/LoginPage') {
// console.log('⏭️ Allowing access to LoginPage')
// return
// }
// // This is the crucial change: check for the authentication signal
// const isAuthRedirect = to.query.authenticated === 'true';
// // If this is a redirect from a successful login, we need to let the route load
// if (isAuthRedirect) {
// console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
// // We navigate to a clean URL to remove the query parameter
// return navigateTo({ path: to.path, query: {} }, { replace: true });
// }
// try {
// console.log('🔍 Checking authentication status...')
// const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null)
// if (session && session.user) {
// console.log('✅ User is authenticated:', session.user.name || session.user.email)
// return
// } else {
// console.log('❌ No valid session found, redirecting to login')
// return navigateTo('/LoginPage')
// }
// } catch (error) {
// console.error('❌ Auth middleware error:', error)
// console.log('🔄 Redirecting to login due to error')
// return navigateTo('/LoginPage')
// }
// })
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
import type { RouteLocationNormalized } from 'vue-router';
// Define the shape of the user object returned by your authentication API.
// This provides type safety for session.user.
interface User {
name?: string | null;
email: string;
// Add other properties from your user object as needed.
}
// Define the shape of the full session object returned by the API.
interface Session {
user: User;
}
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
console.log('🛡️ Auth middleware triggered for:', to.path);
// Skip middleware on server-side during development build/generation
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping auth check on server-side during development');
return;
}
// Allow the login page to handle its own checks without redirection loops
if (to.path === '/LoginPage') {
console.log('⏭️ Allowing access to LoginPage');
return;
}
// Check for the authentication signal from a successful login redirect
const isAuthRedirect: boolean = to.query.authenticated === 'true';
// If this is a redirect from a successful login, allow the route to load.
// We navigate to a clean URL to remove the query parameter.
if (isAuthRedirect) {
console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
return navigateTo({ path: to.path, query: {} }, { replace: true });
}
try {
console.log('🔍 Checking authentication status...');
// Use the defined Session interface to type the fetch response
const session: Session | null = await $fetch<Session>('/api/auth/session').catch(() => null);
// Check if a valid session and user exist using optional chaining
if (session?.user) {
console.log('✅ User is authenticated:', session.user.name || session.user.email);
return;
} else {
console.log('❌ No valid session found, redirecting to login');
return navigateTo('/LoginPage');
}
} catch (error) {
console.error('❌ Auth middleware error:', error);
console.log('🔄 Redirecting to login due to error');
return navigateTo('/LoginPage');
}
});

44
middleware/guest.ts Normal file
View File

@@ -0,0 +1,44 @@
// middleware/guest.ts
export default defineNuxtRouteMiddleware(async (to) => {
console.log('👋 Guest middleware triggered for:', to.path);
// Skip middleware on server-side during build/generation
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping guest check on server-side during development');
return;
}
const isAuthRedirect = to.query.authenticated === 'true';
const isLogoutSuccess = to.query.logout === 'success';
// If this is a logout success, allow access to login page
if (isLogoutSuccess) {
console.log('✅ Logout success detected, allowing access to login page');
return;
}
// If this is a redirect from a successful login, we need to wait
if (isAuthRedirect) {
console.log('⏳ Client-side is processing a new login session, waiting for session to be established...');
// We navigate to a clean URL to remove the query parameter from the user's view
return navigateTo({ path: to.path, query: {} }, { replace: true });
}
try {
console.log('🔍 Checking if user is already authenticated...');
// The $fetch will automatically send the new user_session cookie
const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null);
if (session && session.user) {
console.log('✅ User already authenticated, redirecting to dashboard');
return navigateTo('/dashboard');
} else {
console.log(' No session found, staying on login page');
return;
}
} catch (error) {
console.log(' Auth check failed (expected for login), staying on login page');
return;
}
});

View File

@@ -1,4 +1,6 @@
// nuxt.config.ts
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2025-05-15', compatibilityDate: '2025-05-15',
devtools: { enabled: true }, devtools: { enabled: true },
@@ -12,6 +14,8 @@ export default defineNuxtConfig({
'@nuxt/scripts', '@nuxt/scripts',
'@nuxt/test-utils', '@nuxt/test-utils',
'@nuxt/ui', '@nuxt/ui',
'@pinia/nuxt',
// Remove '@sidebase/nuxt-auth' completely
(_options, nuxt) => { (_options, nuxt) => {
nuxt.hooks.hook('vite:extendConfig', (config) => { nuxt.hooks.hook('vite:extendConfig', (config) => {
// @ts-expect-error // @ts-expect-error
@@ -19,11 +23,25 @@ export default defineNuxtConfig({
}) })
}, },
], ],
// Remove the auth configuration completely
// auth: { ... } <- Remove this entire block
runtimeConfig: {
authSecret: process.env.NUXT_AUTH_SECRET,
keycloakClientId: process.env.KEYCLOAK_CLIENT_ID,
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
public: {
authUrl: process.env.AUTH_ORIGIN || 'http://localhost:3001' || 'http://localhost:3000'
}
},
build: { build: {
transpile: ['vuetify'] // Important for Nuxt 3 with Vuetify transpile: ['vuetify']
}, },
css: [ css: [
'vuetify/lib/styles/main.sass', // Or 'vuetify/styles' depending on version 'vuetify/lib/styles/main.sass',
'@mdi/font/css/materialdesignicons.min.css', '@mdi/font/css/materialdesignicons.min.css',
], ],
vite: { vite: {
@@ -31,7 +49,7 @@ export default defineNuxtConfig({
noExternal: ['vuetify'] noExternal: ['vuetify']
}, },
plugins: [ plugins: [
vuetify({ autoImport: true }) // If using vite-plugin-vuetify vuetify({ autoImport: true })
] ]
} }
}) })

143
package-lock.json generated
View File

@@ -14,12 +14,18 @@
"@nuxt/scripts": "^0.11.10", "@nuxt/scripts": "^0.11.10",
"@nuxt/test-utils": "^3.19.2", "@nuxt/test-utils": "^3.19.2",
"@nuxt/ui": "^3.3.0", "@nuxt/ui": "^3.3.0",
"@pinia/nuxt": "^0.11.2",
"@unhead/vue": "^2.0.13", "@unhead/vue": "^2.0.13",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"chart.js": "^4.5.0",
"dayjs": "^1.11.18",
"eslint": "^9.32.0", "eslint": "^9.32.0",
"nuxt": "^3.17.7", "nuxt": "^3.17.7",
"pinia": "^3.0.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
@@ -1594,6 +1600,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@kwsites/file-exists": { "node_modules/@kwsites/file-exists": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
@@ -4497,6 +4509,21 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/@pinia/nuxt": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.2.tgz",
"integrity": "sha512-CgvSWpbktxxWBV7ModhAcsExsQZqpPq6vMYEe9DexmmY6959ev8ukL4iFhr/qov2Nb9cQAWd7niFDnaWkN+FHg==",
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^3.9.0"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"pinia": "^3.0.3"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -7892,6 +7919,18 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -8604,6 +8643,12 @@
"node": ">= 12" "node": ">= 12"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
"license": "MIT"
},
"node_modules/db0": { "node_modules/db0": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz", "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz",
@@ -8953,9 +8998,9 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.1.1", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/devlop": { "node_modules/devlop": {
@@ -10286,10 +10331,13 @@
} }
}, },
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.4.6", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT", "license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": { "peerDependencies": {
"picomatch": "^3 || ^4" "picomatch": "^3 || ^4"
}, },
@@ -14715,6 +14763,36 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pinia": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.2"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz",
"integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.7"
}
},
"node_modules/pkg-types": { "node_modules/pkg-types": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
@@ -17158,6 +17236,13 @@
} }
} }
}, },
"node_modules/sortablejs": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
"integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
"license": "MIT",
"peer": true
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.7.6", "version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
@@ -19042,17 +19127,17 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.2", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==", "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.6", "fdir": "^6.5.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"rollup": "^4.43.0", "rollup": "^4.43.0",
"tinyglobby": "^0.2.14" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
@@ -19355,6 +19440,22 @@
"vuetify": "^3.0.0" "vuetify": "^3.0.0"
} }
}, },
"node_modules/vite/node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/vitest-environment-nuxt": { "node_modules/vitest-environment-nuxt": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/vitest-environment-nuxt/-/vitest-environment-nuxt-1.0.1.tgz", "resolved": "https://registry.npmjs.org/vitest-environment-nuxt/-/vitest-environment-nuxt-1.0.1.tgz",
@@ -19400,6 +19501,16 @@
"ufo": "^1.6.1" "ufo": "^1.6.1"
} }
}, },
"node_modules/vue-chartjs": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-component-type-helpers": { "node_modules/vue-component-type-helpers": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.0.5.tgz", "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.0.5.tgz",
@@ -19412,6 +19523,16 @@
"integrity": "sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==", "integrity": "sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/vue-draggable-next": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vue-draggable-next/-/vue-draggable-next-2.3.0.tgz",
"integrity": "sha512-ymbY0UIwfSdg0iDN/iyNNwUrTqZ/6KbPryzsvTNXBLuDCuOBdNijSK8yynNtmiSj6RapTPQfjLGQdJrZkzBd2w==",
"license": "MIT",
"peerDependencies": {
"sortablejs": "^1.14.0",
"vue": "^3.5.17"
}
},
"node_modules/vue-eslint-parser": { "node_modules/vue-eslint-parser": {
"version": "10.2.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz",

View File

@@ -17,12 +17,18 @@
"@nuxt/scripts": "^0.11.10", "@nuxt/scripts": "^0.11.10",
"@nuxt/test-utils": "^3.19.2", "@nuxt/test-utils": "^3.19.2",
"@nuxt/ui": "^3.3.0", "@nuxt/ui": "^3.3.0",
"@pinia/nuxt": "^0.11.2",
"@unhead/vue": "^2.0.13", "@unhead/vue": "^2.0.13",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"chart.js": "^4.5.0",
"dayjs": "^1.11.18",
"eslint": "^9.32.0", "eslint": "^9.32.0",
"nuxt": "^3.17.7", "nuxt": "^3.17.7",
"pinia": "^3.0.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {

476
pages/Dashboard.vue Normal file
View File

@@ -0,0 +1,476 @@
<template>
<v-container fluid class="pa-6 bg-grey-lighten-4">
<div class="d-flex justify-space-between align-center mb-6">
<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 }}!
</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>
{{ currentDate }}
</v-chip>
</div>
</div>
<v-row class="mb-6">
<v-col cols="12" sm="6" md="3">
<v-card class="pa-4 rounded-xl elevation-6" color="blue-lighten-1" theme="dark">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-group</v-icon>
<div>
<div class="text-h4 font-weight-black">2635</div>
<div class="text-subtitle-1">Total Visitors</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">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-multiple-plus</v-icon>
<div>
<div class="text-h4 font-weight-black">759</div>
<div class="text-subtitle-1">Offline Registrants</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">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-multiple-plus-outline</v-icon>
<div>
<div class="text-h4 font-weight-black">1876</div>
<div class="text-subtitle-1">Online Registrants</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">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-ticket</v-icon>
<div>
<div class="text-h4 font-weight-black">248</div>
<div class="text-subtitle-1">Total Tickets Printed</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
<v-row>
<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-btn-toggle>
</v-card-title>
<v-card-text>
<Bar
:data="queueChartData"
:options="queueChartOptions"
style="height: 300px"
/>
</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>
<div>
<v-chip
:color="activeYear === '2024' ? 'green-lighten-1' : 'grey-lighten-2'"
class="mr-2 text-caption font-weight-bold cursor-pointer"
@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"
@click="changeYear('2025')"
>
2025
</v-chip>
</div>
</v-card-title>
<v-card-text>
<Bar
:data="barData"
:options="barOptions"
style="height: 300px"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<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>
<Line
ref="realtimeChart"
:data="initialLineData"
:options="lineOptions"
style="height: 300px"
/>
</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>
<Pie
:data="pieData"
:options="pieOptions"
style="height: 300px"
/>
</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-container>
</template>
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue';
import { Bar, Pie, Line } from 'vue-chartjs';
import dayjs from 'dayjs';
// Tambahkan plugin Dayjs
import weekday from 'dayjs/plugin/weekday';
import weekOfYear from 'dayjs/plugin/weekOfYear';
dayjs.extend(weekday);
dayjs.extend(weekOfYear);
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
ArcElement,
PointElement,
LineElement,
} from 'chart.js';
definePageMeta({
middleware:['auth']
})
// Register necessary Chart.js elements
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
// Use the auth composable
const { user, isLoading, checkAuth, logout } = useAuth()
const isLoggingOut = ref(false)
// Dashboard data
const currentDate = ref('');
const activeYear = ref('2025');
const queueTimePeriod = ref('month');
// --- MOCK DATA DINAMIS ANTREEAN (Tetap) ---
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 },
]);
// --- AKHIR MOCK DATA ANTREEAN ---
// --- REFACTORED REALTIME LOGIC ---
const realtimeChart = ref(null); // Ref untuk mengakses komponen Line
const maxDataPoints = 10;
let realtimeInterval = null;
// Initial data structure (non-reactive for update function)
const initialLineData = {
labels: Array.from({ length: maxDataPoints }, (_, i) =>
dayjs().subtract((maxDataPoints - 1 - i) * 5, 'second').format('HH:mm:ss')
),
datasets: [
{
label: 'Tickets Processed',
backgroundColor: '#FF5722',
borderColor: '#FF5722',
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 50) + 100),
fill: false,
tension: 0.1,
},
],
};
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Crucial: Disable animation for smooth realtime scrolling
},
plugins: {
legend: { display: true },
title: { display: false }
},
scales: {
y: {
beginAtZero: true,
suggestedMax: 200,
title: { display: true, text: 'Count' },
grid: { display: true }
},
x: {
title: { display: true, text: 'Time (HH:MM:SS)' },
grid: { display: false }
}
}
});
// Function to simulate realtime data update using chart.update()
const updateRealtimeData = () => {
const chart = realtimeChart.value?.chart;
if (!chart) return;
// 1. Get the current data arrays
const dataArray = chart.data.datasets[0].data;
const labelArray = chart.data.labels;
// 2. Shift (remove) the oldest data point and label
dataArray.shift();
labelArray.shift();
// 3. Generate new data point and time label
const newDataPoint = Math.floor(Math.random() * 50) + 100;
const newTimeLabel = dayjs().format('HH:mm:ss');
// 4. Push the new data and label
dataArray.push(newDataPoint);
labelArray.push(newTimeLabel);
// 5. CRUCIAL: Tell Chart.js to redraw itself without destroying the instance
chart.update();
};
// --- END REFACTORED REALTIME 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);
} else {
await navigateTo('/LoginPage');
}
} catch (error) {
console.error('Auth check error:', error);
await navigateTo('/LoginPage');
}
});
// 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) ---
const processQueueData = (data, period) => {
const grouped = {};
const sortedDates = data.map(item => ({
...item,
date: dayjs(item.date)
})).sort((a, b) => a.date.valueOf() - b.date.valueOf());
sortedDates.forEach(item => {
let key;
let label;
if (period === 'day') {
key = item.date.format('YYYY-MM-DD');
label = item.date.format('DD/MM');
} else if (period === 'week') {
key = item.date.format('YYYY-WW');
label = `Wk ${item.date.week()} ${item.date.year()}`;
} 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].count += item.count;
});
const finalLabels = Object.values(grouped).map(g => g.label);
const finalCounts = Object.values(grouped).map(g => g.count);
return {
labels: finalLabels,
datasets: [
{
label: `Total Antrean per ${period}`,
backgroundColor: '#FFB300',
data: finalCounts,
},
],
};
};
const queueChartData = computed(() => {
if (!mockQueueData.value.length) return { labels: [], datasets: [] };
return processQueueData(mockQueueData.value, queueTimePeriod.value);
});
const queueChartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true },
},
scales: {
y: {
beginAtZero: true,
grid: { display: true }
},
x: {
grid: { display: false }
}
}
});
// --- AKHIR LOGIKA GRAFIK ANTREEAN ---
// Computed property Monthly Visitor (Tetap)
const barData = computed(() => {
const dataForYear = activeYear.value === '2024' ? visitorData2024 : visitorData2025;
return {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'],
datasets: [
{
label: `Total Visitors ${activeYear.value}`,
backgroundColor: '#2196F3',
data: dataForYear,
},
],
};
});
// Bar chart options (Tetap)
const barOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
grid: { display: false }
},
x: {
grid: { display: false }
}
}
});
// Pie chart data (Tetap)
const pieData = ref({
labels: ['Offline Registrants', 'Online Registrants'],
datasets: [
{
backgroundColor: ['#2196F3', '#4CAF50'],
data: [759, 1876],
},
],
});
// Pie chart options (Tetap)
const pieOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed || 0;
return `${label}: ${value}`;
}
}
}
}
});
</script>
<style scoped>
.cursor-pointer {
cursor: pointer;
}
</style>

View File

@@ -1,128 +0,0 @@
<template>
<v-main class="bg-grey-lighten-3">
<v-container>
<!-- Fast Track Content -->
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h4">Fast Track</h1>
<div class="d-flex">
<v-btn
prepend-icon="mdi-view-dashboard"
variant="text"
class="text-capitalize"
>
Dashboard
</v-btn>
<v-btn
prepend-icon="mdi-fast-forward"
variant="text"
class="text-capitalize"
>
Fast Track
</v-btn>
</div>
</div>
<!-- Fast Track Table -->
<v-card class="pa-4">
<div class="d-flex justify-space-between align-center my-3">
<div class="d-flex align-center">
<span>Show</span>
<v-select
density="compact"
variant="outlined"
:items="[10, 25, 50, 100]"
class="mx-2"
style="width: 90px"
></v-select>
<span>entries</span>
</div>
<v-text-field
label="Search"
variant="outlined"
density="compact"
style="max-width: 200px"
></v-text-field>
</div>
<v-table class="mt-3">
<thead>
<tr>
<th
v-for="header in fastTrackHeaders"
:key="header.text"
>
{{ header.text }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in fastTrackData" :key="index">
<td>{{ item.no }}</td>
<td>{{ item.barcode }}</td>
<td>{{ item.tanggal }}</td>
<td>{{ item.noAntrian }}</td>
<td>{{ item.rm }}</td>
<td>{{ item.shift }}</td>
<td>{{ item.klinik }}</td>
<td>{{ item.pj }}</td>
<td>{{ item.keterangan }}</td>
<td>{{ item.pembayaran }}</td>
<td>{{ item.status }}</td>
<td>
<v-btn color="blue">Fast Track</v-btn>
</td>
</tr>
</tbody>
</v-table>
<div class="d-flex justify-space-between align-center mt-3">
<span>Showing 1 to 10 of 1,434 entries</span>
<v-pagination
v-model="page"
:length="144"
:total-visible="5"
></v-pagination>
</div>
</v-card>
</v-container>
</v-main>
</template>
<script setup>
import { ref } from "vue";
// Reactive data
const search = ref("");
const itemsPerPage = ref(10);
const page = ref(1);
// Table headers
const fastTrackHeaders = [
{ text: 'No' },
{ text: 'Barcode' },
{ text: 'Tanggal / Jam Daftar' },
{ text: 'No Antrian' },
{ text: 'RM' },
{ text: 'Shift' },
{ text: 'Klinik' },
{ text: 'PJ' },
{ text: 'Keterangan' },
{ text: 'Pembayaran' },
{ text: 'Status' },
{ text: 'Aksi' },
];
// Mock data
const fastTrackData = [
{ no: 1, barcode: '250813100928', tanggal: '13-08-2025 / 07:17', noAntrian: 'ON1045', rm: '', shift: 'Shift 1', klinik: 'ONKOLOGI', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
{ no: 2, barcode: '250813100930', tanggal: '13-08-2025 / 07:19', noAntrian: 'GI1018', rm: '', shift: 'Shift 1', klinik: 'GIGI DAN MULUT', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
{ no: 3, barcode: '250813100937', tanggal: '13-08-2025 / 07:19', noAntrian: 'MT1073', rm: '', shift: 'Shift 1', klinik: 'MATA', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
{ no: 4, barcode: '250813100936', tanggal: '13-08-2025 / 07:19', noAntrian: 'ON1047', rm: '', shift: 'Shift 1', klinik: 'ONKOLOGI', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
{ no: 5, barcode: '250813100935', tanggal: '13-08-2025 / 07:18', noAntrian: 'ON1046', rm: '', shift: 'Shift 1', klinik: 'ONKOLOGI', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
{ no: 6, barcode: '250813100934', tanggal: '13-08-2025 / 07:18', noAntrian: 'IP1101', rm: '', shift: 'Shift 1', klinik: 'IPD', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
{ no: 7, barcode: '250813100933', tanggal: '13-08-2025 / 07:18', noAntrian: 'UM1031', rm: '', shift: 'Shift 1', klinik: 'IPD', pj: '', keterangan: '', pembayaran: 'UMUM', status: 'Proses Barcode' },
{ no: 8, barcode: '250813100932', tanggal: '13-08-2025 / 07:17', noAntrian: 'AN1122', rm: '', shift: 'Shift 1', klinik: 'ANAK', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Proses Barcode' },
{ no: 9, barcode: '250813100931', tanggal: '13-08-2025 / 07:17', noAntrian: 'PR1033', rm: '', shift: 'Shift 1', klinik: 'PARU', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Anjungan' },
{ no: 10, barcode: '250813100930', tanggal: '13-08-2025 / 07:17', noAntrian: 'TH1035', rm: '', shift: 'Shift 1', klinik: 'THT', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Anjungan' },
];
</script>

View File

@@ -1,75 +1,93 @@
<template> <template>
<v-divider class="my-8"></v-divider>
<v-main class="bg-grey-lighten-3"> <v-main class="bg-grey-lighten-3">
<v-container> <v-container fluid class="pa-6">
<div class="d-flex align-center justify-space-between mb-4 mt-10">
<h1 class="text-h4">Klinik Admin</h1> <!-- Colored Header with Quota Chip -->
<v-tooltip text="Jumlah Maksimal Bangku Tersedia"> <v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center justify-space-between pa-4">
<div class="d-flex align-center">
<v-icon size="48" color="white" class="mr-4">mdi-account-group-outline</v-icon>
<h1 class="text-h4 font-weight-bold text-white">Klinik Admin</h1>
</div>
<v-tooltip text="Jumlah Maksimal Bangku Tersedia" location="bottom">
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-chip <v-chip
v-bind="props" v-bind="props"
class="text-white" class="text-white px-4 py-2"
color="green-darken-1" color="green-lighten-1"
variant="flat"
rounded="xl"
> >
<v-icon left>mdi-circle-small</v-icon> <v-icon start>mdi-chair-rolling</v-icon>
Max Quota Bangku 0 Max Quota Bangku 0
</v-chip> </v-chip>
</template> </template>
</v-tooltip> </v-tooltip>
</div> </v-card>
<!-- Loket Admin Table --> <!-- Loket Admin Table -->
<v-card class="mb-5 pa-4"> <v-card class="mb-6 pa-6 rounded-xl elevation-2">
<v-card-title class="d-flex justify-space-between align-center"> <v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
Loket Admin Loket Admin
<div> <div>
<v-tooltip text="Panggil 1 Antrian"> <v-btn
<template v-slot:activator="{ props }"> color="green-lighten-1"
<v-btn variant="flat"
v-bind="props" rounded="xl"
color="green" class="text-white elevation-4 mr-2 btn-call-group"
class="mr-2 clickable-btn" @click="handleCallClick(1)"
@click="handleCallClick(1)" >
>1</v-btn <v-icon start>mdi-numeric-1-box</v-icon>
> <span class="d-none d-md-inline">Panggil 1 Antrian</span>
</template> </v-btn>
</v-tooltip> <v-btn
<v-tooltip text="Panggil 5 Antrian"> color="blue-lighten-1"
<template v-slot:activator="{ props }"> variant="flat"
<v-btn rounded="xl"
v-bind="props" class="text-white elevation-4 mr-2 btn-call-group"
color="blue" @click="handleCallClick(5)"
class="mr-2 clickable-btn" >
@click="handleCallClick(5)" <v-icon start>mdi-numeric-5-box</v-icon>
>5</v-btn <span class="d-none d-md-inline">Panggil 5 Antrian</span>
> </v-btn>
</template> <v-btn
</v-tooltip> color="orange-lighten-1"
<v-tooltip text="Panggil 10 Antrian"> variant="flat"
<template v-slot:activator="{ props }"> rounded="xl"
<v-btn class="text-white elevation-4 mr-2 btn-call-group"
v-bind="props" @click="handleCallClick(10)"
color="orange" >
class="mr-2 clickable-btn" <v-icon start>mdi-numeric-10-box</v-icon>
@click="handleCallClick(10)" <span class="d-none d-md-inline">Panggil 10 Antrian</span>
>10</v-btn </v-btn>
> <v-btn
</template> color="red-lighten-1"
</v-tooltip> variant="flat"
<v-tooltip text="Panggil 20 Antrian"> rounded="xl"
<template v-slot:activator="{ props }"> class="text-white elevation-4 btn-call-group"
<v-btn @click="handleCallClick(20)"
v-bind="props" >
color="red" <v-icon start>mdi-numeric-20-box</v-icon>
class="clickable-btn" <span class="d-none d-md-inline">Panggil 20 Antrian</span>
@click="handleCallClick(20)" </v-btn>
>20</v-btn
>
</template>
</v-tooltip>
</div> </div>
</v-card-title> </v-card-title>
<v-table class="mt-3">
<!-- Pilihan Show Entries untuk Loket Admin -->
<div class="d-flex justify-end mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show Entries:</span>
<v-select
:items="[10, 25, 50]"
v-model="itemsPerPageLoket"
variant="outlined"
density="compact"
hide-details
class="show-entries-select"
></v-select>
</div>
</div>
<v-table class="mt-3 custom-table rounded-lg elevation-0">
<thead> <thead>
<tr> <tr>
<th v-for="header in loketHeaders" :key="header.text"> <th v-for="header in loketHeaders" :key="header.text">
@@ -78,7 +96,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(item, index) in loketData" :key="index"> <tr v-for="(item, index) in paginatedLoketData" :key="index">
<td>{{ item.no }}</td> <td>{{ item.no }}</td>
<td>{{ item.barcode }}</td> <td>{{ item.barcode }}</td>
<td>{{ item.noRekamedik }}</td> <td>{{ item.noRekamedik }}</td>
@@ -87,26 +105,56 @@
<td>{{ item.ket }}</td> <td>{{ item.ket }}</td>
<td>{{ item.fastTrack }}</td> <td>{{ item.fastTrack }}</td>
<td>{{ item.pembayaran }}</td> <td>{{ item.pembayaran }}</td>
<td><v-btn size="x-small" color="primary">Panggil</v-btn></td>
<td> <td>
<v-btn size="x-small" color="red">Batalkan</v-btn> <v-btn size="small" color="primary" class="text-white rounded-lg" @click="handlePanggil(item)">
<v-icon start>mdi-phone-incoming</v-icon>
Panggil
</v-btn>
</td>
<td>
<v-btn size="small" color="red-darken-1" class="text-white rounded-lg" @click="handleBatalkan(item)">
<v-icon start>mdi-close</v-icon>
Batalkan
</v-btn>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</v-table> </v-table>
<div class="d-flex justify-space-between align-center mt-3"> <div class="d-flex justify-space-between align-center mt-3 pa-2">
<span>Showing {{ loketData.length > 0 ? 1 : 0 }} to {{ loketData.length }} of {{ loketData.length }} entries</span> <span>
Menampilkan {{ (currentPageLoket - 1) * itemsPerPageLoket + 1 }} hingga
{{ Math.min(currentPageLoket * itemsPerPageLoket, loketData.length) }} dari
{{ loketData.length }} entri
</span>
<div> <div>
<v-btn size="small" variant="text" disabled>Previous</v-btn> <v-btn size="small" variant="flat" :disabled="currentPageLoket === 1" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket - 1)">Previous</v-btn>
<v-btn size="small" variant="text" disabled>Next</v-btn> <v-btn size="small" variant="flat" :disabled="currentPageLoket >= totalPagesLoket" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket + 1)">Next</v-btn>
</div> </div>
</div> </div>
</v-card> </v-card>
<!-- Data Pengunjung Table --> <!-- Data Pengunjung Table -->
<v-card class="pa-4"> <v-card class="pa-6 rounded-xl elevation-2">
<v-card-title>Data Pengunjung: Loket</v-card-title> <v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
<v-table class="mt-3"> Data Pengunjung: Loket
</v-card-title>
<!-- Pilihan Show Entries untuk Data Pengunjung -->
<div class="d-flex justify-end mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show Entries:</span>
<v-select
:items="[10, 25, 50]"
v-model="itemsPerPagePengunjung"
variant="outlined"
density="compact"
hide-details
class="show-entries-select"
></v-select>
</div>
</div>
<v-table class="mt-3 custom-table rounded-lg elevation-0">
<thead> <thead>
<tr> <tr>
<th v-for="header in pengunjungHeaders" :key="header.text"> <th v-for="header in pengunjungHeaders" :key="header.text">
@@ -115,7 +163,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(item, index) in pengunjungData" :key="index"> <tr v-for="(item, index) in paginatedPengunjungData" :key="index">
<td>{{ item.no }}</td> <td>{{ item.no }}</td>
<td>{{ item.barcode }}</td> <td>{{ item.barcode }}</td>
<td>{{ item.noRekamedik }}</td> <td>{{ item.noRekamedik }}</td>
@@ -123,15 +171,23 @@
<td>{{ item.noAntrianKlinik }}</td> <td>{{ item.noAntrianKlinik }}</td>
<td>{{ item.shift }}</td> <td>{{ item.shift }}</td>
<td>{{ item.pembayaran }}</td> <td>{{ item.pembayaran }}</td>
<td>{{ item.status }}</td> <td>
<v-chip :color="item.status === 'Selesai' ? 'green' : 'orange'" size="small">
{{ item.status }}
</v-chip>
</td>
</tr> </tr>
</tbody> </tbody>
</v-table> </v-table>
<div class="d-flex justify-space-between align-center mt-3"> <div class="d-flex justify-space-between align-center mt-3 pa-2">
<span>Showing {{ pengunjungData.length > 0 ? 1 : 0 }} to {{ pengunjungData.length }} of {{ pengunjungData.length }} entries</span> <span>
Menampilkan {{ (currentPagePengunjung - 1) * itemsPerPagePengunjung + 1 }} hingga
{{ Math.min(currentPagePengunjung * itemsPerPagePengunjung, pengunjungData.length) }} dari
{{ pengunjungData.length }} entri
</span>
<div> <div>
<v-btn size="small" variant="text" disabled>Previous</v-btn> <v-btn size="small" variant="flat" :disabled="currentPagePengunjung === 1" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung - 1)">Previous</v-btn>
<v-btn size="small" variant="text" disabled>Next</v-btn> <v-btn size="small" variant="flat" :disabled="currentPagePengunjung >= totalPagesPengunjung" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung + 1)">Next</v-btn>
</div> </div>
</div> </div>
</v-card> </v-card>
@@ -140,55 +196,83 @@
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref, computed } from "vue";
// Sample data for the tables definePageMeta({
const loketData = ref([ middleware:['auth']
{ })
no: 1,
barcode: "1234567890", // Generate dummy data
noRekamedik: "RM001", const generateDummyData = (count) => {
noAntrian: "A001", const data = [];
shift: "Pagi", const shiftOptions = ['Pagi', 'Siang', 'Sore'];
ket: "Normal", const pembayaranOptions = ['BPJS', 'Umum', 'Asuransi'];
fastTrack: "Tidak", const statusOptions = ['Menunggu', 'Selesai', 'Di-cancel'];
pembayaran: "BPJS"
}, for (let i = 1; i <= count; i++) {
{ data.push({
no: 2, no: i,
barcode: "0987654321", barcode: `B${1000 + i}`,
noRekamedik: "RM002", noRekamedik: `RM${100 + i}`,
noAntrian: "A002", noAntrian: `A${100 + i}`,
shift: "Pagi", noAntrianKlinik: `K${100 + i}`,
ket: "Normal", shift: shiftOptions[Math.floor(Math.random() * shiftOptions.length)],
fastTrack: "Ya", ket: "Dummy Data",
pembayaran: "Umum" fastTrack: Math.random() > 0.5 ? "Ya" : "Tidak",
pembayaran: pembayaranOptions[Math.floor(Math.random() * pembayaranOptions.length)],
status: statusOptions[Math.floor(Math.random() * statusOptions.length)]
});
} }
]); return data;
};
const pengunjungData = ref([ // State for Loket Admin table
{ const loketData = ref(generateDummyData(50));
no: 1, const currentPageLoket = ref(1);
barcode: "1234567890", const itemsPerPageLoket = ref(10);
noRekamedik: "RM001",
noAntrian: "A001", // State for Data Pengunjung table
noAntrianKlinik: "K001", const pengunjungData = ref(generateDummyData(50));
shift: "Pagi", const currentPagePengunjung = ref(1);
pembayaran: "BPJS", const itemsPerPagePengunjung = ref(10);
status: "Menunggu"
}, // Computed properties for Loket Admin table
{ const paginatedLoketData = computed(() => {
no: 2, const start = (currentPageLoket.value - 1) * itemsPerPageLoket.value;
barcode: "0987654321", const end = start + itemsPerPageLoket.value;
noRekamedik: "RM002", return loketData.value.slice(start, end);
noAntrian: "A002", });
noAntrianKlinik: "K002",
shift: "Pagi", const totalPagesLoket = computed(() => {
pembayaran: "Umum", return Math.ceil(loketData.value.length / itemsPerPageLoket.value);
status: "Selesai" });
// Computed properties for Data Pengunjung table
const paginatedPengunjungData = computed(() => {
const start = (currentPagePengunjung.value - 1) * itemsPerPagePengunjung.value;
const end = start + itemsPerPagePengunjung.value;
return pengunjungData.value.slice(start, end);
});
const totalPagesPengunjung = computed(() => {
return Math.ceil(pengunjungData.value.length / itemsPerPagePengunjung.value);
});
// Method to handle page change for Loket Admin table
const handlePageChangeLoket = (page) => {
if (page >= 1 && page <= totalPagesLoket.value) {
currentPageLoket.value = page;
} }
]); };
// Method to handle page change for Data Pengunjung table
const handlePageChangePengunjung = (page) => {
if (page >= 1 && page <= totalPagesPengunjung.value) {
currentPagePengunjung.value = page;
}
};
// Methods to handle button clicks (unchanged)
const loketHeaders = [ const loketHeaders = [
{ text: 'No' }, { text: 'No' },
{ text: 'Barcode' }, { text: 'Barcode' },
@@ -200,7 +284,7 @@ const loketHeaders = [
{ text: 'Pembayaran' }, { text: 'Pembayaran' },
{ text: 'Panggil' }, { text: 'Panggil' },
{ text: 'Aksi' }, { text: 'Aksi' },
] ];
const pengunjungHeaders = [ const pengunjungHeaders = [
{ text: 'No' }, { text: 'No' },
@@ -211,22 +295,78 @@ const pengunjungHeaders = [
{ text: 'Shift' }, { text: 'Shift' },
{ text: 'Pembayaran' }, { text: 'Pembayaran' },
{ text: 'Status' }, { text: 'Status' },
] ];
// Methods to handle clicks
const handleCallClick = (value) => { const handleCallClick = (value) => {
console.log(`Panggil ${value} antrian diklik!`); console.log(`Panggil ${value} antrian diklik!`);
// Tambahkan logika untuk memanggil antrian di sini };
const handlePanggil = (item) => {
console.log('Panggil pasien:', item);
};
const handleBatalkan = (item) => {
console.log('Batalkan pasien:', item);
}; };
</script> </script>
<style scoped> <style scoped>
.clickable-btn { /* Main container padding */
cursor: pointer; .v-container {
transition: transform 0.2s ease-in-out; max-width: 1400px;
} }
.clickable-btn:hover { .header-banner {
transform: translateY(-2px); background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
color: white;
}
/* General card styling */
.v-card {
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
/* Call buttons group */
.btn-call-group {
min-width: 50px;
height: 40px !important;
font-weight: bold;
}
/* Table styling */
.custom-table {
border: 1px solid #e0e0e0;
}
.custom-table :deep(th) {
background-color: #f5f5f5;
font-weight: bold;
font-size: 14px;
text-transform: uppercase;
}
.custom-table :deep(tr:hover) {
background-color: #e8eaf6 !important; /* Light blue on hover */
cursor: pointer;
}
.custom-table :deep(tbody tr:nth-of-type(odd)) {
background-color: #fafafa;
}
.pagination-btn {
margin: 0 4px;
background-color: #e0e0e0 !important;
}
/* Custom styling for status chip */
.v-chip.v-chip--size-small {
padding: 4px 8px;
font-size: 0.75rem;
}
.show-entries-select {
max-width: 100px;
} }
</style> </style>

View File

@@ -1,83 +1,121 @@
<template> <template>
<v-divider class="my-8"></v-divider>
<v-main class="bg-grey-lighten-3"> <v-main class="bg-grey-lighten-3">
<v-container> <v-container fluid class="pa-6">
<!-- Klinik Ruang Admin Content -->
<h1 class="text-h4 mb-4 mt-10">Klinik Ruang Admin</h1> <!-- Colored Header Banner -->
<v-card class="pa-4 mb-4"> <v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
<v-card-title>GENERATE TIKET</v-card-title> <v-icon size="48" color="white" class="mr-4">mdi-clipboard-list-outline</v-icon>
<div class="d-flex align-center"> <h1 class="text-h4 font-weight-bold text-white">Klinik Ruang Admin</h1>
<v-text-field
label="Masukkan Barcode"
variant="outlined"
density="compact"
class="mr-4"
></v-text-field>
<v-col cols="12 " md="6">
<v-chip color="#B71C1C" class="text-caption">
Tekan Enter: (Apabila barcode depan nomor ada huruf lain, ex: J008730180085 "hiraukan huruf 'J' nya")
</v-chip>
</v-col>
</div>
</v-card> </v-card>
<v-card class="pa-4"> <!-- Main Content Area -->
<v-card-title>Pasien Klinik Ruang Admin</v-card-title> <v-row>
<div class="d-flex justify-space-between align-center my-3"> <v-col cols="12">
<div class="d-flex align-center"> <v-card class="pa-6 rounded-xl elevation-2 mb-4">
<span>Show</span> <v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
<v-select GENERATE TIKET
density="compact" </v-card-title>
variant="outlined" <div class="d-flex align-center">
:items="[10, 25, 50, 100]" <v-text-field
class="mx-2" label="Masukkan Barcode"
style="width: 80px" variant="solo"
></v-select> density="compact"
<span>entries</span> hide-details
</div> flat
<v-text-field class="mr-4 barcode-input"
label="Search" v-model="barcodeInput"
variant="outlined" @keyup.enter="generateTicket"
density="compact" ></v-text-field>
style="max-width: 200px" <v-chip color="#B71C1C" class="text-caption font-weight-bold text-white chip-warning">
></v-text-field> Tekan Enter: (Apabila barcode depan nomor ada huruf lain, ex: J008730180085 "hiraukan huruf 'J' nya")
</div> </v-chip>
</div>
</v-card>
<v-table class="mt-3"> <v-card class="pa-6 rounded-xl elevation-2">
<thead> <v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
<tr> Pasien Klinik Ruang Admin
<th </v-card-title>
v-for="header in klinikRuangAdminHeaders"
:key="header.text" <div class="d-flex justify-space-between align-center my-3">
> <div class="d-flex align-center">
{{ header.text }} <span>Show</span>
</th> <v-select
</tr> density="compact"
</thead> variant="solo"
<tbody> flat
<tr> :items="[10, 25, 50, 100]"
<td :colspan="klinikRuangAdminHeaders.length" class="text-center"> class="mx-2 select-items"
No data available in table hide-details
</td> v-model="itemsPerPage"
</tr> ></v-select>
</tbody> <span>entries</span>
</v-table> </div>
<v-text-field
label="Search"
variant="solo"
density="compact"
flat
hide-details
append-inner-icon="mdi-magnify"
style="max-width: 200px"
v-model="searchQuery"
></v-text-field>
</div>
<div class="d-flex justify-space-between align-center mt-3"> <v-table class="mt-3 custom-table">
<span>Showing 0 to 0 of 0 entries</span> <thead>
<div> <tr>
<v-btn size="small" variant="text" disabled>Previous</v-btn> <th v-for="header in klinikRuangAdminHeaders" :key="header.text">
<v-btn size="small" variant="text" disabled>Next</v-btn> {{ header.text }}
</div> </th>
</div> </tr>
</v-card> </thead>
<tbody>
<tr v-if="paginatedItems.length === 0">
<td :colspan="klinikRuangAdminHeaders.length" class="text-center text-grey">
Tidak ada data yang tersedia
</td>
</tr>
<tr v-for="(item, index) in paginatedItems" :key="item.no">
<td>{{ (currentPage - 1) * itemsPerPage + index + 1 }}</td>
<td>{{ item.barcode }}</td>
<td>{{ item.noRekamedik }}</td>
<td>{{ item.noAntrian }}</td>
<td>{{ item.noAntrianKlinik }}</td>
<td>{{ item.noAntrianRuang }}</td>
<td>{{ item.shift }}</td>
<td>{{ item.pembayaran }}</td>
<td>
<v-btn color="success" size="small" class="text-white">Action</v-btn>
</td>
<td>{{ item.status }}</td>
</tr>
</tbody>
</v-table>
<div class="d-flex justify-space-between align-center mt-3 pa-2">
<span>Showing {{ showingFrom }} to {{ showingTo }} of {{ filteredItems.length }} entries</span>
<div class="d-flex">
<v-btn size="small" variant="flat" :disabled="currentPage === 1" @click="prevPage" class="pagination-btn">Previous</v-btn>
<v-btn size="small" variant="flat" :disabled="currentPage === totalPages" @click="nextPage" class="pagination-btn ml-2">Next</v-btn>
</div>
</div>
</v-card>
</v-col>
</v-row>
</v-container> </v-container>
</v-main> </v-main>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref, computed, watch } from "vue";
definePageMeta({
middleware:['auth']
})
// === Data Dummy untuk Tabel ===
const klinikRuangAdminHeaders = [ const klinikRuangAdminHeaders = [
{ text: 'No' }, { text: 'No' },
{ text: 'Barcode' }, { text: 'Barcode' },
@@ -89,9 +127,167 @@ const klinikRuangAdminHeaders = [
{ text: 'Pembayaran' }, { text: 'Pembayaran' },
{ text: 'Action' }, { text: 'Action' },
{ text: 'Status' }, { text: 'Status' },
] ];
const allItems = ref([
{ no: 1, barcode: '008730180085', noRekamedik: 'RM001', noAntrian: 'A001', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Selesai' },
{ no: 2, barcode: '008730180086', noRekamedik: 'RM002', noAntrian: 'A002', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'BPJS', status: 'Proses' },
{ no: 3, barcode: '008730180087', noRekamedik: 'RM003', noAntrian: 'A003', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'Tunai', status: 'Menunggu' },
{ no: 4, barcode: '008730180088', noRekamedik: 'RM004', noAntrian: 'A004', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Selesai' },
{ no: 5, barcode: '008730180089', noRekamedik: 'RM005', noAntrian: 'A005', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'BPJS', status: 'Proses' },
{ no: 6, barcode: '008730180090', noRekamedik: 'RM006', noAntrian: 'A006', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Menunggu' },
{ no: 7, barcode: '008730180091', noRekamedik: 'RM007', noAntrian: 'A007', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'BPJS', status: 'Selesai' },
{ no: 8, barcode: '008730180092', noRekamedik: 'RM008', noAntrian: 'A008', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'Tunai', status: 'Proses' },
{ no: 9, barcode: '008730180093', noRekamedik: 'RM009', noAntrian: 'A009', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Menunggu' },
{ no: 10, barcode: '008730180094', noRekamedik: 'RM010', noAntrian: 'A010', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'BPJS', status: 'Selesai' },
{ no: 11, barcode: '008730180095', noRekamedik: 'RM011', noAntrian: 'A011', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Proses' },
]);
// === State untuk Paginasi dan Pencarian ===
const itemsPerPage = ref(10);
const currentPage = ref(1);
const searchQuery = ref('');
const barcodeInput = ref('');
// === Computed Properties untuk Filter dan Paginasi ===
const filteredItems = computed(() => {
if (!searchQuery.value) {
return allItems.value;
}
const searchLower = searchQuery.value.toLowerCase();
return allItems.value.filter(item => {
return Object.values(item).some(value =>
String(value).toLowerCase().includes(searchLower)
);
});
});
const paginatedItems = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
const end = start + itemsPerPage.value;
return filteredItems.value.slice(start, end);
});
const totalPages = computed(() => {
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
});
const showingFrom = computed(() => {
if (filteredItems.value.length === 0) return 0;
return (currentPage.value - 1) * itemsPerPage.value + 1;
});
const showingTo = computed(() => {
const end = currentPage.value * itemsPerPage.value;
return Math.min(end, filteredItems.value.length);
});
// === Fungsi untuk Paginasi dan Pencarian ===
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};
const generateTicket = () => {
// Logika untuk menambahkan data ke tabel
if (barcodeInput.value) {
// Hapus karakter non-digit jika ada
const cleanedBarcode = barcodeInput.value.replace(/\D/g, '');
const newNo = allItems.value.length + 1;
const newItem = {
no: newNo,
barcode: cleanedBarcode,
noRekamedik: `RM${String(newNo).padStart(3, '0')}`,
noAntrian: `A${String(newNo).padStart(3, '0')}`,
noAntrianKlinik: 'K1',
noAntrianRuang: 'R1',
shift: 'Pagi',
pembayaran: 'Tunai',
status: 'Menunggu'
};
allItems.value.unshift(newItem); // Tambahkan item baru di paling depan
barcodeInput.value = ''; // Reset input
currentPage.value = 1; // Kembali ke halaman pertama setelah menambahkan data
}
};
// === Watcher untuk mereset halaman saat filter atau items per page berubah ===
watch([searchQuery, itemsPerPage], () => {
currentPage.value = 1;
});
</script> </script>
<style scoped> <style scoped>
/* Scoped styles can be added here if needed for this specific component */ /* Scoped styles to make the page more lively */
.v-container {
max-width: 1400px;
}
/* .header-banner {
background: linear-gradient(45deg, #42a5f5, #1565c0); /* Blue gradient */
/* color: white;
padding: 24px;
} */
.header-banner {
background: linear-gradient(45deg, #1A237E, #283593); /* Dark Blue gradient */
color: white;
padding: 24px;
}
.barcode-input .v-field--variant-solo {
background-color: #e0e0e0;
}
.chip-warning {
border-radius: 8px;
padding: 8px 12px;
}
.select-items {
max-width: 80px;
}
.select-items .v-field--variant-solo {
background-color: #e0e0e0;
}
.custom-table :deep(th) {
background-color: #e0e0e0;
font-weight: bold;
}
.custom-table :deep(tr) {
background-color: #f8f8f8;
}
.custom-table :deep(tbody tr:nth-of-type(odd)) {
background-color: #f1f1f1;
}
.pagination-btn {
margin: 0 4px;
background-color: #ffb38a !important;
}
.next-queue-card {
background: linear-gradient(135deg, #00A896, #00796B); /* Teal gradient */
}
.current-queue-number {
background-color: white;
border: 4px solid #00A896;
}
.text-primary {
color: #00A896 !important;
}
</style> </style>

868
pages/LoginPage.vue Normal file
View File

@@ -0,0 +1,868 @@
<!-- // 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>
<div class="floating-medical-icon icon-2">
<v-icon size="108">mdi-medical-bag</v-icon>
</div>
<div class="floating-medical-icon icon-3">
<v-icon size="126">mdi-stethoscope</v-icon>
</div>
<div class="floating-medical-icon icon-4">
<v-icon size="120">mdi-hospital-box</v-icon>
</div>
<div class="floating-medical-icon icon-5">
<v-icon size="96">mdi-pill</v-icon>
</div>
<div class="floating-medical-icon icon-6">
<v-icon size="114">mdi-bandage</v-icon>
</div>
<v-row class="fill-height align-center justify-center">
<!-- Left Content -->
<v-col cols="12" md="6" class="text-section">
<div class="hero-content">
<div class="logo-section mb-6">
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
class="mt-3 hospital-logo"
/>
<h1 class="hero-title">Sistem Terbaik</h1>
<h1 class="hero-title">Untuk Pelayanan</h1>
<h1 class="hero-title">Kesehatan</h1>
</div>
<p class="hero-description">
Tingkatkan efisiensi layanan rumah sakit dengan sistem antrean RSSA yang canggih dan intuitif.
Dirancang dengan kesederhanaan, keamanan, dan kecepatan, memastikan perjalanan
Anda ke platform kami semudah mungkin. Mari kita buat inovasi menjadi sederhana!
</p>
</div>
</v-col>
<!-- Right Login Card -->
<v-col cols="12" md="6" class="d-flex justify-center">
<v-card class="login-card white-card rounded-xl pa-8" max-width="450" width="100%">
<!-- Header -->
<div class="text-center mb-6">
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
</div>
<!-- Logo Section -->
<div class="d-flex flex-column align-center text-center mb-6">
<span class="text-h5 font-weight-bold app-title-dark text-blue-darken-3">Antrean RSSA</span>
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
class="mt-3 hospital-logo"
/>
</div>
<!-- Alert Messages -->
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
{{ errorMessage }}
</v-alert>
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
<!-- SSO Info -->
<div class="text-center mb-6">
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
</div>
<!-- Keycloak Login Button -->
<v-btn
@click="handleLogin"
class="login-btn"
block
rounded="lg"
size="large"
:loading="isLoading"
:disabled="isLoading"
>
<v-icon left>mdi-shield-key</v-icon>
<span class="font-weight-bold">
{{ isLoading ? 'Connecting to Keycloak...' : 'Login dengan Keycloak' }}
</span>
<v-icon right>mdi-arrow-right</v-icon>
</v-btn>
<!-- Registration Section -->
<v-divider class="my-6 custom-divider-dark"></v-divider>
<div class="text-center">
<v-btn
@click="showRegistrationDialog = true"
class="register-btn-dark"
variant="outlined"
block
rounded="lg"
size="large"
>
<v-icon left>mdi-account-plus</v-icon>
<span class="font-weight-bold">Daftar Akun Baru</span>
</v-btn>
<div class="text-center mt-4">
<span class="help-text-dark">
Belum memiliki akun?
</span>
<br>
<v-btn
@click="showAdminContact = true"
variant="text"
color="#0053AD"
size="small"
class="contact-link-dark mt-1"
>
Hubungi Administrator
</v-btn>
</div>
</div>
<!-- Password Help -->
<div class="text-center mt-4">
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
</div>
</v-card>
</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>
<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>
<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-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-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-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
@click="showRegistrationDialog = false"
color="grey"
variant="outlined"
rounded
>
Tutup
</v-btn>
<v-btn
@click="showRegistrationDialog = false; showAdminContact = true"
color="#0053AD"
rounded
>
<v-icon left>mdi-phone</v-icon>
Hubungi Admin
</v-btn>
</v-card-actions>
</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>
<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-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>
<div>
<v-list-item-title class="text-grey-darken-2 font-weight-bold">
IT Support RSSA
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Bagian Teknologi Informasi
</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>
<div>
<v-list-item-title class="text-grey-darken-2">
(0341) 343343 ext. 1234
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Telepon Internal
</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>
<div>
<v-list-item-title class="text-grey-darken-2">
it-support@rssa.malang.go.id
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Email Resmi
</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>
<div>
<v-list-item-title class="text-grey-darken-2">
Senin - Jumat: 07:00 - 15:00
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Jam Operasional
</v-list-item-subtitle>
</div>
</v-list-item>
</v-list>
<v-alert
type="info"
variant="tonal"
class="mt-4"
color="blue"
>
<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
@click="showAdminContact = false"
color="grey"
variant="outlined"
rounded
>
Tutup
</v-btn>
<v-btn
@click="copyContactInfo"
color="#0053AD"
rounded
>
<v-icon left>mdi-content-copy</v-icon>
Salin Info
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import type { LoginResponse } from '~/types/auth'
// Use guest middleware to redirect if already authenticated
definePageMeta({
layout: 'empty',
middleware:['auth','guest']
})
// Reactive state
const isLoading = ref<boolean>(false)
const errorMessage = ref<string>('')
const successMessage = ref<string>('')
const showRegistrationDialog = ref<boolean>(false)
const showAdminContact = ref<boolean>(false)
// Check URL parameters for errors
const route = useRoute()
onMounted(() => {
if (route.query.error) {
errorMessage.value = decodeURIComponent(route.query.error as string)
}
})
// Custom login handler
const handleLogin = async (): Promise<void> => {
isLoading.value = true
errorMessage.value = ''
successMessage.value = ''
try {
console.log('Starting login process...')
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
method: 'POST'
})
if (response?.success && response?.data?.authUrl) {
console.log('Redirecting to Keycloak...')
successMessage.value = 'Redirecting to Keycloak...'
setTimeout(() => {
window.location.href = response.data!.authUrl
}, 500)
} else {
throw new Error('Failed to get authorization URL')
}
} catch (error: any) {
console.error('Login error:', error)
errorMessage.value = `Login failed: ${error.message || 'Please try again.'}`
} finally {
isLoading.value = false
}
}
// Copy contact information to clipboard
const copyContactInfo = async (): Promise<void> => {
const contactInfo = `
IT Support RSSA
Telepon: (0341) 343343 ext. 1234
Email: it-support@rssa.malang.go.id
Jam Operasional: Senin - Jumat, 08:00 - 16:00
`.trim()
try {
await navigator.clipboard.writeText(contactInfo)
successMessage.value = 'Informasi kontak berhasil disalin!'
showAdminContact.value = false
setTimeout(() => {
successMessage.value = ''
}, 3000)
} catch (error) {
console.error('Failed to copy contact info:', error)
}
}
</script>
<style scoped>
/* Main Background */
.login-background {
background: linear-gradient(135deg, #f1b464 0%, #faa22e 25%, #e49458 50%, #e46f30 75%, #e26450 100%);
min-height: 100vh;
position: relative;
overflow: hidden;
}
/* Navigation Bar */
.navbar {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.brand-logo {
color: white;
font-size: 1.2rem;
}
.nav-link {
color: white !important;
text-transform: none;
font-weight: 500;
}
/* Floating Medical Icons */
.floating-medical-icon {
position: absolute;
animation: dvdBounce 20s linear infinite;
opacity: 0.8;
transition: opacity 0.3s ease;
z-index: 1;
width: fit-content;
height: fit-content;
}
.floating-medical-icon:hover {
opacity: 1;
}
.floating-medical-icon .v-icon {
color: rgba(255, 255, 255, 0.15) !important;
}
.icon-1 {
top: 0%;
left: 0%;
animation-delay: 0s;
animation-duration: 25s;
animation-name: dvdBounce1;
}
.icon-1 .v-icon {
color: rgba(6, 37, 83, 0.15) !important;
}
.icon-2 {
top: 0%;
right: 0%;
animation-delay: 0s;
animation-duration: 30s;
animation-name: dvdBounce2;
}
.icon-2 .v-icon {
color: rgba(15, 180, 70, 0.12) !important;
}
.icon-3 {
bottom: 0%;
left: 0%;
animation-delay: 0s;
animation-duration: 22s;
animation-name: dvdBounce3;
}
.icon-3 .v-icon {
color: rgba(23, 178, 206, 0.18) !important;
}
.icon-4 {
top: 50%;
left: 0%;
animation-delay: 0s;
animation-duration: 28s;
animation-name: dvdBounce4;
}
.icon-4 .v-icon {
color: rgba(223, 8, 8, 0.14) !important;
}
.icon-5 {
bottom: 0%;
right: 0%;
animation-delay: 0s;
animation-duration: 26s;
animation-name: dvdBounce5;
}
.icon-5 .v-icon {
color: rgba(148, 15, 236, 0.16) !important;
}
.icon-6 {
top: 25%;
left: 0%;
animation-delay: 0s;
animation-duration: 24s;
animation-name: dvdBounce6;
}
.icon-6 .v-icon {
color: rgba(87, 48, 5, 0.13) !important;
}
/* DVD Bounce Animation 1 - Top Left to Bottom Right */
@keyframes dvdBounce1 {
0% { transform: translate(0, 0); }
25% { transform: translate(80vw, 70vh); }
50% { transform: translate(20vw, 10vh); }
75% { transform: translate(70vw, 80vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 2 - Top Right to Bottom Left */
@keyframes dvdBounce2 {
0% { transform: translate(0, 0); }
25% { transform: translate(-75vw, 60vh); }
50% { transform: translate(-30vw, 20vh); }
75% { transform: translate(-85vw, 75vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 3 - Bottom Left to Top Right */
@keyframes dvdBounce3 {
0% { transform: translate(0, 0); }
25% { transform: translate(70vw, -60vh); }
50% { transform: translate(40vw, -80vh); }
75% { transform: translate(90vw, -30vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 4 - Middle Left across screen */
@keyframes dvdBounce4 {
0% { transform: translate(0, 0); }
16.6% { transform: translate(60vw, -30vh); }
33.3% { transform: translate(90vw, 20vh); }
50% { transform: translate(50vw, 40vh); }
66.6% { transform: translate(10vw, -20vh); }
83.3% { transform: translate(80vw, -40vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 5 - Bottom Right to Top Left */
@keyframes dvdBounce5 {
0% { transform: translate(0, 0); }
25% { transform: translate(-60vw, -70vh); }
50% { transform: translate(-90vw, -20vh); }
75% { transform: translate(-40vw, -80vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 6 - Complex zigzag pattern */
@keyframes dvdBounce6 {
0% { transform: translate(0, 0); }
14.3% { transform: translate(50vw, 30vh); }
28.6% { transform: translate(85vw, -20vh); }
42.9% { transform: translate(30vw, 60vh); }
57.1% { transform: translate(70vw, 10vh); }
71.4% { transform: translate(15vw, 70vh); }
85.7% { transform: translate(80vw, 40vh); }
100% { transform: translate(0, 0); }
}
/* Hero Section */
.text-section {
padding-left: 4rem;
z-index: 2;
}
.hero-content {
max-width: 600px;
}
.hero-title {
color: white;
font-size: 3rem;
font-weight: 900;
line-height: 1.1;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.hero-description {
color: rgba(255, 255, 255, 0.9);
font-size: 1.1rem;
line-height: 1.6;
margin-top: 2rem;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
}
/* Login Card - White Background */
.login-card {
z-index: 3;
max-width: 450px;
margin: 2rem;
}
.white-card {
background: white !important;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 20px 50px rgba(0, 0, 0, 0.15),
0 8px 25px rgba(0, 0, 0, 0.1);
}
/* White Dialog Styles */
.white-dialog {
background: white !important;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
}
/* Card Header - Dark Text */
.welcome-title-dark {
color: #37474F;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.login-instruction-dark {
color: #546E7A;
font-size: 0.8rem;
letter-spacing: 0.3px;
}
/* App Title - Dark */
.app-title-dark {
color: #0053AD;
text-shadow: none;
}
.hospital-logo {
height: 64px;
width: auto;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
}
.sso-text-dark {
color: #546E7A;
font-size: 0.95rem;
font-weight: 500;
}
/* Buttons */
.login-btn {
background: linear-gradient(135deg, #0053AD 0%, #0663C7 50%, #0671E0 100%) !important;
color: white !important;
border: none;
box-shadow:
0 8px 25px rgba(0, 83, 173, 0.3),
0 4px 12px rgba(0, 83, 173, 0.2);
transition: all 0.3s ease;
text-transform: none;
font-size: 1rem;
height: 56px;
}
.login-btn:hover {
transform: translateY(-2px);
background: linear-gradient(135deg, #004A9B 0%, #0558B0 50%, #0661CA 100%) !important;
box-shadow:
0 12px 30px rgba(0, 83, 173, 0.4),
0 6px 15px rgba(0, 83, 173, 0.3);
}
.register-btn-dark {
color: #0053AD !important;
border: 2px solid #0053AD !important;
background: transparent !important;
transition: all 0.3s ease;
text-transform: none;
height: 48px;
}
.register-btn-dark:hover {
background: rgba(0, 83, 173, 0.05) !important;
border-color: #0663C7 !important;
transform: translateY(-1px);
}
/* Custom Divider - Dark */
.custom-divider-dark {
border-color: rgba(0, 0, 0, 0.12) !important;
opacity: 1 !important;
}
/* Help Text and Links - Dark */
.help-text-dark {
color: #546E7A;
font-size: 0.9rem;
}
.contact-link-dark {
color: #0053AD !important;
text-decoration: underline;
text-transform: none;
font-size: 0.9rem;
}
.help-link-dark {
color: #78909C;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.help-link-dark:hover {
color: #0053AD;
}
/* Transparent Background */
.transparent {
background: transparent !important;
}
/* Navigation Dropdown Styles */
.nav-dropdown {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
min-width: 220px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dropdown-item {
color: #FF9B1B;
transition: all 0.3s ease;
border-radius: 8px;
margin: 4px 8px;
padding: 8px 12px;
}
.dropdown-item:hover {
background: linear-gradient(135deg, #FF9B1B 0%, #FF8F00 100%);
color: white;
transform: translateX(4px);
}
.dropdown-item .v-icon {
transition: all 0.3s ease;
}
.dropdown-item:hover .v-icon {
transform: scale(1.1);
}
.nav-link {
text-transform: none;
font-weight: 500;
transition: all 0.3s ease;
}
.nav-link:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Responsive Design */
@media (max-width: 960px) {
.text-section {
padding-left: 2rem;
text-align: center;
margin-bottom: 2rem;
}
.hero-title {
font-size: 2.5rem;
}
.hero-description {
font-size: 1rem;
}
.login-card {
margin: 1rem;
}
}
@media (max-width: 600px) {
.hero-title {
font-size: 2rem;
}
.text-section {
padding-left: 1rem;
}
}
</style>

View File

@@ -1,89 +1,118 @@
<template> <template>
<!-- Main Content --> <!-- Main Content -->
<v-main> <v-main class="bg-grey-lighten-3">
<v-container fluid class="pa-4 main-content-padding"> <v-container fluid class="pa-6 main-content-padding">
<!-- Header Stats --> <!-- Header Banner & Stats -->
<div class="d-flex justify-space-between align-center mb-4"> <v-card class="d-flex justify-space-between align-center pa-5 rounded-xl elevation-4 mb-6 header-banner">
<div class="d-flex align-center"> <div class="d-flex align-center">
<span class="text-h6 mr-4">Total 0</span> <v-icon size="40" class="mr-3 text-white">mdi-hospital-box-outline</v-icon>
<span class="text-body-2">Max 150 Pasien</span> <span class="text-h4 font-weight-bold text-white">Loket Admin </span>
</div> </div>
<div class="d-flex align-center"> <div class="d-flex align-center text-white text-end flex-wrap justify-end">
<span class="mr-4">Dashboard</span> <span class="mr-4">Loket 24</span>
<span class="mr-4">Loket 24 | Senin, 11 Agustus 2025</span> <span class="mr-4">{{ currentDateLongFormatted }}</span>
<span class="mr-4">11 Agustus 2025 - Pelayanan</span> <span>{{ currentDateShortFormatted }} - Pelayanan</span>
</div> </div>
</div> </v-card>
<!-- Status Cards --> <!-- Status Cards Section -->
<v-card class="pa-5 mb-5" color="white" flat></v-card> <v-row class="mb-6">
<v-row align="center"> <!-- Panggil 1 Antrian Card -->
<v-col cols="12" md="1"> <v-col cols="12" sm="6" md="3">
<v-card color="green" dark class="text-center clickable-card" @click="handleStatusCardClick('1')"> <v-card
<v-card-text> class="pa-4 rounded-xl elevation-2 text-center"
<div class="text-h4">1</div> color="#4CAF50"
@click="handleStatusCardClick(1)"
>
<v-card-text class="text-white">
<div class="text-h4 font-weight-bold">1</div>
<div class="text-subtitle-1 mt-1">Panggil</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
<v-col cols="12" md="1"> <!-- Panggil 5 Antrian Card -->
<v-card color="blue" dark class="text-center clickable-card" @click="handleStatusCardClick('5')"> <v-col cols="12" sm="6" md="3">
<v-card-text> <v-card
<div class="text-h4">5</div> class="pa-4 rounded-xl elevation-2 text-center"
color="#4CAF50"
@click="handleStatusCardClick(5)"
>
<v-card-text class="text-white">
<div class="text-h4 font-weight-bold">5</div>
<div class="text-subtitle-1 mt-1">Panggil</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
<v-col cols="12" md="1"> <!-- Panggil 10 Antrian Card -->
<v-card color="orange" dark class="text-center clickable-card" @click="handleStatusCardClick('10')"> <v-col cols="12" sm="6" md="3">
<v-card-text> <v-card
<div class="text-h4">10</div> class="pa-4 rounded-xl elevation-2 text-center"
color="#4CAF50"
@click="handleStatusCardClick(10)"
>
<v-card-text class="text-white">
<div class="text-h4 font-weight-bold">10</div>
<div class="text-subtitle-1 mt-1">Panggil</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
<v-col cols="12" md="1"> <!-- Panggil 20 Antrian Card -->
<v-card color="red" dark class="text-center clickable-card" @click="handleStatusCardClick('20')"> <v-col cols="12" sm="6" md="3">
<v-card-text> <v-card
<div class="text-h4">20</div> class="pa-4 rounded-xl elevation-2 text-center"
color="#4CAF50"
@click="handleStatusCardClick(20)"
>
<v-card-text class="text-white">
<div class="text-h4 font-weight-bold">20</div>
<div class="text-subtitle-1 mt-1">Panggil</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
</v-row> </v-row>
<!-- Next Patient Card --> <!-- Next Patient Card -->
<v-col cols="12" md="4"> <v-card class="next-patient-card d-flex align-center justify-center pa-8 text-center rounded-xl elevation-6 mb-6">
<v-card color="green" dark class="mb-4 clickable-card" @click="handleNextPatientClick"> <div class="text-center">
<v-card-text class="text-center"> <div class="text-h4 text-white">NEXT PATIENT</div>
<div class="text-h4 mb-2">NEXT</div> <div class="text-h2 font-weight-bold text-white mt-2">UM1001</div>
<div class="text-h6 mb-1">Pasien : UM1001</div> <v-btn
<div class="text-body-2"> size="large"
Klik untuk memanggil pasien selanjutnya color="#00A896"
</div> class="mt-4 text-white"
</v-card-text> @click="handleNextPatientClick"
</v-card> >
</v-col> <v-icon start>mdi-arrow-right-circle</v-icon>
Panggil Pasien Selanjutnya
</v-btn>
</div>
</v-card>
<!-- Main Data Table --> <!-- Main Data Table -->
<v-card class="mb-4"> <v-card class="mb-6 pa-6 rounded-xl elevation-2">
<v-card-title class="d-flex justify-space-between align-center"> <v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
<span>Data Pasien</span> Data Pasien
<div class="d-flex align-center"> <div class="d-flex align-center">
<span class="mr-2">Show</span> <span class="mr-2 text-caption">Show</span>
<v-select <v-select
v-model="itemsPerPage" v-model="itemsPerPage"
:items="[10, 25, 50, 100]" :items="[10, 25, 50, 100]"
density="compact" density="compact"
variant="outlined" variant="solo"
style="max-width: 80px" flat
class="mr-4" hide-details
class="mx-2 select-items"
></v-select> ></v-select>
<span class="mr-2">entries</span> <span class="mr-2 text-caption">entries</span>
<span class="mr-4">Search:</span>
<v-text-field <v-text-field
v-model="search" v-model="search"
density="compact" density="compact"
variant="outlined" variant="solo"
style="max-width: 200px" flat
hide-details hide-details
append-inner-icon="mdi-magnify"
label="Search"
style="max-width: 200px"
></v-text-field> ></v-text-field>
</div> </div>
</v-card-title> </v-card-title>
@@ -92,57 +121,88 @@
:items="mainPatients" :items="mainPatients"
:search="search" :search="search"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
class="elevation-1" :row-class="getRowClass"
class="custom-table"
> >
<!-- Custom template for the 'aksi' column -->
<template v-slot:item.aksi="{ item }"> <template v-slot:item.aksi="{ item }">
<div class="d-flex ga-1"> <div class="d-flex ga-1">
<v-btn size="small" color="success" variant="flat" <!-- Show different buttons based on the item's status -->
>Panggil</v-btn <template v-if="item.status === 'dipanggil'">
> <v-btn size="small" color="primary" variant="flat" class="rounded-lg" @click="handleProsesClick(item)">
<v-btn size="small" color="info" variant="flat">Cancel</v-btn> <v-icon start>mdi-cogs</v-icon>Proses
<v-btn size="small" color="primary" variant="flat" </v-btn>
>Selesai</v-btn <v-btn size="small" color="success" variant="flat" class="rounded-lg">
> <v-icon start>mdi-check-circle-outline</v-icon>Selesai
</v-btn>
</template>
<template v-else-if="item.status === 'dalam_proses'">
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
</v-btn>
<v-btn size="small" color="warning" variant="flat" class="rounded-lg">
<v-icon start>mdi-pause-circle-outline</v-icon>Tunda
</v-btn>
<v-btn size="small" color="error" variant="flat" class="rounded-lg">
<v-icon start>mdi-close-circle-outline</v-icon>Batal
</v-btn>
</template>
<template v-else>
<v-btn size="small" color="primary" variant="flat" class="rounded-lg">
<v-icon start>mdi-cogs</v-icon>Proses
</v-btn>
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
</v-btn>
</template>
</div> </div>
</template> </template>
<template v-slot:item.jamPanggil="{ item }"> <!-- Custom template for the 'panggil' column -->
<span :class="getRowClass(item)">{{ item.jamPanggil }}</span> <template v-slot:item.panggil="{ item }">
<v-btn
size="small"
:color="item.status === 'dalam_proses' ? 'grey' : 'info'"
variant="flat"
class="rounded-lg"
@click="handlePanggilClick(item)"
:disabled="item.status === 'dalam_proses'"
>
<v-icon start>mdi-phone</v-icon>Panggil
</v-btn>
</template>
<!-- Custom template for the 'noAntrian' column -->
<template v-slot:item.noAntrian="{ item }">
<span :class="{'online-antrian': item.status === 'dipanggil'}">{{ item.noAntrian }}</span>
</template> </template>
</v-data-table> </v-data-table>
</v-card> </v-card>
<!-- Total Quota Used -->
<v-card color="cyan" dark class="mb-4">
<v-card-text class="text-center">
<div class="text-h6">Total Quota Terpakai 5</div>
</v-card-text>
</v-card>
<!-- Late Patients Table --> <!-- Late Patients Table -->
<v-card class="mb-4"> <v-card class="mb-6 pa-6 rounded-xl elevation-2">
<v-card-title class="d-flex justify-space-between align-center"> <v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
<span>Info Pasien Lapor Terlambat</span> Info Pasien Lapor Terlambat
<div class="d-flex align-center"> <div class="d-flex align-center">
<span class="mr-2 text-caption text-orange" <span class="mr-2 text-caption text-orange">KETERANGAN: PASIEN MASUK PADA TANGGAL</span>
>KETERANGAN: PASIEN MASUK PADA TANGGAL</span <span class="mr-2 text-caption">Show</span>
>
<span class="mr-2">Show</span>
<v-select <v-select
v-model="lateItemsPerPage" v-model="lateItemsPerPage"
:items="[10, 25, 50, 100]" :items="[10, 25, 50, 100]"
density="compact" density="compact"
variant="outlined" variant="solo"
style="max-width: 80px" flat
class="mr-4" hide-details
class="mx-2 select-items"
></v-select> ></v-select>
<span class="mr-2">entries</span> <span class="mr-2 text-caption">entries</span>
<span class="mr-4">Search:</span>
<v-text-field <v-text-field
v-model="lateSearch" v-model="lateSearch"
density="compact" density="compact"
variant="outlined" variant="solo"
style="max-width: 200px" flat
hide-details hide-details
append-inner-icon="mdi-magnify"
label="Search"
style="max-width: 200px"
></v-text-field> ></v-text-field>
</div> </div>
</v-card-title> </v-card-title>
@@ -151,48 +211,50 @@
:items="latePatients" :items="latePatients"
:search="lateSearch" :search="lateSearch"
:items-per-page="lateItemsPerPage" :items-per-page="lateItemsPerPage"
class="elevation-1" class="custom-table"
> >
<template v-slot:no-data> <template v-slot:no-data>
<div class="text-center pa-4">No data available in table</div> <div class="text-center pa-4">Tidak ada data yang tersedia</div>
</template> </template>
</v-data-table> </v-data-table>
</v-card> </v-card>
<!-- Clinic Entry Patients Table --> <!-- Clinic Entry Patients Table -->
<v-card> <v-card class="mb-6 pa-6 rounded-xl elevation-2">
<v-card-title class="d-flex justify-space-between align-center"> <v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
<span>Info Pasien Masuk Klinik</span> Info Pasien Masuk Klinik
<div class="d-flex align-center">
<span class="mr-2">Show</span>
<v-select
v-model="clinicItemsPerPage"
:items="[10, 25, 50, 100]"
density="compact"
variant="outlined"
style="max-width: 80px"
class="mr-4"
></v-select>
<span class="mr-2">entries</span>
<span class="mr-4">Search:</span>
<v-text-field
v-model="clinicSearch"
density="compact"
variant="outlined"
style="max-width: 200px"
hide-details
></v-text-field>
</div>
</v-card-title> </v-card-title>
<v-data-table <v-data-table
:headers="clinicHeaders" :headers="clinicHeaders"
:items="clinicPatients" :items="clinicPatients"
:search="clinicSearch" :search="clinicSearch"
:items-per-page="clinicItemsPerPage" :items-per-page="clinicItemsPerPage"
class="elevation-1" class="custom-table"
> >
<template v-slot:no-data> <template v-slot:no-data>
<div class="text-center pa-4">No data available in table</div> <div class="text-center pa-4">Tidak ada data yang tersedia</div>
</template>
</v-data-table>
</v-card>
<!-- Info Klinik Table -->
<v-card class="pa-6 rounded-xl elevation-2">
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
Info Klinik
</v-card-title>
<v-data-table
:headers="infoKlinikHeaders"
:items="infoKlinikData"
class="custom-table"
hide-default-footer
disable-pagination
>
<template v-slot:bottom>
<v-card-text class="d-flex justify-end text-right">
<span class="mr-4 font-weight-bold text-h6">Total:</span>
<span class="mr-12 font-weight-bold text-h6 text-primary">{{ totalDapatDipanggil }}</span>
<span class="font-weight-bold text-h6 text-primary">{{ totalShiftBelumBuka }}</span>
</v-card-text>
</template> </template>
</v-data-table> </v-data-table>
</v-card> </v-card>
@@ -201,7 +263,11 @@
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref, onMounted, computed } from "vue";
definePageMeta({
middleware:['auth']
})
// Reactive data // Reactive data
const search = ref(""); const search = ref("");
@@ -210,6 +276,8 @@ const clinicSearch = ref("");
const itemsPerPage = ref(10); const itemsPerPage = ref(10);
const lateItemsPerPage = ref(10); const lateItemsPerPage = ref(10);
const clinicItemsPerPage = ref(10); const clinicItemsPerPage = ref(10);
const currentDateLongFormatted = ref("");
const currentDateShortFormatted = ref("");
// Table headers // Table headers
const mainHeaders = ref([ const mainHeaders = ref([
@@ -221,8 +289,8 @@ const mainHeaders = ref([
{ title: "Klinik", value: "klinik" }, { title: "Klinik", value: "klinik" },
{ title: "Fast Track", value: "fastTrack" }, { title: "Fast Track", value: "fastTrack" },
{ title: "Pembayaran", value: "pembayaran" }, { title: "Pembayaran", value: "pembayaran" },
{ title: "Panggil", value: "panggil" }, { title: "Panggil", align: 'center', value: "panggil", sortable: false },
{ title: "Aksi", value: "aksi", sortable: false }, { title: "Aksi", value: "aksi", sortable: false },
]); ]);
const lateHeaders = ref([ const lateHeaders = ref([
@@ -246,128 +314,213 @@ const clinicHeaders = ref([
{ title: "Aksi", value: "aksi", sortable: false }, { title: "Aksi", value: "aksi", sortable: false },
]); ]);
// Sample data const infoKlinikHeaders = ref([
const mainPatients = ref([ { title: "#", value: "no" },
{ { title: "Klinik", value: "klinik" },
no: 1, { title: "Jumlah Shift", value: "jumlahShift" },
jamPanggil: "12:49", { title: "Quota Per Shift", value: "quotaPerShift" },
barcode: "250811100163", { title: "Status", value: "status" },
noAntrian: "UM1001 | Online - 250811100163", { title: "Dapat Di Panggil", value: "dapatDiPanggil" },
shift: "Shift 1", { title: "Shift Belum Buka", value: "shiftBelumBuka" },
klinik: "KANDUNGAN",
fastTrack: "UMUM",
pembayaran: "UMUM",
panggil: "Panggil",
status: "current",
},
{
no: 2,
jamPanggil: "10:52",
barcode: "250811100155",
noAntrian: "UM1002 | Online - 250811100155",
shift: "Shift 1",
klinik: "IPD",
fastTrack: "UMUM",
pembayaran: "UMUM",
panggil: "Cancel",
status: "normal",
},
{
no: 3,
jamPanggil: "07:35",
barcode: "250811100355",
noAntrian: "UM1005 | Online - 250811100355",
shift: "Shift 1",
klinik: "THT",
fastTrack: "UMUM",
pembayaran: "UMUM",
panggil: "Panggil",
status: "normal",
},
{
no: 4,
jamPanggil: "08:05",
barcode: "250811100355",
noAntrian: "UM1006 | Online - 250811100355",
shift: "Shift 1",
klinik: "THT",
fastTrack: "UMUM",
pembayaran: "UMUM",
panggil: "Panggil",
status: "normal",
},
{
no: 5,
jamPanggil: "12:43",
barcode: "250811100402",
noAntrian: "UM1004 | Online - 250811100402",
shift: "Shift 1",
klinik: "SARAF",
fastTrack: "UMUM",
pembayaran: "UMUM",
panggil: "Panggil",
status: "normal",
},
]); ]);
const latePatients = ref([]); // Sample data with new 'originalAntrian' and 'status' properties
const mainPatients = ref([
{ no: 1, jamPanggil: "11:46", barcode: "250826100362", noAntrian: "UM1002 | Online - 250826100362", originalAntrian: "UM1002", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "dipanggil" },
{ no: 2, jamPanggil: "06:47", barcode: "250826100140", noAntrian: "UM1003", originalAntrian: "UM1003", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
{ no: 3, jamPanggil: "06:47", barcode: "250826100143", noAntrian: "UM1004", originalAntrian: "UM1004", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
{ no: 4, jamPanggil: "06:47", barcode: "250826100500", noAntrian: "UM1005", originalAntrian: "UM1005", shift: "Shift 1", klinik: "MATA", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
{ no: 5, jamPanggil: "06:47", barcode: "250826100525", noAntrian: "UM1006", originalAntrian: "UM1006", shift: "Shift 1", klinik: "ONKOLOGI", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
{ no: 6, jamPanggil: "06:47", barcode: "250826100536", noAntrian: "UM1007", originalAntrian: "UM1007", shift: "Shift 1", klinik: "THT", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
]);
// Tambahkan lebih banyak data pasien untuk demonstrasi "Panggil 20"
for (let i = 7; i <= 25; i++) {
mainPatients.value.push({
no: i,
jamPanggil: "07:00",
barcode: `250826100${100 + i}`,
noAntrian: `UM100${i}`,
originalAntrian: `UM100${i}`,
shift: "Shift 1",
klinik: "UMUM",
fastTrack: "",
pembayaran: "UMUM",
panggil: "Panggil",
status: "",
});
}
const latePatients = ref([]);
const clinicPatients = ref([]); const clinicPatients = ref([]);
const infoKlinikData = ref([
{ no: 1, klinik: "ANESTESI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 2, klinik: "GERIATRI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 3, klinik: "GIGI DAN MULUT", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 4, klinik: "HOM", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 5, klinik: "IPD", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 6, klinik: "JANTUNG", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 7, klinik: "KANDUNGAN", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 8, klinik: "KOMPLEMENTER", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBukan: "-" },
{ no: 9, klinik: "KUL.KEL", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 10, klinik: "MATA", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 11, klinik: "ONKOLOGI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 12, klinik: "PARU", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 13, klinik: "SARAF", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 14, klinik: "THT", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
]);
// Computed properties for totals
const totalDapatDipanggil = computed(() => {
return infoKlinikData.value.reduce((total, item) => {
return total + (item.dapatDiPanggil === "-" ? 0 : parseInt(item.dapatDiPanggil));
}, 0);
});
const totalShiftBelumBuka = computed(() => {
return infoKlinikData.value.reduce((total, item) => {
return total + (item.shiftBelumBuka === "-" ? 0 : parseInt(item.shiftBelumBuka));
}, 0);
});
// Methods // Methods
const getRowClass = (item) => { const getRowClass = (item) => {
if (item.status === "current") { if (item.status === 'dipanggil') {
return "text-green font-weight-bold"; return 'called-row';
} }
return ""; return '';
}; };
const handleStatusCardClick = (value) => { const handleStatusCardClick = (count) => {
console.log(`Status card with value ${value} was clicked!`); console.log(`Memanggil ${count} antrean pasien.`);
// Tambahkan logika Anda di sini const updatedPatients = mainPatients.value.map((patient, index) => {
const isCalled = index < count;
return {
...patient,
status: isCalled ? 'dipanggil' : '',
noAntrian: isCalled ? `${patient.originalAntrian} | Online - ${patient.barcode}` : patient.originalAntrian
};
});
mainPatients.value = updatedPatients;
}; };
const handleNextPatientClick = () => { const handleNextPatientClick = () => {
console.log("Next Patient card was clicked!"); console.log("Tombol Panggil Pasien Selanjutnya diklik! (Mengambil pasien pertama dari antrean)");
// Tambahkan logika untuk memanggil pasien selanjutnya di sini const updatedPatients = mainPatients.value.map(patient => {
return { ...patient, status: '', noAntrian: patient.originalAntrian };
});
mainPatients.value = updatedPatients;
}; };
const handlePanggilClick = (item) => {
console.log(`Tombol Panggil untuk pasien: ${item.noAntrian} diklik!`);
// Membuat salinan baru dari seluruh array untuk memicu reaktivitas
const updatedPatients = mainPatients.value.map(p => {
// Jika pasien cocok, buat salinan baru dengan status 'dipanggil' dan tambahkan "Online"
if (p.no === item.no) {
return {
...p,
status: 'dipanggil',
noAntrian: `${p.originalAntrian} | Online - ${p.barcode}`
};
}
// Jika tidak, kembalikan objek pasien aslinya
return p;
});
// Ganti seluruh array data dengan salinan yang baru.
mainPatients.value = updatedPatients;
};
const handleProsesClick = (item) => {
console.log(`Tombol Proses untuk pasien: ${item.noAntrian} diklik!`);
const updatedPatients = mainPatients.value.map(p => {
if (p.no === item.no) {
return {
...p,
status: 'dalam_proses'
};
}
return p;
});
mainPatients.value = updatedPatients;
};
// Mengatur tanggal saat komponen dimuat
onMounted(() => {
const today = new Date();
const optionsLong = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
currentDateLongFormatted.value = today.toLocaleDateString('id-ID', optionsLong);
const optionsShort = { year: 'numeric', month: 'long', day: 'numeric' };
currentDateShortFormatted.value = today.toLocaleDateString('id-ID', optionsShort);
});
</script> </script>
<style scoped> <style scoped>
.v-list-item--active { /* Scoped styles for a cleaner look */
background-color: rgba(25, 118, 210, 0.12);
color: #1976d2;
}
.text-green {
color: #4caf50 !important;
}
/* Custom scrollbar */
:deep(.v-data-table) {
font-size: 14px;
}
:deep(.v-data-table__wrapper) {
max-height: 400px;
overflow-y: auto;
}
/* Row highlighting */
:deep(.v-data-table tbody tr:nth-child(1)) {
background-color: #fff3cd !important;
}
.main-content-padding { .main-content-padding {
padding-left: 64px !important; padding-left: 24px !important;
padding-right: 24px !important;
} }
.clickable-card { /* Header Banner */
.header-banner {
background: linear-gradient(90deg, #1565C0, #1976D2);
color: white;
min-height: 120px;
}
/* Next Patient Card */
.next-patient-card {
background: linear-gradient(45deg, #00A896, #00796B);
color: white;
}
/* Status Cards */
.v-card.text-center {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease-in-out; }
.v-card.text-center:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
} }
.clickable-card:hover { /* Table styling */
transform: translateY(-4px); .custom-table :deep(thead th) {
background-color: #E8EAF6; /* Light gray-blue header */
font-weight: bold;
}
.custom-table :deep(tbody tr:nth-of-type(odd)) {
background-color: #F5F5F5; /* Light gray for odd rows */
}
/* Highlighted row for "dipanggil" status */
.custom-table :deep(tbody tr.called-row) {
background-color: #998479 !important; /* Light green background */
}
/* Field and Select styling */
.select-items .v-field--variant-solo,
.v-text-field .v-field--variant-solo {
background-color: #ECEFF1;
}
.text-blue {
color: #1976D2 !important;
}
.text-primary {
color: #1976D2 !important;
}
.online-antrian {
font-weight: bold;
color: #1976D2;
} }
</style> </style>

175
pages/RanapAdmin.vue Normal file
View File

@@ -0,0 +1,175 @@
<template>
<v-main class="ranap-admin-page">
<v-container fluid class="pa-6">
<!-- Modern Header -->
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
<v-icon size="48" color="white" class="mr-4">mdi-hospital-box-outline</v-icon>
<h1 class="text-h4 font-weight-bold text-white">Ranap Admin</h1>
</v-card>
<v-card class="elevation-2 rounded-xl pa-4">
<div class="d-flex justify-space-between align-center mb-4">
<div class="d-flex align-center">
<span class="text-caption mr-2">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
density="compact"
hide-details
variant="solo"
flat
class="pagination-select"
:menu-props="{ attach: true }"
></v-select>
<span class="text-caption ml-2">entries</span>
</div>
<v-text-field
v-model="search"
label="Search"
append-inner-icon="mdi-magnify"
single-line
hide-details
density="compact"
variant="solo"
flat
class="search-field"
></v-text-field>
</div>
<v-data-table
:headers="headers"
:items="filteredItems"
:items-per-page="itemsPerPage"
class="elevation-0"
item-key="noAntrean"
>
<template v-slot:item.aksi="{ item }">
<v-btn small color="success" class="text-white" @click="selectItem(item)">
Selesai
</v-btn>
</template>
<template v-slot:bottom>
<div class="d-flex justify-space-between align-center pa-2">
<span class="text-caption">Showing {{ (page - 1) * itemsPerPage + 1 }} to {{ Math.min(page * itemsPerPage, filteredItems.length) }} of {{ filteredItems.length }} entries</span>
<v-pagination
v-model="page"
:length="pageCount"
:total-visible="5"
rounded="circle"
></v-pagination>
</div>
</template>
</v-data-table>
</v-card>
<v-row class="mt-6">
<v-col cols="12" md="4" class="pr-md-2">
<v-card class="elevation-2 rounded-xl next-card pa-4 d-flex flex-column align-center text-center">
<h2 class="text-h3 font-weight-bold next-title">NEXT</h2>
<span class="text-h4 font-weight-medium my-4 next-content">{{ nextAntrean || 'Kosong' }}</span>
<p class="text-caption">Klik untuk memanggil pasien selanjutnya</p>
</v-card>
</v-col>
</v-row>
</v-container>
</v-main>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
const headers = [
{ title: 'No', align: 'start', sortable: false, key: 'no' },
{ title: 'No. Antrean', key: 'noAntrean' },
{ title: 'Daftar', key: 'daftar' },
{ title: 'Pelayanan', key: 'pelayanan' },
{ title: 'Aksi', key: 'aksi' },
];
const items = ref([
{ no: 1, noAntrean: '001', daftar: '26 Aug 2025 07:10:31', pelayanan: 'Belum Dilayani' },
{ no: 2, noAntrean: '002', daftar: '26 Aug 2025 07:10:35', pelayanan: 'Belum Dilayani' },
{ no: 3, noAntrean: '003', daftar: '26 Aug 2025 07:10:44', pelayanan: 'Belum Dilayani' },
{ no: 4, noAntrean: '004', daftar: '26 Aug 2025 07:10:46', pelayanan: 'Belum Dilayani' },
{ no: 5, noAntrean: '005', daftar: '26 Aug 2025 07:10:47', pelayanan: 'Belum Dilayani' },
{ no: 6, noAntrean: '006', daftar: '26 Aug 2025 07:10:49', pelayanan: 'Belum Dilayani' },
{ no: 7, noAntrean: '007', daftar: '26 Aug 2025 07:10:51', pelayanan: 'Belum Dilayani' },
{ no: 8, noAntrean: '008', daftar: '26 Aug 2025 07:10:53', pelayanan: 'Belum Dilayani' },
{ no: 9, noAntrean: '009', daftar: '26 Aug 2025 07:10:54', pelayanan: 'Belum Dilayani' },
{ no: 10, noAntrean: '010', daftar: '26 Aug 2025 07:10:55', pelayanan: 'Belum Dilayani' },
]);
const search = ref('');
const itemsPerPage = ref(10);
const page = ref(1);
const nextAntrean = ref('001');
const filteredItems = computed(() => {
if (!search.value) {
return items.value;
}
return items.value.filter(item =>
Object.values(item).some(val =>
String(val).toLowerCase().includes(search.value.toLowerCase())
)
);
});
const pageCount = computed(() => {
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
});
const selectItem = (item) => {
console.log('Item selected:', item);
// Di sini Anda bisa menambahkan logika untuk mengubah status pasien menjadi "Dilayani" atau memindahkannya ke antrean berikutnya.
};
</script>
<style scoped>
.ranap-admin-page {
background-color: #f5f5f5;
min-height: 100vh;
}
.header-banner {
background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
color: white;
padding: 24px;
}
.search-field, .pagination-select {
max-width: 250px;
}
.v-data-table :deep(table) {
border-collapse: separate;
border-spacing: 0 10px;
}
.v-data-table :deep(tbody tr) {
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.05);
border-radius: 8px;
overflow: hidden;
}
.next-card {
background-color: #00A896;
color: white;
height: 200px;
}
.next-title {
font-size: 3.5rem;
}
.next-content {
font-size: 3rem;
line-height: 1;
}
</style>

571
pages/Setting/HakAkses.vue Normal file
View File

@@ -0,0 +1,571 @@
<template>
<div class="hak-akses-page pa-6">
<v-breadcrumbs :items="breadcrumbs" class="pl-0 mb-4">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<v-card v-if="viewMode === 'add' || viewMode === 'editName'" class="pa-6 rounded-xl elevation-4">
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
<v-icon :icon="viewMode === 'add' ? 'mdi-plus' : 'mdi-pencil'" class="mr-2 text-primary" size="28"></v-icon>
<span>{{ formTitle }}</span>
</v-card-title>
<v-divider class="mb-4"></v-divider>
<v-card-text class="px-0">
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User</v-label>
<v-text-field
v-model="editedItem.namaTipeUser"
placeholder="Masukkan Nama Tipe User"
variant="outlined"
density="comfortable"
class="mt-1"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-0 mt-4">
<v-btn
color="grey-darken-1"
variant="flat"
rounded="lg"
class="text-capitalize mr-2"
@click="cancelForm"
>
Batal
</v-btn>
<v-btn
color="primary"
variant="flat"
rounded="lg"
class="text-capitalize"
@click="saveItem"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
<EditHakAkses
v-else-if="viewMode === 'editAccess'"
:item="editedItem"
@save="updateItemAccess"
@cancel="cancelForm"
/>
<v-card v-else-if="viewMode === 'view'" class="pa-6 rounded-xl elevation-4">
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
<v-icon icon="mdi-eye-outline" class="mr-2 text-info" size="28"></v-icon>
<span>{{ formTitle }}</span>
</v-card-title>
<v-divider class="mb-4"></v-divider>
<v-card-text class="px-0">
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User</v-label>
<v-text-field
v-model="editedItem.namaTipeUser"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
<v-table density="comfortable" class="elevation-1 rounded-xl">
<thead>
<tr>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">No</th>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Akses</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Lihat</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Tambah</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Edit</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Hapus</th>
</tr>
</thead>
<tbody>
<tr v-for="(menu, index) in editedItem.hakAksesMenu" :key="index">
<td>{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center">
<v-icon :color="menu.canAccess ? 'green' : 'grey-lighten-2'">{{ menu.canAccess ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canView ? 'green' : 'grey-lighten-2'">{{ menu.canView ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canAdd ? 'green' : 'grey-lighten-2'">{{ menu.canAdd ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canEdit ? 'green' : 'grey-lighten-2'">{{ menu.canEdit ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canDelete ? 'green' : 'grey-lighten-2'">{{ menu.canDelete ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-0 mt-4">
<v-btn
color="grey-darken-1"
variant="flat"
rounded="lg"
class="text-capitalize"
@click="cancelForm"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
<div v-else>
<v-card class="d-flex flex-column flex-sm-row justify-space-between align-center pa-6 mb-6 rounded-xl bg-blue-lighten-5 elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0 text-blue-darken-3">
<v-icon icon="mdi-shield-lock-outline" class="mr-2" size="40"></v-icon>
<span>Hak Akses</span>
</v-card-title>
<div class="d-flex mt-4 mt-sm-0">
<v-btn
color="success"
prepend-icon="mdi-plus"
rounded="lg"
class="text-capitalize mr-2"
variant="flat"
@click="showAddForm"
>
Tambah Baru
</v-btn>
<v-btn
color="info"
prepend-icon="mdi-format-list-numbered"
rounded="lg"
class="text-capitalize"
variant="flat"
@click="toggleReorderMode"
>
{{ reorderMode ? 'Selesai' : 'Atur Urutan' }}
</v-btn>
</div>
</v-card>
<v-card class="pa-6 rounded-xl elevation-2">
<v-card-text class="pa-0">
<div class="d-flex flex-column flex-sm-row align-center justify-space-between mb-4">
<div class="d-flex align-center mb-4 mb-sm-0">
<span class="mr-2 text-subtitle-1 text-medium-emphasis">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50]"
variant="outlined"
density="compact"
hide-details
style="max-width: 80px;"
rounded="lg"
class="mr-2"
></v-select>
<span class="text-subtitle-1 text-medium-emphasis">entries</span>
</div>
<div class="d-flex align-center">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
variant="outlined"
density="compact"
hide-details
rounded="lg"
clearable
></v-text-field>
</div>
</div>
<v-data-table
:headers="headers"
:items="allHakAksesData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
class="elevation-0 custom-table"
>
<template v-slot:[`item.actions`]="{ item }">
<div class="d-flex justify-center">
<v-btn icon size="small" variant="text" class="mr-1" @click="viewItem(item)">
<v-icon color="blue-darken-1">mdi-eye-outline</v-icon>
</v-btn>
<v-btn icon size="small" variant="text" class="mr-1" @click="editItem(item)">
<v-icon color="orange-darken-1">mdi-pencil-outline</v-icon>
</v-btn>
<v-btn icon size="small" variant="text" class="mr-1" @click="deleteItem(item)">
<v-icon color="red-darken-1">mdi-delete-outline</v-icon>
</v-btn>
<v-btn icon size="small" variant="text" @click="editAccess(item)">
<v-icon color="green-darken-1">mdi-lock-check-outline</v-icon>
</v-btn>
</div>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<template v-slot:item.id="{ item }">
<div class="text-center">{{ item.id }}</div>
</template>
<template v-slot:bottom>
<v-row class="ma-2 pa-2">
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey-darken-1">
{{ showingEntriesText }}
</v-col>
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
<v-pagination
v-model="page"
:length="pageCount"
rounded="circle"
:total-visible="5"
></v-pagination>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card class="pa-6 rounded-xl elevation-4">
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" rounded="lg" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red-darken-1" variant="text" rounded="lg" @click="confirmDelete">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="reorderMode" max-width="600px">
<v-card class="pa-6 rounded-xl elevation-4">
<v-card-title class="text-h6 font-weight-bold">Atur Urutan Menu</v-card-title>
<v-card-text>
<VueDraggableNext
v-model="draggableMenus"
item-key="name"
tag="v-list"
class="pa-0"
handle=".handle"
:animation="200"
>
<template #item="{ element }">
<v-list-item class="rounded-lg elevation-1 my-2" :title="element.name">
<template #prepend>
<v-icon icon="mdi-drag-vertical" class="handle"></v-icon>
</template>
</v-list-item>
</template>
</VueDraggableNext>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" rounded="lg" @click="cancelReorder">Batal</v-btn>
<v-btn color="primary" variant="text" rounded="lg" @click="saveReorder">Simpan Urutan</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useLocalStorage, useSessionStorage } from '@vueuse/core';
import { VueDraggableNext } from 'vue-draggable-next';
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
import { useNavItemsStore } from '@/stores/navItems';
definePageMeta({
middleware: ['auth']
})
interface HakAksesMenu {
name: string;
canAccess: boolean;
canView: boolean;
canAdd: boolean;
canEdit: boolean;
canDelete: boolean;
}
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
interface HakAksesData {
id: number;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
}
interface BackendPermissionItem {
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
page_name: string;
pageID: number;
}
interface SessionData {
roles: string[];
groups: string[];
}
// State for views
const viewMode = ref<'table' | 'add' | 'editName' | 'editAccess' | 'view'>('table');
const reorderMode = ref(false);
const allHakAksesData = useLocalStorage<HakAksesData[]>('allHakAksesData', []);
const navItemsStore = useNavItemsStore();
const draggableMenus = ref<NavItem[]>([]);
// Data table headers
const headers = ref([
{ title: 'No', key: 'id' as const },
{ title: 'Nama Tipe User', key: 'namaTipeUser' as const, sortable: true },
{ title: 'Aksi', align: 'center' as const, key: 'actions' as const, sortable: false },
]);
// Breadcrumbs
const breadcrumbs = computed(() => {
const baseCrumbs = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Hak Akses', disabled: false, href: '/setting/hakakses' },
];
if (viewMode.value === 'add') {
return [...baseCrumbs, { title: 'Tambah Hak Akses', disabled: true, href: '/setting/tambahhakakses' }];
} else if (viewMode.value === 'editName') {
return [...baseCrumbs, { title: 'Edit Nama Tipe User', disabled: true, href: '/setting/editnamahakakses' }];
} else if (viewMode.value === 'editAccess') {
return [...baseCrumbs, { title: 'Edit Hak Akses', disabled: true, href: '/setting/edithakakses' }];
} else if (viewMode.value === 'view') {
return [...baseCrumbs, { title: 'Detail Hak Akses', disabled: true, href: '/setting/viewhakakses' }];
}
return baseCrumbs;
});
// Table and pagination state
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
const editedIndex = ref(-1);
const editedItem = ref<HakAksesData>({
id: 0,
namaTipeUser: '',
hakAksesMenu: [],
});
// --- Menu management logic ---
// This is your list of all possible menus with a path and icon
const defaultMenuItems = () => ([
{ name: 'Dashboard', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/dashboard', icon: 'mdi-view-dashboard' },
{ name: 'Setting', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/setting', icon: 'mdi-cog' },
{ name: 'Setting / Hak Akses', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/setting/hakakses', icon: 'mdi-shield-lock-outline' },
// ... and so on for all your pages
]);
// Computed properties
const pageCount = computed(() => {
return Math.ceil(allHakAksesData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allHakAksesData.value.length);
const total = allHakAksesData.value.length;
return `Showing ${start} to ${end} of ${total} entries`;
});
const formTitle = computed(() => {
if (viewMode.value === 'editName') return 'Edit Nama Tipe User';
if (viewMode.value === 'editAccess') return 'Edit Hak Akses';
if (viewMode.value === 'view') return 'Detail Hak Akses';
return 'Tambah Hak Akses';
});
// Delete dialog state
const showDeleteDialog = ref(false);
// Functions to reorder data and sync
const toggleReorderMode = () => {
reorderMode.value = !reorderMode.value;
if (reorderMode.value) {
draggableMenus.value = navItemsStore.navItems.map(item => ({ ...item }));
}
};
const saveReorder = () => {
navItemsStore.updateNavItems(draggableMenus.value);
reorderMode.value = false;
reindexData();
};
const cancelReorder = () => {
reorderMode.value = false;
};
// Re-indexes IDs to be sequential
const reindexData = () => {
allHakAksesData.value.forEach((item, index) => {
item.id = index + 1;
});
};
// Functions for actions
const showAddForm = () => {
resetForm();
viewMode.value = 'add';
};
const viewItem = (item: HakAksesData) => {
editedItem.value = { ...item };
viewMode.value = 'view';
};
const editItem = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
editedItem.value = { ...item };
viewMode.value = 'editName';
};
const editAccess = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
const baseMenuItems = defaultMenuItems();
const existingAccess = item.hakAksesMenu;
const mergedMenuItems = baseMenuItems.map(defaultMenu => {
const existingMenu = existingAccess.find(exist => exist.name === defaultMenu.name);
return existingMenu ? { ...defaultMenu, ...existingMenu } : defaultMenu;
});
editedItem.value = {
...item,
hakAksesMenu: mergedMenuItems
};
viewMode.value = 'editAccess';
};
const deleteItem = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
showDeleteDialog.value = true;
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
editedIndex.value = -1;
};
const confirmDelete = () => {
if (editedIndex.value > -1) {
allHakAksesData.value.splice(editedIndex.value, 1);
reindexData();
}
closeDeleteDialog();
};
const cancelForm = () => {
viewMode.value = 'table';
resetForm();
};
const updateItemAccess = (updatedItem: HakAksesData) => {
if (editedIndex.value > -1) {
Object.assign(allHakAksesData.value[editedIndex.value], updatedItem);
}
cancelForm();
};
const resetForm = () => {
editedItem.value = {
id: 0,
namaTipeUser: '',
hakAksesMenu: navItemsStore.navItems.map(navItem => ({
name: navItem.name,
canAccess: false,
canView: false,
canAdd: false,
canEdit: false,
canDelete: false,
})),
};
};
const saveItem = () => {
if (editedIndex.value > -1) {
// Edit item
Object.assign(allHakAksesData.value[editedIndex.value], editedItem.value);
} else {
// Add item with a new ID
editedItem.value.id = allHakAksesData.value.length + 1;
allHakAksesData.value.push(editedItem.value);
reindexData(); // Re-index to ensure sequential IDs
}
cancelForm();
};
// Handle initial data load and ID re-indexing
onMounted(() => {
reindexData();
});
</script>
<style scoped>
.hak-akses-page {
font-family: 'Roboto', sans-serif;
background-color: #f5f7fa;
min-height: 100vh;
}
.custom-table {
border: none;
}
.v-data-table :deep(th) {
font-weight: bold !important;
color: #333 !important;
}
.v-data-table :deep(td) {
vertical-align: middle;
}
.v-btn--icon {
border-radius: 8px;
}
.v-select :deep(.v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.handle {
cursor: grab;
}
</style>

View File

@@ -0,0 +1,491 @@
<template>
<div class="master-klinik-page pa-4">
<!-- Breadcrumbs -->
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<!-- Tampilan Formulir Tambah/Edit/View -->
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
{{ isEditMode ? 'Edit Klinik' : readOnly ? 'Detail Klinik' : 'Tambah Klinik' }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Kode</v-label>
<v-text-field
v-model="editedItem.kode"
placeholder="Masukkan Kode Klinik"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama</v-label>
<v-text-field
v-model="editedItem.namaKlinik"
placeholder="Masukkan Nama Klinik"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Shift</v-label>
<v-text-field
v-model="editedItem.shift"
placeholder="Jumlah Shift"
type="number"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Kuota Shift</v-label>
<v-text-field
v-model="editedItem.kuotaShift"
placeholder="Kuota Per Shift"
type="number"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row class="d-flex align-center">
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Jam Buka Shift</v-label>
<div class="d-flex align-center">
<v-text-field
v-model="editedItem.jamBuka"
placeholder="Jam Buka Shift 1"
variant="outlined"
density="comfortable"
class="mr-2"
:readonly="readOnly"
></v-text-field>
<span class="text-h6 font-weight-bold mr-2">:</span>
<v-text-field
v-model="editedItem.menitBuka"
placeholder="Menit Buka Shift 1"
variant="outlined"
density="comfortable"
:readonly="readOnly"
></v-text-field>
</div>
</v-col>
<v-col cols="12" md="6">
<v-switch
v-model="editedItem.autoShift"
inset
color="primary"
label="Auto Shift"
hide-details
:readonly="readOnly"
></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold">Kuota Bangku</v-label>
<v-text-field
v-model="editedItem.kuotaBangku"
placeholder="Masukkan Kuota Bangku Klinik"
type="number"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold">Jadwal Klinik</v-label>
<v-table class="rounded-lg elevation-1 mt-2">
<thead>
<tr>
<th class="text-left text-uppercase font-weight-bold">#</th>
<th class="text-left text-uppercase font-weight-bold">Hari</th>
<th class="text-left text-uppercase font-weight-bold">Pilih</th>
</tr>
</thead>
<tbody>
<tr v-for="(day, index) in days" :key="index">
<td>{{ index + 1 }}</td>
<td>{{ day.name }}</td>
<td>
<v-checkbox
v-model="day.selected"
color="primary"
hide-details
:readonly="readOnly"
></v-checkbox>
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-start pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="cancelForm"
>
{{ readOnly ? 'Tutup' : 'Batal' }}
</v-btn>
<v-btn
v-if="!readOnly"
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="saveItem"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
<!-- Tampilan Tabel Data -->
<div v-else>
<!-- Banner biru sebagai pengganti header h1 -->
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-hospital" class="mr-2" size="40"></v-icon>
<span>Master Klinik</span>
</v-card-title>
<v-btn
color="success"
prepend-icon="mdi-plus"
rounded
class="text-capitalize"
@click="showForm = true"
>
Tambah Baru
</v-btn>
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-text>
<!-- Table controls -->
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50]"
variant="outlined"
density="compact"
hide-details
style="max-width: 80px;"
rounded
class="mr-2"
></v-select>
<span class="text-subtitle-1">entries</span>
</div>
<div class="d-flex align-center">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
variant="outlined"
density="compact"
hide-details
rounded
clearable
></v-text-field>
</div>
</div>
<!-- Data Table dengan paginasi kustom -->
<v-data-table
:headers="headers"
:items="allKlinikData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
class="rounded-lg elevation-0 custom-table"
>
<!-- Slot untuk aksi di setiap baris -->
<template v-slot:[`item.actions`]="{ item }">
<!-- Tombol "View" yang hanya menampilkan detail -->
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="red" size="small" @click="deleteItem(item)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<!-- Slot kustom untuk footer tabel (paginasi) -->
<template v-slot:bottom>
<v-row class="ma-2">
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
{{ showingEntriesText }}
</v-col>
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
<v-pagination
v-model="page"
:length="pageCount"
rounded="circle"
:total-visible="5"
></v-pagination>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
</div>
<!-- Delete Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card class="pa-4 rounded-lg">
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
// State untuk menampilkan/menyembunyikan formulir
const showForm = ref(false);
const readOnly = ref(false); // State untuk mode "view"
// Data dummy untuk Master Klinik
const allKlinikData = ref([
{ id: 1, kode: 'AN', namaKlinik: 'ANAK', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 2, kode: 'AS', namaKlinik: 'ANESTESI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 3, kode: 'BD', namaKlinik: 'BEDAH', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 4, kode: 'GR', namaKlinik: 'GERIATRI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 5, kode: 'GI', namaKlinik: 'GIGI DAN MULUT', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 6, kode: 'GZ', namaKlinik: 'GIZI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 7, kode: 'HO', namaKlinik: 'HOM', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 8, kode: 'IP', namaKlinik: 'IPD', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 9, kode: 'JT', namaKlinik: 'JANTUNG', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 10, kode: 'JW', namaKlinik: 'JIWA', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 11, kode: 'OB', namaKlinik: 'KANDUNGAN', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 12, kode: 'KT', namaKlinik: 'KEMOTERAPI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 13, kode: 'KN', namaKlinik: 'KOMPLEMENTER', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 14, kode: 'KL', namaKlinik: 'KUL KEL', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 15, kode: 'MT', namaKlinik: 'MATA', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 16, kode: 'MC', namaKlinik: 'MCU', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 17, kode: 'ON', namaKlinik: 'ONKOLOGI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 18, kode: 'PR', namaKlinik: 'PARU', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 19, kode: 'RT', namaKlinik: 'R. TINDAKAN', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 20, kode: 'RD', namaKlinik: 'RADIOTERAPI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 21, kode: 'RM', namaKlinik: 'REHAB MEDIK', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 22, kode: 'NU', namaKlinik: 'SARAF', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 23, kode: 'TH', namaKlinik: 'THT', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
]);
const headers = ref([
{ title: 'No', key: 'id' },
{ title: 'Kode', key: 'kode', sortable: true },
{ title: 'Nama Klinik', key: 'namaKlinik', sortable: true },
{ title: 'Shift', key: 'shift', sortable: true },
{ title: 'Kuota Shift', key: 'kuotaShift', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
]);
// Breadcrumbs
const breadcrumbs = ref([
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
]);
const days = ref([
{ name: 'Senin', selected: false },
{ name: 'Selasa', selected: false },
{ name: 'Rabu', selected: false },
{ name: 'Kamis', selected: false },
{ name: 'Jum`at', selected: false },
]);
// State untuk tabel dan paginasi
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
// Computed properties untuk paginasi kustom
const pageCount = computed(() => {
return Math.ceil(allKlinikData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allKlinikData.value.length);
const total = allKlinikData.value.length;
return `Showing ${start} to ${end} of ${total} entries`;
});
// State untuk dialog
const showDeleteDialog = ref(false);
const isEditMode = ref(false);
const editedIndex = ref(-1);
const editedItem = ref({
id: 0,
kode: '',
namaKlinik: '',
shift: 1,
kuotaShift: 0,
kuotaBangku: 0,
jamBuka: '',
menitBuka: '',
autoShift: false,
});
// Fungsi untuk tombol aksi
// Fungsi untuk tombol View yang hanya menampilkan data tanpa bisa diedit
const viewItem = (item) => {
editedItem.value = { ...item };
readOnly.value = true;
isEditMode.value = false;
showForm.value = true;
// Update breadcrumbs untuk mode view
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
{ title: 'Detail Klinik', disabled: true, href: '/setting/viewklinik' },
];
};
const editItem = (item) => {
editedIndex.value = allKlinikData.value.findIndex(d => d.id === item.id);
editedItem.value = { ...item };
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
// Update breadcrumbs untuk mode edit
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
{ title: 'Edit Klinik', disabled: true, href: '/setting/editklinik' },
];
};
const deleteItem = (item) => {
editedIndex.value = allKlinikData.value.findIndex(d => d.id === item.id);
showDeleteDialog.value = true;
};
const confirmDelete = () => {
if (editedIndex.value > -1) {
allKlinikData.value.splice(editedIndex.value, 1);
}
closeDeleteDialog();
};
const cancelForm = () => {
showForm.value = false;
isEditMode.value = false;
readOnly.value = false;
editedItem.value = {
id: 0,
kode: '',
namaKlinik: '',
shift: 1,
kuotaShift: 0,
kuotaBangku: 0,
jamBuka: '',
menitBuka: '',
autoShift: false,
};
editedIndex.value = -1;
// Reset breadcrumbs ke mode tabel
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
];
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
editedIndex.value = -1;
};
const saveItem = () => {
if (isEditMode.value) {
Object.assign(allKlinikData.value[editedIndex.value], editedItem.value);
} else {
// Generate new ID
const newId = allKlinikData.value.length > 0 ? Math.max(...allKlinikData.value.map(item => item.id)) + 1 : 1;
editedItem.value.id = newId;
allKlinikData.value.push(editedItem.value);
}
cancelForm(); // Kembali ke tampilan tabel setelah menyimpan
};
</script>
<style scoped>
.master-klinik-page {
font-family: 'Roboto', sans-serif;
background-color: #f5f7fa;
min-height: 100vh;
}
.custom-table {
border: none;
}
.v-data-table :deep(th) {
font-weight: bold !important;
color: #333 !important;
}
.v-data-table :deep(td) {
vertical-align: middle;
}
.v-btn--icon {
border-radius: 8px;
}
.v-select :deep(.v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
</style>

View File

@@ -0,0 +1,376 @@
<template>
<div class="master-klinik-ruang-page pa-4">
<!-- Breadcrumbs -->
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<!-- Tampilan Formulir Tambah/Edit/View -->
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
{{ isEditMode ? 'Edit Ruang Klinik' : readOnly ? 'Detail Ruang Klinik' : 'Tambah Ruang Klinik' }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama Klinik</v-label>
<v-select
v-model="editedItem.namaKlinik"
:items="['ANAK', 'ANESTESI', 'BEDAH', 'GERIATRI', 'GIGI DAN MULUT', 'GIZI', 'HOM', 'IPD', 'JANTUNG', 'JIWA', 'KANDUNGAN', 'KEMOTERAPI', 'KOMPLEMENTER', 'KUL KEL', 'MATA', 'MCU', 'ONKOLOGI', 'PARU', 'R. TINDAKAN', 'RADIOTERAPI', 'REHAB MEDIK', 'SARAF', 'THT']"
placeholder="Pilih Nama Klinik"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Kode Ruang</v-label>
<v-text-field
v-model="editedItem.kode"
placeholder="Masukkan Kode Ruang"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold">Nama Ruang</v-label>
<v-text-field
v-model="editedItem.namaRuang"
placeholder="Masukkan Nama Ruang"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-start pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="cancelForm"
>
{{ readOnly ? 'Tutup' : 'Batal' }}
</v-btn>
<v-btn
v-if="!readOnly"
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="saveItem"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
<!-- Tampilan Tabel Data -->
<div v-else>
<!-- Banner biru sebagai pengganti header h1 -->
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-hospital-box-outline" class="mr-2" size="40"></v-icon>
<span>Master Klinik Ruang</span>
</v-card-title>
<v-btn
color="success"
prepend-icon="mdi-plus"
rounded
class="text-capitalize"
@click="showForm = true"
>
Tambah Baru
</v-btn>
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-text>
<!-- Table controls -->
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50]"
variant="outlined"
density="compact"
hide-details
style="max-width: 80px;"
rounded
class="mr-2"
></v-select>
<span class="text-subtitle-1">entries</span>
</div>
<div class="d-flex align-center">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
variant="outlined"
density="compact"
hide-details
rounded
clearable
></v-text-field>
</div>
</div>
<!-- Data Table dengan paginasi kustom -->
<v-data-table
:headers="headers"
:items="allRuangData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
class="rounded-lg elevation-0 custom-table"
>
<!-- Slot untuk aksi di setiap baris -->
<template v-slot:[`item.actions`]="{ item }">
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="red" size="small" @click="deleteItem(item)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<!-- Slot kustom untuk footer tabel (paginasi) -->
<template v-slot:bottom>
<v-row class="ma-2">
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
{{ showingEntriesText }}
</v-col>
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
<v-pagination
v-model="page"
:length="pageCount"
rounded="circle"
:total-visible="5"
></v-pagination>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
</div>
<!-- Delete Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card class="pa-4 rounded-lg">
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
// State untuk menampilkan/menyembunyikan formulir
const showForm = ref(false);
const readOnly = ref(false);
// Data dummy untuk Master Klinik Ruang
const allRuangData = ref([
{ id: 1, namaKlinik: 'ANAK', kode: 'AN-01', namaRuang: 'RUANG ANAK 1' },
{ id: 2, namaKlinik: 'ANAK', kode: 'AN-02', namaRuang: 'RUANG ANAK 2' },
{ id: 3, namaKlinik: 'ANESTESI', kode: 'AS-01', namaRuang: 'RUANG ANESTESI 1' },
{ id: 4, namaKlinik: 'BEDAH', kode: 'BD-01', namaRuang: 'RUANG BEDAH 1' },
{ id: 5, namaKlinik: 'GIGI DAN MULUT', kode: 'GI-01', namaRuang: 'RUANG GIGI DAN MULUT 1' },
{ id: 6, namaKlinik: 'GERIATRI', kode: 'GR-01', namaRuang: 'RUANG GERIATRI 1' },
{ id: 7, namaKlinik: 'GIZI', kode: 'GZ-01', namaRuang: 'RUANG GIZI 1' },
{ id: 8, namaKlinik: 'HOM', kode: 'HO-01', namaRuang: 'RUANG HOM 1' },
{ id: 9, namaKlinik: 'IPD', kode: 'IP-01', namaRuang: 'RUANG IPD 1' },
{ id: 10, namaKlinik: 'JANTUNG', kode: 'JT-01', namaRuang: 'RUANG JANTUNG 1' },
{ id: 11, namaKlinik: 'JIWA', kode: 'JW-01', namaRuang: 'RUANG JIWA 1' },
{ id: 12, namaKlinik: 'KANDUNGAN', kode: 'OB-01', namaRuang: 'RUANG KANDUNGAN 1' },
{ id: 13, namaKlinik: 'KANDUNGAN', kode: 'OB-02', namaRuang: 'RUANG KANDUNGAN 2' },
{ id: 14, namaKlinik: 'KEMOTERAPI', kode: 'KT-01', namaRuang: 'RUANG KEMOTERAPI 1' },
{ id: 15, namaKlinik: 'KOMPLEMENTER', kode: 'KN-01', namaRuang: 'RUANG KOMPLEMENTER 1' },
{ id: 16, namaKlinik: 'KUL KEL', kode: 'KL-01', namaRuang: 'RUANG KUL KEL 1' },
{ id: 17, namaKlinik: 'MATA', kode: 'MT-01', namaRuang: 'RUANG MATA 1' },
{ id: 18, namaKlinik: 'MCU', kode: 'MC-01', namaRuang: 'RUANG MCU 1' },
{ id: 19, namaKlinik: 'ONKOLOGI', kode: 'ON-01', namaRuang: 'RUANG ONKOLOGI 1' },
{ id: 20, namaKlinik: 'PARU', kode: 'PR-01', namaRuang: 'RUANG PARU 1' },
{ id: 21, namaKlinik: 'R. TINDAKAN', kode: 'RT-01', namaRuang: 'RUANG R. TINDAKAN 1' },
{ id: 22, namaKlinik: 'RADIOTERAPI', kode: 'RD-01', namaRuang: 'RUANG RADIOTERAPI 1' },
{ id: 23, namaKlinik: 'REHAB MEDIK', kode: 'RM-01', namaRuang: 'RUANG REHAB MEDIK 1' },
{ id: 24, namaKlinik: 'SARAF', kode: 'NU-01', namaRuang: 'RUANG SARAF 1' },
{ id: 25, namaKlinik: 'THT', kode: 'TH-01', namaRuang: 'RUANG THT 1' },
]);
const headers = ref([
{ title: 'No', key: 'id' },
{ title: 'Nama Klinik', key: 'namaKlinik', sortable: true },
{ title: 'Kode Ruang', key: 'kode', sortable: true },
{ title: 'Nama Ruang', key: 'namaRuang', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
]);
// Breadcrumbs
const breadcrumbs = ref([
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
]);
// State untuk tabel dan paginasi
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
// Computed properties untuk paginasi kustom
const pageCount = computed(() => {
return Math.ceil(allRuangData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allRuangData.value.length);
const total = allRuangData.value.length;
return `Showing ${start} to ${end} of ${total} entries`;
});
// State untuk dialog
const showDeleteDialog = ref(false);
const isEditMode = ref(false);
const editedIndex = ref(-1);
const editedItem = ref({
id: 0,
namaKlinik: '',
kode: '',
namaRuang: '',
});
// Fungsi untuk tombol aksi
const viewItem = (item) => {
editedItem.value = { ...item };
readOnly.value = true;
isEditMode.value = false;
showForm.value = true;
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
{ title: 'Detail Ruang Klinik', disabled: true, href: '/setting/viewruang' },
];
};
const editItem = (item) => {
editedIndex.value = allRuangData.value.findIndex(d => d.id === item.id);
editedItem.value = { ...item };
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
{ title: 'Edit Ruang Klinik', disabled: true, href: '/setting/editruang' },
];
};
const deleteItem = (item) => {
editedIndex.value = allRuangData.value.findIndex(d => d.id === item.id);
showDeleteDialog.value = true;
};
const confirmDelete = () => {
if (editedIndex.value > -1) {
allRuangData.value.splice(editedIndex.value, 1);
}
closeDeleteDialog();
};
const cancelForm = () => {
showForm.value = false;
isEditMode.value = false;
readOnly.value = false;
editedItem.value = {
id: 0,
namaKlinik: '',
kode: '',
namaRuang: '',
};
editedIndex.value = -1;
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
];
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
editedIndex.value = -1;
};
const saveItem = () => {
if (isEditMode.value) {
Object.assign(allRuangData.value[editedIndex.value], editedItem.value);
} else {
const newId = allRuangData.value.length > 0 ? Math.max(...allRuangData.value.map(item => item.id)) + 1 : 1;
editedItem.value.id = newId;
allRuangData.value.push(editedItem.value);
}
cancelForm();
};
</script>
<style scoped>
.master-klinik-ruang-page {
font-family: 'Roboto', sans-serif;
background-color: #f5f7fa;
min-height: 100vh;
}
.custom-table {
border: none;
}
.v-data-table :deep(th) {
font-weight: bold !important;
color: #333 !important;
}
.v-data-table :deep(td) {
vertical-align: middle;
}
.v-btn--icon {
border-radius: 8px;
}
.v-select :deep(.v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
</style>

496
pages/Setting/UserLogin.vue Normal file
View File

@@ -0,0 +1,496 @@
<template>
<div class="user-login-page pa-4">
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
{{ isEditMode ? 'Edit Pengguna' : readOnly ? 'Detail Pengguna' : 'Tambah Pengguna' }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama Lengkap</v-label>
<v-text-field
v-model="editedItem.namaLengkap"
placeholder="Masukkan Nama Lengkap"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama User</v-label>
<v-text-field
v-model="editedItem.namaUser"
placeholder="Masukkan Nama User"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Tipe User</v-label>
<v-select
v-model="editedItem.tipeUser"
:items="['Super Admin', 'Admin', 'Loket', 'Klinik', 'Admin Barcode', 'INOVA', 'Ranap', 'Report Only', 'Farmasi', 'Manager']"
placeholder="Pilih Tipe User"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Keterangan</v-label>
<v-text-field
v-model="editedItem.keterangan"
placeholder="Masukkan Keterangan"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Roles</v-label>
<v-select
v-model="editedItem.roles"
:items="availableRoles"
multiple
chips
placeholder="Pilih Roles Pengguna"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Groups</v-label>
<v-select
v-model="editedItem.groups"
:items="availableGroups"
multiple
chips
placeholder="Pilih Groups Pengguna"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
</v-row>
<v-row v-if="!readOnly && !isEditMode">
<v-col cols="12">
<v-label class="font-weight-bold">Password</v-label>
<v-text-field
v-model="editedItem.password"
placeholder="Masukkan Password"
type="password"
variant="outlined"
density="comfortable"
class="mb-2"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-start pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="cancelForm"
>
{{ readOnly ? 'Tutup' : 'Batal' }}
</v-btn>
<v-btn
v-if="!readOnly"
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="saveItem"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
<div v-else>
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-account-group-outline" class="mr-2" size="40"></v-icon>
<span>User Login</span>
</v-card-title>
<v-btn
color="success"
prepend-icon="mdi-plus"
rounded
class="text-capitalize"
@click="showAddForm"
>
Tambah User
</v-btn>
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-text>
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
variant="outlined"
density="compact"
hide-details
style="max-width: 80px;"
rounded
class="mr-2"
></v-select>
<span class="text-subtitle-1">entries</span>
</div>
<div class="d-flex align-center">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
variant="outlined"
density="compact"
hide-details
rounded
clearable
></v-text-field>
</div>
</div>
<v-data-table
:headers="headers"
:items="allUserData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
class="rounded-lg elevation-0 custom-table"
>
<template v-slot:[`item.roles`]="{ item }">
<v-chip
v-for="role in item.roles"
:key="role"
color="blue-lighten-1"
size="small"
class="mr-1 mb-1"
>
{{ role }}
</v-chip>
</template>
<template v-slot:[`item.groups`]="{ item }">
<v-chip
v-for="group in item.groups"
:key="group"
color="purple-lighten-1"
size="small"
class="mr-1 mb-1"
>
{{ group }}
</v-chip>
</template>
<template v-slot:[`item.actions`]="{ item }">
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="red" size="small" @click="deleteItem(item)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<template v-slot:bottom>
<v-row class="ma-2">
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
{{ showingEntriesText }}
</v-col>
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
<v-pagination
v-model="page"
:length="pageCount"
rounded="circle"
:total-visible="5"
></v-pagination>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
<!-- Custom Modal/Dialog for Confirmation -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card class="pa-4 rounded-lg">
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data **{{ itemToDelete?.namaLengkap }}**?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
>
{{ snackbar.message }}
</v-snackbar>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
// --- NEW DATA LISTS FOR SELECTION ---
const availableRoles = [
'User_Standard', 'User_Report', 'User_Klinik', 'User_Farmasi',
'Admin_UserManagement', 'Admin_System', 'Manager_View'
];
const availableGroups = [
'/RS/Klinik', '/RS/Loket', '/RS/Farmasi', '/RS/IT', '/RS/Management'
];
// ------------------------------------
// Define the core user data structure (without IDs initially)
const rawUserData = [
{ namaLengkap: 'LOKET 1', namaUser: 'loket1', tipeUser: 'Loket', keterangan: 'Loket 1', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'LOKET 2', namaUser: 'loket2', tipeUser: 'Loket', keterangan: 'Loket 2', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'LOKET 3', namaUser: 'loket3', tipeUser: 'Loket', keterangan: 'Loket 3', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'ANAK', namaUser: 'anak', tipeUser: 'Klinik', keterangan: 'ANAK', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
{ namaLengkap: 'ADMIN PRAM', namaUser: 'adminpram', tipeUser: 'Admin', keterangan: 'Administrator Utama', roles: ['Admin_UserManagement', 'User_Report'], groups: ['/RS/IT'] },
{ namaLengkap: 'Report Only', namaUser: 'laporan', tipeUser: 'Report Only', keterangan: 'Hanya melihat laporan', roles: ['User_Report'], groups: ['/RS/Management'] },
{ namaLengkap: 'Farmasi Utama', namaUser: 'farmasi_utama', tipeUser: 'Farmasi', keterangan: 'Apoteker Penanggung Jawab', roles: ['User_Farmasi'], groups: ['/RS/Farmasi'] },
{ namaLengkap: 'Super Admin User', namaUser: 'superadmin', tipeUser: 'Super Admin', keterangan: 'Full Control System', roles: ['Admin_System', 'Admin_UserManagement', 'User_Standard'], groups: ['/RS/IT', '/RS/Management'] },
// Adding more generic data to make the list longer and sequential
{ namaLengkap: 'Klinik Umum', namaUser: 'klinik_umum', tipeUser: 'Klinik', keterangan: 'Dokter Umum', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
{ namaLengkap: 'Manajer Keuangan', namaUser: 'manager_keu', tipeUser: 'Manager', keterangan: 'Pengelola Anggaran', roles: ['Manager_View', 'User_Report'], groups: ['/RS/Management'] },
];
// Map over the raw data to assign sequential IDs starting from 1
const allUserData = ref(rawUserData.map((item, index) => ({
...item,
id: index + 1
})));
// State untuk menampilkan/menyembunyikan formulir
const showForm = ref(false);
const readOnly = ref(false);
const headers = ref([
{ title: 'No', key: 'id' },
{ title: 'Nama Lengkap', key: 'namaLengkap', sortable: true },
{ title: 'Nama User', key: 'namaUser', sortable: true },
{ title: 'Tipe User', key: 'tipeUser', sortable: true },
{ title: 'Roles', key: 'roles', sortable: false },
{ title: 'Groups', key: 'groups', sortable: false },
{ title: 'Keterangan', key: 'keterangan', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
]);
// Breadcrumbs
const breadcrumbs = ref([
{ title: 'Dashboard', disabled: false, href: '#/dashboard' },
{ title: 'Setting', disabled: false, href: '#/setting' },
{ title: 'User Login', disabled: false, href: '#/setting/userlogin' },
]);
// State untuk tabel dan paginasi
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
const itemToDelete = ref(null);
// State untuk dialog dan form
const showDeleteDialog = ref(false);
const isEditMode = ref(false);
const editedIndex = ref(-1);
const snackbar = ref({
show: false,
message: '',
color: 'success',
timeout: 3000,
});
// Updated `editedItem` structure with new fields
const editedItem = ref({
id: 0,
namaLengkap: '',
namaUser: '',
tipeUser: '',
keterangan: '',
password: '',
roles: [],
groups: []
});
// Computed properties untuk paginasi kustom
const pageCount = computed(() => {
return Math.ceil(allUserData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allUserData.value.length);
const total = allUserData.value.length;
// Handle case where total is 0 (no data)
if (total === 0) return 'Showing 0 to 0 of 0 entries';
return `Showing ${start} to ${end} of ${total} entries`;
});
// FUNCTIONS
const resetForm = () => {
editedItem.value = {
id: 0,
namaLengkap: '',
namaUser: '',
tipeUser: '',
keterangan: '',
password: '',
roles: [],
groups: []
};
};
const showAddForm = () => {
resetForm();
isEditMode.value = false;
readOnly.value = false;
showForm.value = true;
};
const viewItem = (item) => {
// We copy the item data for view mode
editedItem.value = Object.assign({}, item);
isEditMode.value = false;
readOnly.value = true;
showForm.value = true;
};
const editItem = (item) => {
// Find the index of the item in the actual array
editedIndex.value = allUserData.value.findIndex(data => data.id === item.id);
// Copy the item data to the editedItem
editedItem.value = Object.assign({}, item);
// Clear password field for security (will only be set if user types a new one)
editedItem.value.password = '';
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
};
const saveItem = () => {
// Basic validation (can be expanded)
if (!editedItem.value.namaLengkap || !editedItem.value.namaUser || !editedItem.value.tipeUser) {
snackbar.value = { show: true, message: 'Nama Lengkap, Nama User, dan Tipe User wajib diisi!', color: 'error', timeout: 3000 };
return;
}
// Remove password from the final object for display purposes,
// as it should be securely handled in a real backend API.
const { password, ...itemData } = editedItem.value;
if (isEditMode.value) {
// Edit existing item
if (editedIndex.value > -1) {
Object.assign(allUserData.value[editedIndex.value], itemData);
snackbar.value = { show: true, message: 'Data pengguna berhasil diperbarui!', color: 'success', timeout: 3000 };
}
} else {
// Add new item: find the highest current ID and add 1
const maxId = allUserData.value.length > 0 ? Math.max(...allUserData.value.map(item => item.id)) : 0;
itemData.id = maxId + 1;
allUserData.value.push(itemData);
snackbar.value = { show: true, message: 'Pengguna baru berhasil ditambahkan!', color: 'success', timeout: 3000 };
}
cancelForm();
};
const deleteItem = (item) => {
itemToDelete.value = item;
showDeleteDialog.value = true;
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
itemToDelete.value = null;
};
const confirmDelete = () => {
if (itemToDelete.value) {
// Find the index of the item to delete
const index = allUserData.value.findIndex(data => data.id === itemToDelete.value.id);
if (index > -1) {
// Remove the item
allUserData.value.splice(index, 1);
// Re-index the remaining items to keep the 'id' column sequential visually
allUserData.value = allUserData.value.map((item, i) => ({
...item,
id: i + 1
}));
snackbar.value = { show: true, message: 'Data pengguna berhasil dihapus!', color: 'warning', timeout: 3000 };
}
}
closeDeleteDialog();
};
const cancelForm = () => {
showForm.value = false;
resetForm();
editedIndex.value = -1;
};
</script>
<style scoped>
/* Custom Table Styling for better visual separation */
.custom-table :deep(table) {
border-collapse: collapse;
}
.custom-table :deep(th) {
background-color: #f5f5f5 !important;
font-weight: bold;
font-size: 0.875rem; /* text-sm */
}
.custom-table :deep(td) {
padding-top: 12px !important;
padding-bottom: 12px !important;
}
</style>

View File

@@ -1,188 +1,174 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
definePageMeta({
middleware:['auth']
})
// Define a type for the data structure you now return from the API
interface SessionData {
user: {
name: string;
email: string;
// ... add other user fields
};
status: string;
createdAt: number;
expiresAt: number;
accessToken: string;
idToken: string;
refreshToken: string;
accessTokenPayload: any;
idTokenPayload: any;
fullSessionObject: any;
}
const sessionData = ref<SessionData | null>(null);
const loading = ref(true);
const authError = ref<any>(null);
// Computeds for easy display
const sessionExpiresDate = computed(() => {
if (!sessionData.value?.expiresAt) return 'N/A';
return new Date(sessionData.value.expiresAt).toLocaleString();
});
const sessionCreatedDate = computed(() => {
if (!sessionData.value?.createdAt) return 'N/A';
return new Date(sessionData.value.createdAt).toLocaleString();
});
const currentDateTime = computed(() => new Date().toLocaleString());
// Helper to display JSON data nicely
const formatJson = (data: any) => {
return JSON.stringify(data, null, 2);
};
onMounted(async () => {
try {
// Fetch the enhanced session data from your API
const data = await $fetch<SessionData>('/api/auth/session');
sessionData.value = data;
authError.value = null;
} catch (e: any) {
console.error('Failed to fetch session data:', e);
// Store the error status for display
authError.value = e.data?.statusMessage || 'Session check failed. Please log in.';
sessionData.value = null;
} finally {
loading.value = false;
}
});
</script>
<template> <template>
<v-main class="bg-grey-lighten-3"> <div class="container mx-auto p-4 max-w-4xl">
<!-- Konten utama dibungkus dalam div yang menyesuaikan padding kiri --> <h1 class="text-3xl font-bold mb-6 border-b pb-2">Complete Session Data Debug Page</h1>
<div :style="contentStyle">
<v-container fluid>
<p>Admin Anjungan</p>
<v-card class="pa-5 mb-5" color="white" flat>
<v-row align="center">
<v-col cols="12" md="4">
<v-text-field
label="Barcode"
placeholder="Masukkan Barcode"
outlined
dense
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-chip color="#B71C1C" class="text-caption">
Tekan Enter. (Barcode depan nomor selalu ada huruf lain, Ex:
J20073010005 "Hiraukan huruf 'J' nya")
</v-chip>
</v-col>
<v-col cols="12" md="2">
<v-btn block color="info">Pendaftaran Online</v-btn>
</v-col>
</v-row>
</v-card>
<v-divider class="my-5"></v-divider> <div v-if="loading" class="text-center p-8">
<p class="text-xl">Loading session data...</p>
<v-card class="mb-5">
<v-toolbar flat color="transparent" dense>
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
DATA PENGUNJUNG TERLAMBAT
</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="searchLate"
append-icon="mdi-magnify"
label="Search"
single-line
hide-details
dense
class="mr-2"
variant="outlined"
></v-text-field>
<v-select
:items="[10, 25, 50, 100]"
label="Show"
dense
single-line
hide-details
class="shrink"
variant="outlined"
></v-select>
</v-toolbar>
<v-card-text>
<v-data-table
:headers="lateHeaders"
:items="lateVisitors"
:search="searchLate"
no-data-text="No data available in table"
hide-default-footer
class="elevation-1"
></v-data-table>
<div class="d-flex justify-end pt-2">
<v-pagination
v-model="page"
:length="10"
:total-visible="5"
></v-pagination>
</div>
</v-card-text>
</v-card>
<v-divider class="my-5"></v-divider>
<v-card>
<v-toolbar flat color="transparent" dense>
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
DATA PENGUNJUNG
</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="search"
append-icon="mdi-magnify"
label="Search"
single-line
hide-details
dense
class="mr-2"
variant="outlined"
></v-text-field>
<v-select
:items="[10, 25, 50, 100]"
label="Show"
dense
single-line
hide-details
class="shrink"
variant="outlined"
></v-select>
</v-toolbar>
<v-card-text>
<v-data-table
:headers="headers"
:items="visitors"
:search="search"
no-data-text="No data available in table"
class="elevation-1"
>
<template v-slot:item.aksi="{ item }">
<div class="d-flex flex-column">
<v-btn small color="success" class="my-1">Tiket</v-btn>
<v-btn small color="primary" class="my-1">Tiket Pengantar</v-btn>
<v-btn small color="warning" class="my-1">ByPass</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-container>
</div> </div>
</v-main>
<div v-else-if="authError" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<strong class="font-bold">Authentication Error:</strong>
<span class="block sm:inline">{{ authError }}</span>
<p class="mt-2">If you expect to be logged in, the session may have expired or the cookie is missing/invalid.</p>
<NuxtLink to="/LoginPage" class="text-blue-600 hover:underline">Go to Login Page</NuxtLink>
</div>
<div v-else-if="sessionData">
<section class="mb-6 border p-4 rounded-lg bg-gray-50">
<h2 class="text-xl font-semibold mb-3">Basic User Information</h2>
<div class="grid grid-cols-2 gap-2 text-sm">
<p><strong>Name:</strong> {{ sessionData.user.name }}</p>
<p><strong>Email:</strong> {{ sessionData.user.email }}</p>
<p><strong>Status:</strong> <span class="text-green-600 font-medium">{{ sessionData.status }}</span></p>
<p><strong>Session Expires:</strong> {{ sessionExpiresDate }}</p>
<p><strong>Created At:</strong> {{ sessionCreatedDate }}</p>
</div>
</section>
<section class="mb-6 border p-4 rounded-lg">
<h2 class="text-xl font-semibold mb-3">Token Information (Raw)</h2>
<div class="space-y-3">
<details>
<summary class="cursor-pointer font-medium hover:text-blue-600">ID Token (session.idToken)</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.idToken }}</pre>
</details>
<details>
<summary class="cursor-pointer font-medium hover:text-blue-600">Access Token (session.accessToken)</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.accessToken }}</pre>
</details>
<details>
<summary class="cursor-pointer font-medium hover:text-blue-600">Refresh Token (session.refreshToken)</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.refreshToken || 'N/A' }}</pre>
</details>
</div>
</section>
<section class="mb-6 border p-4 rounded-lg">
<h2 class="text-xl font-semibold mb-3">Parsed Token Payloads</h2>
<div class="space-y-3">
<details open>
<summary class="cursor-pointer font-medium hover:text-blue-600">Access Token Payload</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.accessTokenPayload) }}</pre>
</details>
<details>
<summary class="cursor-pointer font-medium hover:text-blue-600">ID Token Payload</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.idTokenPayload) }}</pre>
</details>
</div>
</section>
<section class="mb-6 border p-4 rounded-lg">
<h2 class="text-xl font-semibold mb-3">Complete Raw Session Data (Debug)</h2>
<details>
<summary class="cursor-pointer font-medium hover:text-blue-600">Full Session Object</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.fullSessionObject) }}</pre>
</details>
</section>
<section class="mb-6 p-4 rounded-lg border-t-2 border-dashed">
<h2 class="text-xl font-semibold mb-3">Session Timeline</h2>
<ul class="space-y-2">
<li class="flex items-center space-x-2">
<span class="w-2 h-2 bg-black rounded-full"></span>
<p><strong>Session Created:</strong> {{ sessionCreatedDate }}</p>
</li>
<li class="flex items-center space-x-2">
<span class="w-2 h-2 bg-black rounded-full"></span>
<p><strong>Current Time:</strong> {{ currentDateTime }}</p>
</li>
<li class="flex items-center space-x-2">
<span class="w-2 h-2 bg-black rounded-full"></span>
<p><strong>Session Expires:</strong> {{ sessionExpiresDate }}</p>
</li>
</ul>
</section>
</div>
</div>
</template> </template>
<script setup> <style scoped>
import { ref, computed } from "vue"; /* Optional: Basic styling for better visibility */
summary {
// Definisikan props untuk menerima status 'rail' dari layout induk list-style: none; /* Removes the default arrow */
const props = defineProps({ display: block; /* Allows summary to span full width */
rail: Boolean, padding: 0.5rem 0;
}); }
/* Adds a custom arrow/chevron */
// Reactive data summary::before {
const search = ref(""); content: "▶";
const searchLate = ref(""); margin-right: 0.5em;
const page = ref(1); transition: transform 0.2s;
display: inline-block;
// Gaya komputasi untuk menyesuaikan padding }
const contentStyle = computed(() => { details[open] summary::before {
return { content: "▼";
paddingLeft: props.rail ? '56px' : '256px', /* transform: rotate(90deg); */
transition: 'padding-left 0.3s ease-in-out', }
}; </style>
});
// Table headers for late visitors
const lateHeaders = [
{ text: 'No', value: 'no' },
{ text: 'Barcode', value: 'barcode' },
{ text: 'No Rekamedik', value: 'noRekamedik' },
{ text: 'No Antrian', value: 'noAntrian' },
{ text: 'No Antrian Klinik', value: 'noAntrianKlinik' },
{ text: 'Shift', value: 'shift' },
{ text: 'Pembayaran', value: 'pembayaran' },
{ text: 'Status', value: 'status' },
];
// Table headers for all visitors
const headers = [
{ text: 'No', value: 'no' },
{ text: 'Barcode', value: 'barcode' },
{ text: 'No Rekamedik', value: 'noRekamedik' },
{ text: 'No Antrian', value: 'noAntrian' },
{ text: 'Shift', value: 'shift' },
{ text: 'Ket', value: 'ket' },
{ text: 'Fast Track', value: 'fastTrack' },
{ text: 'Pembayaran', value: 'pembayaran' },
{ text: 'Panggil', value: 'panggil' },
{ text: 'Aksi', value: 'aksi' },
];
// Mock data for late visitors
const lateVisitors = ref([
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', noAntrianKlinik: 'K1', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', noAntrianKlinik: 'K2', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', noAntrianKlinik: 'K3', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
]);
// Mock data for all visitors
const visitors = ref([
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', shift: 'Shift 1', ket: '', fastTrack: 'Ya', pembayaran: 'JKN', panggil: 'Ya' },
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
]);
</script>

BIN
public/DSC03847-scaled.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,25 @@
// server/api/auth/[...].ts
import { NuxtAuthHandler } from '#auth'
export default NuxtAuthHandler({
secret: useRuntimeConfig().authSecret,
providers: [
{
id: 'keycloak',
name: 'Keycloak',
type: 'oidc',
issuer: useRuntimeConfig().keycloakIssuer,
clientId: useRuntimeConfig().keycloakClientId,
clientSecret: useRuntimeConfig().keycloakClientSecret,
checks: ['pkce', 'state'],
profile(profile) {
return {
id: profile.sub,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: profile.picture,
}
},
}
]
})

View File

@@ -0,0 +1,141 @@
// server/api/auth/keycloak-callback.ts - FIX APPLIED
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig();
const query = getQuery(event);
console.log('🔄 === KEYCLOAK CALLBACK STARTED ===');
console.log('📋 Query parameters:', query);
const code = query.code as string;
const state = query.state as string;
const error = query.error as string;
const storedState = getCookie(event, 'oauth_state');
if (error) {
console.error('❌ OAuth error from Keycloak:', error);
const errorDescription = query.error_description as string;
console.error('❌ Error description:', errorDescription);
const errorMsg = encodeURIComponent(`Keycloak error: ${error} - ${errorDescription || 'Please try again'}`);
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
console.log('📝 Code received:', !!code);
console.log('🎲 State from URL:', state);
console.log('🎲 State from cookie:', storedState);
console.log('🎲 State validation:', state === storedState);
if (!state || state !== storedState) {
console.error('❌ Invalid state parameter - possible CSRF attack');
console.error('   Expected:', storedState);
console.error('   Received:', state);
const errorMsg = encodeURIComponent('Security validation failed. Please try logging in again.');
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
deleteCookie(event, 'oauth_state');
if (!code) {
console.error('❌ Authorization code not provided');
const errorMsg = encodeURIComponent('No authorization code received from Keycloak.');
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
const tokenUrl = `${config.keycloakIssuer}/protocol/openid-connect/token`;
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`;
// ... (Token exchange logic remains the same) ...
const tokenPayload = new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.keycloakClientId,
client_secret: config.keycloakClientSecret,
code,
redirect_uri: redirectUri,
});
const tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: tokenPayload,
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
console.error('❌ Token exchange failed:', errorText);
const errorMsg = encodeURIComponent(`Token exchange failed: ${tokenResponse.status} - Please check Keycloak configuration`);
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
const tokens = await tokenResponse.json();
// ... (Token decoding and sessionData creation remains the same) ...
let idTokenPayload;
try {
idTokenPayload = JSON.parse(
Buffer.from(tokens.id_token.split('.')[1], 'base64').toString()
);
} catch (decodeError) {
console.error('❌ Failed to decode ID token:', decodeError);
const errorMsg = encodeURIComponent('Invalid ID token format');
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
const sessionData = {
user: {
id: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name || idTokenPayload.preferred_username,
preferred_username: idTokenPayload.preferred_username,
given_name: idTokenPayload.given_name,
family_name: idTokenPayload.family_name,
},
accessToken: tokens.access_token,
idToken: tokens.id_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + (tokens.expires_in * 1000),
createdAt: Date.now(),
};
// ----------------------------------------------------
// 👇 CRITICAL FIX FOR DEPLOYED HTTPS ENVIRONMENTS 👇
// ----------------------------------------------------
// Check if the request was originally HTTPS (via proxy)
const isSecure = process.env.NODE_ENV === 'production' ||
event.node.req.headers['x-forwarded-proto'] === 'https';
console.log('🔗 Setting session cookie with secure flag:', isSecure);
setCookie(event, 'user_session', JSON.stringify(sessionData), {
httpOnly: true,
// CRITICAL: Must be TRUE when operating over HTTPS (deployed)
secure: isSecure,
// Ensures cookie is sent on cross-site redirects (Keycloak -> Your App)
sameSite: 'lax',
maxAge: tokens.expires_in,
path: '/',
});
console.log('✅ Session cookie created successfully');
// Note: The following line will still log false because the cookie
// is in the response header, not the request header yet. This is expected.
const testCookie = getCookie(event, 'user_session');
console.log('🧪 Cookie test - can read back in this handler (Expected False):', !!testCookie);
console.log('↪️ Redirecting to dashboard...');
return sendRedirect(event, '/dashboard?authenticated=true');
} catch (error: any) {
console.error('❌ === CALLBACK ERROR ===');
console.error('❌ Error message:', error.message);
console.error('❌ Error stack:', error.stack);
console.error('❌ ==================');
const errorMsg = encodeURIComponent(`Authentication failed: ${error.message}`);
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
});

View File

@@ -0,0 +1,65 @@
// server/api/auth/keycloak-login.ts
import { randomBytes } from 'crypto'
export default defineEventHandler(async (event) => {
console.log('🔐 Keycloak Login Handler Called')
console.log('📍 Method:', getMethod(event))
try {
const config = useRuntimeConfig()
// Debug: Log runtime config (without secrets)
console.log('🔧 Runtime Config Check:')
console.log(' - Has keycloakIssuer:', !!config.keycloakIssuer)
console.log(' - Has keycloakClientId:', !!config.keycloakClientId)
console.log(' - Has keycloakSecret:', !!config.keycloakClientSecret)
console.log(' - Issuer value:', config.keycloakIssuer)
// Validate required configuration
if (!config.keycloakIssuer) {
throw new Error('KEYCLOAK_ISSUER is not configured')
}
if (!config.keycloakClientId) {
throw new Error('KEYCLOAK_CLIENT_ID is not configured')
}
// Generate state parameter for security
const state = randomBytes(32).toString('hex')
console.log('🎲 Generated state:', state.substring(0, 8) + '...')
// Store state in session cookie
setCookie(event, 'oauth_state', state, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 600 // 10 minutes
})
// Build Keycloak authorization URL
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`
const authUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/auth`)
authUrl.searchParams.set('client_id', config.keycloakClientId)
authUrl.searchParams.set('redirect_uri', redirectUri)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', 'openid profile email')
authUrl.searchParams.set('state', state)
console.log('🏗️ Auth URL built:', authUrl.toString())
return {
success: true,
data: {
authUrl: authUrl.toString()
}
}
} catch (error: any) {
console.error('❌ Login Error:', error.message)
throw createError({
statusCode: 500,
statusMessage: `Failed to generate authorization URL: ${error.message}`
})
}
})

View File

@@ -0,0 +1,74 @@
// server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig();
console.log('🚪 Logout handler called');
// Get the current session to retrieve tokens
const sessionCookie = getCookie(event, 'user_session');
let idToken = null;
if (sessionCookie) {
try {
const session = JSON.parse(sessionCookie);
idToken = session.idToken;
console.log('🔑 ID token found in session:', !!idToken);
} catch (error) {
console.warn('⚠️ Could not parse session cookie:', error);
}
} else {
console.warn('⚠️ No session cookie found');
}
// Clear all auth-related cookies
console.log('🧹 Clearing session cookies...');
deleteCookie(event, 'user_session');
deleteCookie(event, 'oauth_state');
// Also clear with different path variations to be thorough
deleteCookie(event, 'user_session', { path: '/' });
deleteCookie(event, 'oauth_state', { path: '/' });
console.log('✅ Session cleared successfully');
// Construct the Keycloak logout URL with proper parameters
const logoutUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/logout`);
// Add required parameters for proper Keycloak logout - REDIRECT TO LOGIN PAGE
logoutUrl.searchParams.set('client_id', config.keycloakClientId);
logoutUrl.searchParams.set('post_logout_redirect_uri', `${config.public.authUrl}/LoginPage?logout=success`);
// If we have an ID token, add it for proper session termination
if (idToken) {
logoutUrl.searchParams.set('id_token_hint', idToken);
console.log('🔑 Added id_token_hint to logout URL');
} else {
console.warn('⚠️ No ID token available for logout hint');
}
console.log('🔗 Keycloak logout URL constructed:', logoutUrl.toString());
// Return the logout URL to the client for redirect
// This approach gives better control to the client-side code
return {
success: true,
logoutUrl: logoutUrl.toString(),
message: 'Session cleared successfully'
};
} catch (error: any) {
console.error('❌ Logout error:', error);
console.error('❌ Error stack:', error.stack);
// Even if there's an error, try to provide a basic logout URL - REDIRECT TO LOGIN PAGE
const config = useRuntimeConfig();
const fallbackLogoutUrl = `${config.keycloakIssuer}/protocol/openid-connect/logout?client_id=${config.keycloakClientId}&post_logout_redirect_uri=${encodeURIComponent(config.public.authUrl + '/LoginPage?logout=success')}`;
return {
success: false,
logoutUrl: fallbackLogoutUrl,
error: 'Logout encountered an error, but providing fallback logout URL',
message: error.message
};
}
});

View File

@@ -0,0 +1,95 @@
// server/api/auth/session.get.ts
// Helper function to safely decode the JWT payload (Access Token or ID Token)
const decodeTokenPayload = (token: string | undefined): any | null => {
if (!token) return null;
try {
// Tokens are base64 encoded and separated by '.'
const parts = token.split('.');
if (parts.length < 2) return null; // Not a valid JWT format
const payloadBase64 = parts[1];
// Decode from base64 and parse the JSON
// Note: Using Buffer.from is standard in Node.js server environments (like Nitro/H3)
return JSON.parse(Buffer.from(payloadBase64, 'base64').toString());
} catch (e) {
console.error('❌ Failed to decode token payload:', e);
return null;
}
};
// --- START OF THE SINGLE EXPORT DEFAULT HANDLER ---
export default defineEventHandler(async (event) => {
console.log('🔍 Session endpoint called');
const sessionCookie = getCookie(event, 'user_session');
console.log('🍪 Session cookie exists:', !!sessionCookie);
if (!sessionCookie) {
console.log('❌ No session cookie found');
throw createError({
statusCode: 401,
statusMessage: 'No session cookie found'
});
}
try {
const session = JSON.parse(sessionCookie);
console.log('📋 Session parsed successfully');
const isExpired = Date.now() > session.expiresAt;
console.log('   Is Expired:', isExpired);
// Check if the token has expired
if (isExpired) {
console.log('⏰ Session has expired, clearing cookie');
deleteCookie(event, 'user_session');
throw createError({
statusCode: 401,
statusMessage: 'Session expired'
});
}
// Decode tokens and prepare the enhanced response data
const idTokenPayload = decodeTokenPayload(session.idToken);
const accessTokenPayload = decodeTokenPayload(session.accessToken);
// Final response object for the frontend debug page
const sessionResponse = {
// Basic User Info
user: session.user,
// Raw Tokens
idToken: session.idToken,
accessToken: session.accessToken,
refreshToken: session.refreshToken,
// Session Timestamps
expiresAt: session.expiresAt,
createdAt: session.createdAt,
// Parsed Payloads
idTokenPayload: idTokenPayload,
accessTokenPayload: accessTokenPayload,
// Raw Session Data (for Debug section)
fullSessionObject: session,
status: 'authenticated',
};
console.log('✅ Session is valid, returning full session data');
return sessionResponse;
} catch (parseError) {
console.error('❌ Failed to parse session cookie:', parseError);
// If JSON parsing fails or any other error occurs, the session is invalid
deleteCookie(event, 'user_session');
throw createError({
statusCode: 401,
statusMessage: 'Invalid session data'
});
}
});
// --- END OF THE SINGLE EXPORT DEFAULT HANDLER ---

82
stores/navItems.ts Normal file
View File

@@ -0,0 +1,82 @@
// stores/navItems.ts
import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core';
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
// Initial default navigation items
const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
// Add other main menu items
{ id: 2, name: "Loket Admin", icon: "mdi-account-supervisor", path: "/LoketAdmin" },
{ id: 3, name: "Ranap Admin", icon: "mdi-bed", path: "/RanapAdmin" },
{ id: 4, name: "Klinik Admin", icon: "mdi-hospital-box", path: "/KlinikAdmin" },
{ id: 5, name: "Klinik Ruang Admin", icon: "mdi-hospital-marker", path: "/KlinikRuangAdmin" },
{
id: 6,
name: "Anjungan",
icon: "mdi-account-box-multiple",
path: "",
children: [
{ id: 7, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-account-box" },
{ id: 8, name: "Admin Anjungan", path: "/Anjungan/AdminAnjungan", icon: "mdi-account-cog" },
],
},
{ id: 9, name: "Fast Track", icon: "mdi-clock-fast", path: "/FastTrack" },
{ id: 10, name: "Data Pasien", icon: "mdi-account-multiple", path: "/DataPasien" },
{
id: 11,
name: "Screen",
icon: "mdi-monitor",
path: "",
children: [
{ id: 12, name: "Antrian Masuk 1", path: "/Screen/Antrian Masuk 1", icon: "mdi-monitor" },
{ id: 13, name: "Antrian Masuk 2", path: "/Screen/Antrian Masuk 2", icon: "mdi-monitor" },
// ... more screen pages
],
},
{ id: 14, name: "List Pasien", icon: "mdi-format-list-bulleted", path: "/ListPasien" },
{
id: 15 ,
name: "Setting",
icon: "mdi-cog",
path: "",
children: [
{ id: 16, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-shield-lock-outline" },
{ id: 17, name: "User Login", path: "/setting/UserLogin", icon: "mdi-account-circle" },
{ id: 18, name: "Master Loket", path: "/setting/MasterLoket", icon: "mdi-counter" },
{ id: 19, name: "Master Klinik", path: "/setting/MasterKlinik", icon: "mdi-hospital" },
{ id: 20, name: "Master Klinik Ruang", path: "/setting/MasterKlinikRuang", icon: "mdi-hospital-box" },
{ id: 21, name: "Screen", path: "/setting/Screen", icon: "mdi-monitor" },
],
},
];
export const useNavItemsStore = defineStore('navItems', () => {
const navItems = useLocalStorage<NavItem[]>('navItems', defaultNavItems);
function updateNavItems(newItems: NavItem[]) {
// This will update the local storage and the state
navItems.value = newItems.map((item, index) => ({
...item,
id: index + 1
}));
}
function addNavItem(newItem: Omit<NavItem, 'id'>) {
const newId = navItems.value.length > 0 ? Math.max(...navItems.value.map(item => item.id)) + 1 : 1;
navItems.value.push({ ...newItem, id: newId });
}
return {
navItems,
updateNavItems,
addNavItem,
};
});

66
types/auth.ts Normal file
View File

@@ -0,0 +1,66 @@
// types/auth.ts - Enhanced with better error handling and optional fields
export interface User {
id: string
email?: string
name?: string
preferred_username?: string
given_name?: string
family_name?: string
roles?: string[]
realm_access?: {
roles: string[]
}
// Add any other Keycloak user properties you might need
}
export interface SessionResponse {
success?: boolean // Add success indicator
user: User | null
accessToken?: string
refreshToken?: string // Often useful to track
expiresAt?: number
error?: string // For error cases
}
export interface LoginResponse {
success: boolean
data?: {
authUrl: string
}
error?: string // Add error message support
message?: string
}
export interface LogoutResponse {
success: boolean
logoutUrl?: string // Make optional in case of errors
error?: string
message?: string
}
// Additional utility types for auth state
export interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
}
// Token information interface
export interface TokenInfo {
accessToken: string
refreshToken?: string
idToken?: string
expiresAt: number
tokenType?: string
}
// Keycloak specific user info (if you need more detailed typing)
export interface KeycloakUserInfo extends User {
sub: string
email_verified?: boolean
preferred_username?: string
given_name?: string
family_name?: string
name?: string
}