Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9261df87e0 | ||
|
|
d735824d57 | ||
|
|
55d8920a82 | ||
|
|
81398549e2 | ||
|
|
0ad29ae5fa |
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<v-menu
|
||||
v-model="menu"
|
||||
:close-on-content-click="false"
|
||||
location="bottom right"
|
||||
origin="top right"
|
||||
transition="slide-y-transition"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon v-bind="props">
|
||||
<v-avatar size="40">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card class="rounded-lg elevation-4 pa-4" width="300">
|
||||
<div class="d-flex align-center pb-2">
|
||||
<v-avatar size="48">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
<div class="ml-4">
|
||||
<div class="text-subtitle-1 font-weight-bold">
|
||||
{{ user?.name || user?.preferred_username || 'User' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-darken-1">
|
||||
{{ user?.email || 'No email' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-darken-2">
|
||||
ID: {{ user?.id?.substring(0, 8) }}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider class="my-2"></v-divider>
|
||||
|
||||
<v-list dense>
|
||||
<v-list-item link class="rounded-lg" @click="handleAction('account')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-cog</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Pengaturan Akun</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item link class="rounded-lg" @click="handleAction('darkMode')">
|
||||
<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"
|
||||
:disabled="isLoggingOut"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="red">mdi-logout</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ isLoggingOut ? 'Logging out...' : 'Keluar' }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const menu = ref(false);
|
||||
const darkMode = ref(false);
|
||||
const isLoggingOut = ref(false);
|
||||
const emit = defineEmits(['logout']);
|
||||
|
||||
/**
|
||||
* Handles the logout action - delegates to parent
|
||||
*/
|
||||
const signOut = async () => {
|
||||
if (isLoggingOut.value) return;
|
||||
|
||||
isLoggingOut.value = true;
|
||||
menu.value = false;
|
||||
|
||||
try {
|
||||
console.log('🚪 ProfilePopup signOut called...')
|
||||
emit('logout');
|
||||
} 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>
|
||||
|
||||
<style scoped>
|
||||
.text-red {
|
||||
color: rgb(244, 67, 54) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -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> -->
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!-- layouts/default.vue -->
|
||||
<template>
|
||||
<v-app id="inspire">
|
||||
<AppBar @toggle-rail="rail = !rail" />
|
||||
<SideBar :items="navItemsStore.navItems" v-model:drawer="drawer" :rail="rail" />
|
||||
|
||||
<v-main app>
|
||||
<slot />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect } from "vue";
|
||||
import AppBar from "../components/AppBar.vue";
|
||||
import SideBar from "../components/SideBar.vue";
|
||||
import { useNavItemsStore } from '@/stores/navItems'; // Import the new store
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const drawer = ref(true);
|
||||
const rail = ref(true);
|
||||
|
||||
const navItemsStore = useNavItemsStore();
|
||||
|
||||
// Your logic to check user access and filter the menu can go here
|
||||
// For example:
|
||||
// const filteredItems = computed(() => {
|
||||
// return navItemsStore.navItems.filter(item => userHasAccess(item.path));
|
||||
// });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Global styles for layout */
|
||||
</style>
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
+21
-3
@@ -1,4 +1,6 @@
|
||||
// nuxt.config.ts
|
||||
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-05-15',
|
||||
devtools: { enabled: true },
|
||||
@@ -12,6 +14,8 @@ export default defineNuxtConfig({
|
||||
'@nuxt/scripts',
|
||||
'@nuxt/test-utils',
|
||||
'@nuxt/ui',
|
||||
'@pinia/nuxt',
|
||||
// Remove '@sidebase/nuxt-auth' completely
|
||||
(_options, nuxt) => {
|
||||
nuxt.hooks.hook('vite:extendConfig', (config) => {
|
||||
// @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: {
|
||||
transpile: ['vuetify'] // Important for Nuxt 3 with Vuetify
|
||||
transpile: ['vuetify']
|
||||
},
|
||||
css: [
|
||||
'vuetify/lib/styles/main.sass', // Or 'vuetify/styles' depending on version
|
||||
'vuetify/lib/styles/main.sass',
|
||||
'@mdi/font/css/materialdesignicons.min.css',
|
||||
],
|
||||
vite: {
|
||||
@@ -31,7 +49,7 @@ export default defineNuxtConfig({
|
||||
noExternal: ['vuetify']
|
||||
},
|
||||
plugins: [
|
||||
vuetify({ autoImport: true }) // If using vite-plugin-vuetify
|
||||
vuetify({ autoImport: true })
|
||||
]
|
||||
}
|
||||
})
|
||||
Generated
+2309
-1906
File diff suppressed because it is too large
Load diff
+7
-1
@@ -11,18 +11,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@nuxt/content": "^3.6.3",
|
||||
"@nuxt/content": "^2.7.2",
|
||||
"@nuxt/eslint": "^1.7.1",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@nuxt/scripts": "^0.11.10",
|
||||
"@nuxt/test-utils": "^3.19.2",
|
||||
"@nuxt/ui": "^3.3.0",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@unhead/vue": "^2.0.13",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"eslint": "^9.32.0",
|
||||
"nuxt": "^3.17.7",
|
||||
"pinia": "^3.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-draggable-next": "^2.3.0",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<v-divider class="my-8"></v-divider>
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<!-- Konten utama dibungkus dalam div yang menyesuaikan padding kiri -->
|
||||
<div :style="contentStyle">
|
||||
<v-container fluid>
|
||||
<h1 class="text-h4">Admin Anjungan</h1>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
// Definisikan props untuk menerima status 'rail' dari layout induk
|
||||
const props = defineProps({
|
||||
rail: Boolean,
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const search = ref("");
|
||||
const searchLate = ref("");
|
||||
const page = ref(1);
|
||||
|
||||
// Gaya komputasi untuk menyesuaikan padding
|
||||
const contentStyle = computed(() => {
|
||||
return {
|
||||
paddingLeft: props.rail ? '56px' : '64px',
|
||||
transition: 'padding-left 0.3s ease-in-out',
|
||||
};
|
||||
});
|
||||
|
||||
// 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<v-container fluid class="pa-6">
|
||||
|
||||
<!-- Colored Header with Quota Chip -->
|
||||
<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 }">
|
||||
<v-chip
|
||||
v-bind="props"
|
||||
class="text-white px-4 py-2"
|
||||
color="green-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-icon start>mdi-chair-rolling</v-icon>
|
||||
Max Quota Bangku 0
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-card>
|
||||
|
||||
<!-- Loket Admin Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Loket Admin
|
||||
<div>
|
||||
<v-btn
|
||||
color="green-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 mr-2 btn-call-group"
|
||||
@click="handleCallClick(1)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-1-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 1 Antrian</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="blue-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 mr-2 btn-call-group"
|
||||
@click="handleCallClick(5)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-5-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 5 Antrian</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="orange-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 mr-2 btn-call-group"
|
||||
@click="handleCallClick(10)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-10-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 10 Antrian</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="red-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 btn-call-group"
|
||||
@click="handleCallClick(20)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-20-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 20 Antrian</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<!-- 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>
|
||||
<tr>
|
||||
<th v-for="header in loketHeaders" :key="header.text">
|
||||
{{ header.text }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in paginatedLoketData" :key="index">
|
||||
<td>{{ item.no }}</td>
|
||||
<td>{{ item.barcode }}</td>
|
||||
<td>{{ item.noRekamedik }}</td>
|
||||
<td>{{ item.noAntrian }}</td>
|
||||
<td>{{ item.shift }}</td>
|
||||
<td>{{ item.ket }}</td>
|
||||
<td>{{ item.fastTrack }}</td>
|
||||
<td>{{ item.pembayaran }}</td>
|
||||
<td>
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||
<span>
|
||||
Menampilkan {{ (currentPageLoket - 1) * itemsPerPageLoket + 1 }} hingga
|
||||
{{ Math.min(currentPageLoket * itemsPerPageLoket, loketData.length) }} dari
|
||||
{{ loketData.length }} entri
|
||||
</span>
|
||||
<div>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPageLoket === 1" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket - 1)">Previous</v-btn>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPageLoket >= totalPagesLoket" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket + 1)">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Data Pengunjung Table -->
|
||||
<v-card class="pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
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>
|
||||
<tr>
|
||||
<th v-for="header in pengunjungHeaders" :key="header.text">
|
||||
{{ header.text }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in paginatedPengunjungData" :key="index">
|
||||
<td>{{ item.no }}</td>
|
||||
<td>{{ item.barcode }}</td>
|
||||
<td>{{ item.noRekamedik }}</td>
|
||||
<td>{{ item.noAntrian }}</td>
|
||||
<td>{{ item.noAntrianKlinik }}</td>
|
||||
<td>{{ item.shift }}</td>
|
||||
<td>{{ item.pembayaran }}</td>
|
||||
<td>
|
||||
<v-chip :color="item.status === 'Selesai' ? 'green' : 'orange'" size="small">
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||
<span>
|
||||
Menampilkan {{ (currentPagePengunjung - 1) * itemsPerPagePengunjung + 1 }} hingga
|
||||
{{ Math.min(currentPagePengunjung * itemsPerPagePengunjung, pengunjungData.length) }} dari
|
||||
{{ pengunjungData.length }} entri
|
||||
</span>
|
||||
<div>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPagePengunjung === 1" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung - 1)">Previous</v-btn>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPagePengunjung >= totalPagesPengunjung" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung + 1)">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// Generate dummy data
|
||||
const generateDummyData = (count) => {
|
||||
const data = [];
|
||||
const shiftOptions = ['Pagi', 'Siang', 'Sore'];
|
||||
const pembayaranOptions = ['BPJS', 'Umum', 'Asuransi'];
|
||||
const statusOptions = ['Menunggu', 'Selesai', 'Di-cancel'];
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
data.push({
|
||||
no: i,
|
||||
barcode: `B${1000 + i}`,
|
||||
noRekamedik: `RM${100 + i}`,
|
||||
noAntrian: `A${100 + i}`,
|
||||
noAntrianKlinik: `K${100 + i}`,
|
||||
shift: shiftOptions[Math.floor(Math.random() * shiftOptions.length)],
|
||||
ket: "Dummy Data",
|
||||
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;
|
||||
};
|
||||
|
||||
// State for Loket Admin table
|
||||
const loketData = ref(generateDummyData(50));
|
||||
const currentPageLoket = ref(1);
|
||||
const itemsPerPageLoket = ref(10);
|
||||
|
||||
// State for Data Pengunjung table
|
||||
const pengunjungData = ref(generateDummyData(50));
|
||||
const currentPagePengunjung = ref(1);
|
||||
const itemsPerPagePengunjung = ref(10);
|
||||
|
||||
// Computed properties for Loket Admin table
|
||||
const paginatedLoketData = computed(() => {
|
||||
const start = (currentPageLoket.value - 1) * itemsPerPageLoket.value;
|
||||
const end = start + itemsPerPageLoket.value;
|
||||
return loketData.value.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPagesLoket = computed(() => {
|
||||
return Math.ceil(loketData.value.length / itemsPerPageLoket.value);
|
||||
});
|
||||
|
||||
// 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 = [
|
||||
{ text: 'No' },
|
||||
{ text: 'Barcode' },
|
||||
{ text: 'No Rekamedik' },
|
||||
{ text: 'No Antrian' },
|
||||
{ text: 'Shift' },
|
||||
{ text: 'Ket' },
|
||||
{ text: 'Fast Track' },
|
||||
{ text: 'Pembayaran' },
|
||||
{ text: 'Panggil' },
|
||||
{ text: 'Aksi' },
|
||||
];
|
||||
|
||||
const pengunjungHeaders = [
|
||||
{ text: 'No' },
|
||||
{ text: 'Barcode' },
|
||||
{ text: 'No Rekamedik' },
|
||||
{ text: 'No Antrian' },
|
||||
{ text: 'No Antrian Klinik' },
|
||||
{ text: 'Shift' },
|
||||
{ text: 'Pembayaran' },
|
||||
{ text: 'Status' },
|
||||
];
|
||||
|
||||
const handleCallClick = (value) => {
|
||||
console.log(`Panggil ${value} antrian diklik!`);
|
||||
};
|
||||
|
||||
const handlePanggil = (item) => {
|
||||
console.log('Panggil pasien:', item);
|
||||
};
|
||||
|
||||
const handleBatalkan = (item) => {
|
||||
console.log('Batalkan pasien:', item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Main container padding */
|
||||
.v-container {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.header-banner {
|
||||
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>
|
||||
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<v-container fluid class="pa-6">
|
||||
|
||||
<!-- Colored Header Banner -->
|
||||
<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-clipboard-list-outline</v-icon>
|
||||
<h1 class="text-h4 font-weight-bold text-white">Klinik Ruang Admin</h1>
|
||||
</v-card>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card class="pa-6 rounded-xl elevation-2 mb-4">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
GENERATE TIKET
|
||||
</v-card-title>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
label="Masukkan Barcode"
|
||||
variant="solo"
|
||||
density="compact"
|
||||
hide-details
|
||||
flat
|
||||
class="mr-4 barcode-input"
|
||||
v-model="barcodeInput"
|
||||
@keyup.enter="generateTicket"
|
||||
></v-text-field>
|
||||
<v-chip color="#B71C1C" class="text-caption font-weight-bold text-white chip-warning">
|
||||
Tekan Enter: (Apabila barcode depan nomor ada huruf lain, ex: J008730180085 "hiraukan huruf 'J' nya")
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card class="pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
Pasien Klinik Ruang Admin
|
||||
</v-card-title>
|
||||
|
||||
<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="solo"
|
||||
flat
|
||||
:items="[10, 25, 50, 100]"
|
||||
class="mx-2 select-items"
|
||||
hide-details
|
||||
v-model="itemsPerPage"
|
||||
></v-select>
|
||||
<span>entries</span>
|
||||
</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>
|
||||
|
||||
<v-table class="mt-3 custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="header in klinikRuangAdminHeaders" :key="header.text">
|
||||
{{ header.text }}
|
||||
</th>
|
||||
</tr>
|
||||
</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-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// === Data Dummy untuk Tabel ===
|
||||
const klinikRuangAdminHeaders = [
|
||||
{ text: 'No' },
|
||||
{ text: 'Barcode' },
|
||||
{ text: 'No Rekamedik' },
|
||||
{ text: 'No Antrian' },
|
||||
{ text: 'No Antrian Klinik' },
|
||||
{ text: 'No Antrian Ruang' },
|
||||
{ text: 'Shift' },
|
||||
{ text: 'Pembayaran' },
|
||||
{ text: 'Action' },
|
||||
{ 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>
|
||||
|
||||
<style scoped>
|
||||
/* 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>
|
||||
@@ -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: [email protected]
|
||||
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>
|
||||
@@ -1,447 +0,0 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-layout>
|
||||
<!-- App Bar Header -->
|
||||
<v-app-bar app color="green darken-1" dark>
|
||||
<v-app-bar-nav-icon @click="rail = !rail"></v-app-bar-nav-icon>
|
||||
<v-toolbar-title class="ml-2">Antrean RSSA</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon>
|
||||
<v-icon>mdi-account-circle</v-icon>
|
||||
</v-btn>
|
||||
<span class="mr-2">Rajal Bayu Nogroho</span>
|
||||
</v-app-bar>
|
||||
|
||||
<!-- Navigation Drawer -->
|
||||
<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="8"
|
||||
>
|
||||
<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-list-item
|
||||
v-else
|
||||
: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-list>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- Main Content -->
|
||||
<v-main>
|
||||
<v-container fluid class="pa-4">
|
||||
<!-- Header Stats -->
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-h6 mr-4">Total 0</span>
|
||||
<span class="text-body-2">Max 150 Pasien</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-4">Dashboard</span>
|
||||
<span class="mr-4">Loket 24 | Senin, 11 Agustus 2025</span>
|
||||
<span class="mr-4">11 Agustus 2025 - Pelayanan</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Cards -->
|
||||
<v-card class="pa-5 mb-5" color="white" flat></v-card>
|
||||
<v-row align="center">
|
||||
<v-col cols="1
|
||||
2" md="1">
|
||||
<v-card color="green" dark class="text-center">
|
||||
<v-card-text>
|
||||
<div class="text-h4">1</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="1">
|
||||
<v-card color="blue" dark class="text-center">
|
||||
<v-card-text>
|
||||
<div class="text-h4">1</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="1">
|
||||
<v-card color="orange" dark class="text-center">
|
||||
<v-card-text>
|
||||
<div class="text-h4">4</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="1">
|
||||
<v-card color="red" dark class="text-center">
|
||||
<v-card-text>
|
||||
<div class="text-h4">30</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<!-- Next Patient Card -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card color="green" dark class="mb-4">
|
||||
<v-card-text class="text-center">
|
||||
<div class="text-h4 mb-2">NEXT</div>
|
||||
<div class="text-h6 mb-1">Pasien : UM1001</div>
|
||||
<div class="text-body-2">
|
||||
Klik untuk memanggil pasien selanjutnya
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Main Data Table -->
|
||||
<v-card class="mb-4">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>Data Pasien</span>
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
: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="search"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
style="max-width: 200px"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="mainHeaders"
|
||||
:items="mainPatients"
|
||||
:search="search"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn size="small" color="success" variant="flat"
|
||||
>Panggil</v-btn
|
||||
>
|
||||
<v-btn size="small" color="info" variant="flat">Cancel</v-btn>
|
||||
<v-btn size="small" color="primary" variant="flat"
|
||||
>Selesai</v-btn
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:item.jamPanggil="{ item }">
|
||||
<span :class="getRowClass(item)">{{ item.jamPanggil }}</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</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 -->
|
||||
<v-card class="mb-4">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>Info Pasien Lapor Terlambat</span>
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-caption text-orange"
|
||||
>KETERANGAN: PASIEN MASUK PADA TANGGAL</span
|
||||
>
|
||||
<span class="mr-2">Show</span>
|
||||
<v-select
|
||||
v-model="lateItemsPerPage"
|
||||
: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="lateSearch"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
style="max-width: 200px"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="lateHeaders"
|
||||
:items="latePatients"
|
||||
:search="lateSearch"
|
||||
:items-per-page="lateItemsPerPage"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-4">No data available in table</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Clinic Entry Patients Table -->
|
||||
<v-card>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>Info Pasien Masuk Klinik</span>
|
||||
<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-data-table
|
||||
:headers="clinicHeaders"
|
||||
:items="clinicPatients"
|
||||
:search="clinicSearch"
|
||||
:items-per-page="clinicItemsPerPage"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-4">No data available in table</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</v-layout>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
// Reactive data
|
||||
const drawer = ref(true);
|
||||
const rail = ref(false);
|
||||
const search = ref("");
|
||||
const lateSearch = ref("");
|
||||
const clinicSearch = ref("");
|
||||
const itemsPerPage = ref(10);
|
||||
const lateItemsPerPage = ref(10);
|
||||
const clinicItemsPerPage = ref(10);
|
||||
const currentPage = ref("Loket Admin");
|
||||
|
||||
// Navigation items
|
||||
const items = ref([
|
||||
{ title: "Dashboard", icon: "mdi-view-dashboard", to: "/dashboard" },
|
||||
{
|
||||
title: "Setting",
|
||||
icon: "mdi-cog",
|
||||
children: [
|
||||
{ title: "Hak Akses", to: "/setting/hak-akses" },
|
||||
{ title: "User Login", to: "/setting/user-login" },
|
||||
{ title: "Master Loket", to: "/setting/master-loket" },
|
||||
{ title: "Master Klinik", to: "/setting/master-klinik" },
|
||||
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
|
||||
{ title: "Screen", to: "/setting/screen" },
|
||||
],
|
||||
},
|
||||
{ title: "Loket Admin", icon: "mdi-account-supervisor" },
|
||||
{ title: "Ranap Admin", icon: "mdi-bed" },
|
||||
{ title: "Klinik Admin", icon: "mdi-hospital-box" },
|
||||
{ title: "Klinik Ruang Admin", icon: "mdi-hospital-marker" },
|
||||
{ title: "Anjungan", icon: "mdi-account-box-multiple", to: "/anjungan" },
|
||||
{ title: "Fast Track", icon: "mdi-clock-fast" },
|
||||
{ title: "Data Pasien", icon: "mdi-account-multiple" },
|
||||
{ title: "Screen", icon: "mdi-monitor" },
|
||||
{ title: "List Pasien", icon: "mdi-format-list-bulleted" },
|
||||
]);
|
||||
|
||||
// Table headers
|
||||
const mainHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
{ title: "Jam Panggil", value: "jamPanggil" },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Fast Track", value: "fastTrack" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Panggil", value: "panggil" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const lateHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const clinicHeaders = ref([
|
||||
{ title: "#", value: "no", sortable: false },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "No RM", value: "noRM" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Fast Track", value: "fastTrack" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
// Sample data
|
||||
const mainPatients = ref([
|
||||
{
|
||||
no: 1,
|
||||
jamPanggil: "12:49",
|
||||
barcode: "250811100163",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
shift: "Shift 1",
|
||||
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([]);
|
||||
|
||||
const clinicPatients = ref([]);
|
||||
|
||||
// Methods
|
||||
const getRowClass = (item) => {
|
||||
if (item.status === "current") {
|
||||
return "text-green font-weight-bold";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-list-item--active {
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,526 @@
|
||||
<template>
|
||||
<!-- Main Content -->
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<v-container fluid class="pa-6 main-content-padding">
|
||||
<!-- Header Banner & Stats -->
|
||||
<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">
|
||||
<v-icon size="40" class="mr-3 text-white">mdi-hospital-box-outline</v-icon>
|
||||
<span class="text-h4 font-weight-bold text-white">Loket Admin </span>
|
||||
</div>
|
||||
<div class="d-flex align-center text-white text-end flex-wrap justify-end">
|
||||
<span class="mr-4">Loket 24</span>
|
||||
<span class="mr-4">{{ currentDateLongFormatted }}</span>
|
||||
<span>{{ currentDateShortFormatted }} - Pelayanan</span>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Status Cards Section -->
|
||||
<v-row class="mb-6">
|
||||
<!-- Panggil 1 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
class="pa-4 rounded-xl elevation-2 text-center"
|
||||
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>
|
||||
</v-col>
|
||||
<!-- Panggil 5 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
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>
|
||||
</v-col>
|
||||
<!-- Panggil 10 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
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>
|
||||
</v-col>
|
||||
<!-- Panggil 20 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
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>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Next Patient Card -->
|
||||
<v-card class="next-patient-card d-flex align-center justify-center pa-8 text-center rounded-xl elevation-6 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-h4 text-white">NEXT PATIENT</div>
|
||||
<div class="text-h2 font-weight-bold text-white mt-2">UM1001</div>
|
||||
<v-btn
|
||||
size="large"
|
||||
color="#00A896"
|
||||
class="mt-4 text-white"
|
||||
@click="handleNextPatientClick"
|
||||
>
|
||||
<v-icon start>mdi-arrow-right-circle</v-icon>
|
||||
Panggil Pasien Selanjutnya
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Main Data Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Data Pasien
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-caption">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
class="mx-2 select-items"
|
||||
></v-select>
|
||||
<span class="mr-2 text-caption">entries</span>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
append-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
style="max-width: 200px"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="mainHeaders"
|
||||
:items="mainPatients"
|
||||
:search="search"
|
||||
:items-per-page="itemsPerPage"
|
||||
:row-class="getRowClass"
|
||||
class="custom-table"
|
||||
>
|
||||
<!-- Custom template for the 'aksi' column -->
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<div class="d-flex ga-1">
|
||||
<!-- Show different buttons based on the item's status -->
|
||||
<template v-if="item.status === 'dipanggil'">
|
||||
<v-btn size="small" color="primary" variant="flat" class="rounded-lg" @click="handleProsesClick(item)">
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<!-- Custom template for the 'panggil' column -->
|
||||
<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>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Late Patients Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Info Pasien Lapor Terlambat
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-caption text-orange">KETERANGAN: PASIEN MASUK PADA TANGGAL</span>
|
||||
<span class="mr-2 text-caption">Show</span>
|
||||
<v-select
|
||||
v-model="lateItemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
class="mx-2 select-items"
|
||||
></v-select>
|
||||
<span class="mr-2 text-caption">entries</span>
|
||||
<v-text-field
|
||||
v-model="lateSearch"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
append-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
style="max-width: 200px"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="lateHeaders"
|
||||
:items="latePatients"
|
||||
:search="lateSearch"
|
||||
:items-per-page="lateItemsPerPage"
|
||||
class="custom-table"
|
||||
>
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-4">Tidak ada data yang tersedia</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Clinic Entry Patients Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Info Pasien Masuk Klinik
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="clinicHeaders"
|
||||
:items="clinicPatients"
|
||||
:search="clinicSearch"
|
||||
:items-per-page="clinicItemsPerPage"
|
||||
class="custom-table"
|
||||
>
|
||||
<template v-slot:no-data>
|
||||
<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>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// Reactive data
|
||||
const search = ref("");
|
||||
const lateSearch = ref("");
|
||||
const clinicSearch = ref("");
|
||||
const itemsPerPage = ref(10);
|
||||
const lateItemsPerPage = ref(10);
|
||||
const clinicItemsPerPage = ref(10);
|
||||
const currentDateLongFormatted = ref("");
|
||||
const currentDateShortFormatted = ref("");
|
||||
|
||||
// Table headers
|
||||
const mainHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
{ title: "Jam Panggil", value: "jamPanggil" },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Fast Track", value: "fastTrack" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Panggil", align: 'center', value: "panggil", sortable: false },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const lateHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const clinicHeaders = ref([
|
||||
{ title: "#", value: "no", sortable: false },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "No RM", value: "noRM" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Fast Track", value: "fastTrack" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const infoKlinikHeaders = ref([
|
||||
{ title: "#", value: "no" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Jumlah Shift", value: "jumlahShift" },
|
||||
{ title: "Quota Per Shift", value: "quotaPerShift" },
|
||||
{ title: "Status", value: "status" },
|
||||
{ title: "Dapat Di Panggil", value: "dapatDiPanggil" },
|
||||
{ title: "Shift Belum Buka", value: "shiftBelumBuka" },
|
||||
]);
|
||||
|
||||
// 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 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
|
||||
const getRowClass = (item) => {
|
||||
if (item.status === 'dipanggil') {
|
||||
return 'called-row';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleStatusCardClick = (count) => {
|
||||
console.log(`Memanggil ${count} antrean pasien.`);
|
||||
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 = () => {
|
||||
console.log("Tombol Panggil Pasien Selanjutnya diklik! (Mengambil pasien pertama dari antrean)");
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles for a cleaner look */
|
||||
.main-content-padding {
|
||||
padding-left: 24px !important;
|
||||
padding-right: 24px !important;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.v-card.text-center:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-layout>
|
||||
<v-app-bar app color="green darken-1" dark>
|
||||
<v-app-bar-nav-icon @click="rail = !rail"></v-app-bar-nav-icon>
|
||||
<v-toolbar-title class="ml-2">Antrean RSSA</v-toolbar-title>
|
||||
</v-app-bar>
|
||||
|
||||
<v-navigation-drawer v-model="drawer" :rail="rail" permanent app>
|
||||
<!-- /* <v-list-item>
|
||||
<v-list-item-title class="text-h6">
|
||||
RSSA
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
-->
|
||||
|
||||
<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="8"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
:value="item.title"
|
||||
>
|
||||
</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-list-item
|
||||
v-else
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
:to="item.to"
|
||||
:value="item.title"
|
||||
link
|
||||
></v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-main>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
></v-text-field>
|
||||
<v-select
|
||||
:items="[10, 25, 50, 100]"
|
||||
label="Show"
|
||||
dense
|
||||
single-line
|
||||
hide-details
|
||||
class="shrink"
|
||||
></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"
|
||||
></v-text-field>
|
||||
<v-select
|
||||
:items="[10, 25, 50, 100]"
|
||||
label="Show"
|
||||
dense
|
||||
single-line
|
||||
hide-details
|
||||
class="shrink"
|
||||
></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"
|
||||
:footer-props="{
|
||||
'items-per-page-options': [10, 25, 50, 100],
|
||||
'show-current-page': true,
|
||||
}"
|
||||
>
|
||||
<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>
|
||||
</v-main>
|
||||
</v-layout>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const drawer = ref(true); // Nilai awal true agar sidebar terlihat
|
||||
const rail = ref(true); // Nilai awal true agar sidebar dimulai dalam mode rail
|
||||
|
||||
// Struktur data yang memisahkan menu dengan dan tanpa submenu
|
||||
const items = ref([
|
||||
{ title: "Dashboard", icon: "mdi-view-dashboard", to: "/dashboard" },
|
||||
{
|
||||
title: "Setting",
|
||||
icon: "mdi-cog",
|
||||
children: [
|
||||
{ title: "Hak Akses", to: "/setting/hak-akses" },
|
||||
{ title: "User Login", to: "/setting/user-login" },
|
||||
{ title: "Master Loket", to: "/setting/master-loket" },
|
||||
{ title: "Master Klinik", to: "/setting/master-klinik" },
|
||||
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
|
||||
{ title: "Screen", to: "/setting/screen" },
|
||||
],
|
||||
},
|
||||
{ title: "Loket Admin", icon: "mdi-account-supervisor" },
|
||||
{ title: "Ranap Admin", icon: "mdi-bed" },
|
||||
{ title: "Klinik Admin", icon: "mdi-hospital-box" },
|
||||
{ title: "Klinik Ruang Admin", icon: "mdi-hospital-marker" },
|
||||
{ title: "Anjungan", icon: "mdi-account-box-multiple", to: "/anjungan" },
|
||||
]);
|
||||
</script>
|
||||
@@ -0,0 +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>
|
||||
<div class="container mx-auto p-4 max-w-4xl">
|
||||
<h1 class="text-3xl font-bold mb-6 border-b pb-2">Complete Session Data Debug Page</h1>
|
||||
|
||||
<div v-if="loading" class="text-center p-8">
|
||||
<p class="text-xl">Loading session data...</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
/* Optional: Basic styling for better visibility */
|
||||
summary {
|
||||
list-style: none; /* Removes the default arrow */
|
||||
display: block; /* Allows summary to span full width */
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
/* Adds a custom arrow/chevron */
|
||||
summary::before {
|
||||
content: "▶";
|
||||
margin-right: 0.5em;
|
||||
transition: transform 0.2s;
|
||||
display: inline-block;
|
||||
}
|
||||
details[open] summary::before {
|
||||
content: "▼";
|
||||
/* transform: rotate(90deg); */
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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 ---
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user