akbar first commit
This commit is contained in:
106
components/AppBar.vue
Normal file
106
components/AppBar.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<v-app-bar app color="#ff9248" dark>
|
||||||
|
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
|
||||||
|
<v-toolbar-title class="ml-2 font-weight-bold">
|
||||||
|
<span class="text-blue-darken-2">Antrean</span> RSSA
|
||||||
|
</v-toolbar-title>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
|
<!-- Show loading state or user info -->
|
||||||
|
<div v-if="isLoading" class="d-flex align-center">
|
||||||
|
<v-progress-circular indeterminate size="20" class="mr-2"></v-progress-circular>
|
||||||
|
<span class="mr-2">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="isAuthenticated && user">
|
||||||
|
<ProfilePopup
|
||||||
|
:user="user"
|
||||||
|
@logout="handleLogout"
|
||||||
|
/><template>
|
||||||
|
<v-app-bar app color="blue-grey-darken-3" dark flat>
|
||||||
|
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
|
||||||
|
<v-toolbar-title class="ml-2 font-weight-bold">
|
||||||
|
<span class="text-orange-darken-2">Antrian</span> RSSA
|
||||||
|
</v-toolbar-title>
|
||||||
|
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="d-flex align-center">
|
||||||
|
<v-progress-circular indeterminate color="orange-darken-2" size="20" class="mr-2"></v-progress-circular>
|
||||||
|
<span class="text-caption">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="isAuthenticated && user">
|
||||||
|
<v-menu offset-y>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props"
|
||||||
|
variant="flat"
|
||||||
|
rounded="xl"
|
||||||
|
color="transparent"
|
||||||
|
class="pa-2 text-capitalize"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar color="orange-darken-2" size="36" class="mr-2">
|
||||||
|
<span class="text-white font-weight-bold">{{ user.name?.charAt(0) || 'U' }}</span>
|
||||||
|
</v-avatar>
|
||||||
|
<span class="text-subtitle-1 font-weight-bold text-white">{{ user.name || 'User' }}</span>
|
||||||
|
<v-icon right size="20" class="ml-1">mdi-chevron-down</v-icon>
|
||||||
|
</div>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<ProfilePopup
|
||||||
|
:user="user"
|
||||||
|
@logout="handleLogout"
|
||||||
|
/>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-btn @click="redirectToLogin" color="orange-darken-2" variant="flat" rounded="lg" class="text-capitalize">
|
||||||
|
<v-icon left>mdi-login</v-icon>
|
||||||
|
Login
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-app-bar>
|
||||||
|
</template>
|
||||||
|
<span class="mr-2">{{ user.name || user.preferred_username || user.email }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-btn @click="redirectToLogin" color="white" text>
|
||||||
|
Login
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-app-bar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ProfilePopup from './ProfilePopup.vue';
|
||||||
|
|
||||||
|
// Emit untuk parent component
|
||||||
|
const emit = defineEmits(['toggle-rail']);
|
||||||
|
|
||||||
|
// Use auth composable
|
||||||
|
const { user, isAuthenticated, isLoading, checkAuth, logout } = useAuth()
|
||||||
|
|
||||||
|
// Handle logout - use the composable's logout method
|
||||||
|
const handleLogout = async () => {
|
||||||
|
console.log("🚪 AppBar logout initiated...")
|
||||||
|
try {
|
||||||
|
await logout()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ AppBar logout error:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
navigateTo('/LoginPage')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authentication on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
await checkAuth()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
124
components/HakAkses/EditHakAkses.vue
Normal file
124
components/HakAkses/EditHakAkses.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="pa-4 rounded-lg elevation-2">
|
||||||
|
<v-card-title class="text-h5 font-weight-bold mb-4">
|
||||||
|
Edit Hak Akses Menu | {{ localItem.namaTipeUser }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
|
||||||
|
<v-table density="compact" class="elevation-1 rounded-lg">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left">No</th>
|
||||||
|
<th class="text-left">Menu</th>
|
||||||
|
<th class="text-center">Akses</th>
|
||||||
|
<th class="text-center">Lihat</th>
|
||||||
|
<th class="text-center">Tambah</th>
|
||||||
|
<th class="text-center">Edit</th>
|
||||||
|
<th class="text-center">Hapus</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(menu, index) in localItem.hakAksesMenu" :key="menu.name">
|
||||||
|
<td>{{ index + 1 }}</td>
|
||||||
|
<td>{{ menu.name }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-checkbox v-model="menu.canAccess" hide-details></v-checkbox>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-checkbox v-model="menu.canView" hide-details></v-checkbox>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-checkbox v-model="menu.canAdd" hide-details></v-checkbox>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-checkbox v-model="menu.canEdit" hide-details></v-checkbox>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-checkbox v-model="menu.canDelete" hide-details></v-checkbox>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-end pa-4">
|
||||||
|
<v-btn
|
||||||
|
color="grey-darken-1"
|
||||||
|
variant="flat"
|
||||||
|
class="text-capitalize rounded-lg mr-2"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="orange-darken-2"
|
||||||
|
variant="flat"
|
||||||
|
class="text-capitalize rounded-lg"
|
||||||
|
@click="$emit('save', localItem)"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
// Define types for better readability and safety
|
||||||
|
interface HakAksesMenu {
|
||||||
|
name: string;
|
||||||
|
canAccess: boolean;
|
||||||
|
canView: boolean;
|
||||||
|
canAdd: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HakAksesData {
|
||||||
|
id: number;
|
||||||
|
namaTipeUser: string;
|
||||||
|
hakAksesMenu: HakAksesMenu[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define props with type validation
|
||||||
|
const props = defineProps({
|
||||||
|
item: {
|
||||||
|
type: Object as () => HakAksesData,
|
||||||
|
required: true,
|
||||||
|
// Add custom validator for more robust checks
|
||||||
|
validator: (value: HakAksesData) => {
|
||||||
|
return 'namaTipeUser' in value && 'hakAksesMenu' in value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define emits for clarity
|
||||||
|
const emits = defineEmits(['save', 'cancel']);
|
||||||
|
|
||||||
|
// Use a local copy to avoid mutating the prop directly
|
||||||
|
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
|
||||||
|
|
||||||
|
// Watch the prop for changes and update the local copy
|
||||||
|
watch(() => props.item, (newItem) => {
|
||||||
|
localItem.value = JSON.parse(JSON.stringify(newItem));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-table :deep(th) {
|
||||||
|
font-weight: bold !important;
|
||||||
|
background-color: #f9fafb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table :deep(td) {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-checkbox :deep(.v-selection-control__input) {
|
||||||
|
color: #2196F3 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,43 +2,89 @@
|
|||||||
<v-menu
|
<v-menu
|
||||||
v-model="menu"
|
v-model="menu"
|
||||||
:close-on-content-click="false"
|
:close-on-content-click="false"
|
||||||
location="bottom"
|
location="bottom right"
|
||||||
origin="top right"
|
origin="top right"
|
||||||
transition="scale-transition"
|
transition="slide-y-transition"
|
||||||
>
|
>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn
|
<v-btn icon v-bind="props">
|
||||||
icon
|
<v-avatar size="40">
|
||||||
v-bind="props"
|
<v-img
|
||||||
class="ml-auto"
|
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||||
>
|
:alt="`${user?.name || 'User'} Profile`"
|
||||||
<v-icon size="40" color="#000000">mdi-account</v-icon>
|
></v-img>
|
||||||
|
</v-avatar>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-card class="mx-auto" color="#FFA000" dark width="250">
|
<v-card class="rounded-lg elevation-4 pa-4" width="300">
|
||||||
<v-list-item three-line class="py-4">
|
<div class="d-flex align-center pb-2">
|
||||||
<v-list-item-title class="text-h6 text-center font-weight-bold">
|
<v-avatar size="48">
|
||||||
<v-avatar color="#fff" size="60">
|
<v-img
|
||||||
<v-icon size="40" color="#4CAF50">mdi-account</v-icon>
|
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||||
</v-avatar>
|
:alt="`${user?.name || 'User'} Profile`"
|
||||||
</v-list-item-title>
|
></v-img>
|
||||||
<v-list-item-subtitle class="text-center mt-2 text-black">
|
</v-avatar>
|
||||||
<span class="d-block font-weight-bold">Rajal Bayu Nogroho</span>
|
<div class="ml-4">
|
||||||
<span class="d-block">Super Admin</span>
|
<div class="text-subtitle-1 font-weight-bold">
|
||||||
</v-list-item-subtitle>
|
{{ user?.name || user?.preferred_username || 'User' }}
|
||||||
</v-list-item>
|
</div>
|
||||||
|
<div class="text-caption text-grey-darken-1">
|
||||||
|
{{ user?.email || 'No email' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-darken-2">
|
||||||
|
ID: {{ user?.id?.substring(0, 8) }}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-divider class="my-2"></v-divider>
|
||||||
|
|
||||||
<v-card-actions class="d-flex justify-center pa-2">
|
<v-list dense>
|
||||||
<v-btn
|
<v-list-item link class="rounded-lg" @click="handleAction('account')">
|
||||||
color="black"
|
<template v-slot:prepend>
|
||||||
variant="text"
|
<v-icon>mdi-cog</v-icon>
|
||||||
class="font-weight-bold"
|
</template>
|
||||||
|
<v-list-item-title>Pengaturan Akun</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item link class="rounded-lg" @click="handleAction('darkMode')">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon>mdi-weather-night</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Mode Gelap</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-switch
|
||||||
|
v-model="darkMode"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
></v-switch>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item link class="rounded-lg" @click="handleAction('profile')">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon>mdi-account</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Profil Saya</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-divider class="my-2"></v-divider>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
link
|
||||||
|
class="rounded-lg text-red"
|
||||||
@click="signOut"
|
@click="signOut"
|
||||||
|
:disabled="isLoggingOut"
|
||||||
>
|
>
|
||||||
Sign out
|
<template v-slot:prepend>
|
||||||
</v-btn>
|
<v-icon color="red">mdi-logout</v-icon>
|
||||||
</v-card-actions>
|
</template>
|
||||||
|
<v-list-item-title>
|
||||||
|
{{ isLoggingOut ? 'Logging out...' : 'Keluar' }}
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</template>
|
</template>
|
||||||
@@ -46,15 +92,64 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
const menu = ref(false);
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const signOut = () => {
|
const menu = ref(false);
|
||||||
console.log("Sign out button clicked!");
|
const darkMode = ref(false);
|
||||||
|
const isLoggingOut = ref(false);
|
||||||
|
const emit = defineEmits(['logout']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the logout action - delegates to parent
|
||||||
|
*/
|
||||||
|
const signOut = async () => {
|
||||||
|
if (isLoggingOut.value) return;
|
||||||
|
|
||||||
|
isLoggingOut.value = true;
|
||||||
menu.value = false;
|
menu.value = false;
|
||||||
// Implementasi logika logout di sini
|
|
||||||
|
try {
|
||||||
|
console.log('🚪 ProfilePopup signOut called...')
|
||||||
|
emit('logout');
|
||||||
|
} finally {
|
||||||
|
isLoggingOut.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = (action) => {
|
||||||
|
console.log('Action triggered:', action);
|
||||||
|
|
||||||
|
switch(action) {
|
||||||
|
case 'account':
|
||||||
|
// Navigate to account settings
|
||||||
|
navigateTo('/settings/account')
|
||||||
|
break;
|
||||||
|
case 'profile':
|
||||||
|
// Navigate to profile page
|
||||||
|
navigateTo('/profile')
|
||||||
|
break;
|
||||||
|
case 'darkMode':
|
||||||
|
// Dark mode toggle is handled by v-model
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('Unknown action:', action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu for navigation actions
|
||||||
|
if (action !== 'darkMode') {
|
||||||
|
menu.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* No specific scoped styles needed for this component as Vuetify classes handle styling */
|
.text-red {
|
||||||
</style>
|
color: rgb(244, 67, 54) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
75
components/ReorderMenuDialog.vue
Normal file
75
components/ReorderMenuDialog.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!-- <template>
|
||||||
|
<v-dialog v-model="dialog" max-width="500px">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h6 font-weight-bold">
|
||||||
|
Atur Urutan Menu
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list dense>
|
||||||
|
<draggable v-model="localMenus" item-key="title" @end="onDragEnd">
|
||||||
|
<template #item="{ element }">
|
||||||
|
<v-list-item class="reorder-item">
|
||||||
|
<v-list-item-content>
|
||||||
|
<v-list-item-title>{{ element.title }}</v-list-item-title>
|
||||||
|
</v-list-item-content>
|
||||||
|
<v-list-item-icon>
|
||||||
|
<v-icon>mdi-drag</v-icon>
|
||||||
|
</v-list-item-icon>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="grey-darken-1" text @click="dialog = false">Batal</v-btn>
|
||||||
|
<v-btn color="blue" text @click="saveOrder">Simpan Urutan</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import draggable from 'vuedraggable';
|
||||||
|
import navigationItems from '~/data/menu.js';
|
||||||
|
|
||||||
|
const dialog = ref(false);
|
||||||
|
const localMenus = ref([]);
|
||||||
|
|
||||||
|
// Watch for changes in the dialog's visibility
|
||||||
|
watch(dialog, (val) => {
|
||||||
|
if (val) {
|
||||||
|
// Make a deep copy to avoid mutating the original data
|
||||||
|
localMenus.value = JSON.parse(JSON.stringify(navigationItems));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDragEnd = (event) => {
|
||||||
|
// Logic to handle the end of a drag event
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveOrder = () => {
|
||||||
|
// Emit an event with the new menu order
|
||||||
|
// You would then handle this in the parent component
|
||||||
|
// to update the ~/data/menu.js file (or your state management)
|
||||||
|
// and trigger a UI refresh.
|
||||||
|
dialog.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
dialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ openDialog });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reorder-item {
|
||||||
|
cursor: grab;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.reorder-item:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style> -->
|
||||||
103
components/SideBar.vue
Normal file
103
components/SideBar.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!-- components/sideBar.vue -->
|
||||||
|
<template>
|
||||||
|
<v-navigation-drawer
|
||||||
|
:model-value="drawer"
|
||||||
|
:rail="rail"
|
||||||
|
permanent
|
||||||
|
app
|
||||||
|
@update:model-value="emit('update:drawer', $event)"
|
||||||
|
>
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<template v-for="item in items" :key="item.name">
|
||||||
|
<v-menu
|
||||||
|
v-if="item.children"
|
||||||
|
open-on-hover
|
||||||
|
:location="rail ? 'end' : undefined"
|
||||||
|
:offset="10"
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="props"
|
||||||
|
:prepend-icon="item.icon"
|
||||||
|
:title="item.name"
|
||||||
|
:value="item.name"
|
||||||
|
:to="!rail ? item.path : undefined"
|
||||||
|
:link="!rail"
|
||||||
|
></v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-card class="py-2" min-width="200">
|
||||||
|
<v-card-title class="text-subtitle-1 font-weight-bold px-4 py-2">
|
||||||
|
{{ item.name }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item
|
||||||
|
v-for="child in item.children"
|
||||||
|
:key="child.name"
|
||||||
|
:to="child.path"
|
||||||
|
:title="child.name"
|
||||||
|
:prepend-icon="child.icon"
|
||||||
|
link
|
||||||
|
class="px-4"
|
||||||
|
></v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<v-tooltip
|
||||||
|
v-else
|
||||||
|
:disabled="!rail"
|
||||||
|
open-on-hover
|
||||||
|
location="end"
|
||||||
|
:text="item.name"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="props"
|
||||||
|
:prepend-icon="item.icon"
|
||||||
|
:title="item.name"
|
||||||
|
:to="item.path"
|
||||||
|
link
|
||||||
|
></v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps, defineEmits } from 'vue';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon: string;
|
||||||
|
children?: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: {
|
||||||
|
type: Array as () => NavItem[],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
rail: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
drawer: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:drawer']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-navigation-drawer__content {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
135
composables/useAuth.ts
Normal file
135
composables/useAuth.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// composables/useAuth.ts - Enhanced version with better error handling
|
||||||
|
import type { User, SessionResponse, LoginResponse, LogoutResponse } from '~/types/auth'
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const isAuthenticated = computed(() => !!user.value)
|
||||||
|
|
||||||
|
const clearError = () => {
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAuth = async (): Promise<User | null> => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
clearError()
|
||||||
|
|
||||||
|
const response = await $fetch<SessionResponse>('/api/auth/session')
|
||||||
|
|
||||||
|
if (response.success === false && response.error) {
|
||||||
|
error.value = response.error
|
||||||
|
user.value = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
user.value = response.user
|
||||||
|
return response.user
|
||||||
|
} catch (fetchError: any) {
|
||||||
|
console.error('Session check failed:', fetchError)
|
||||||
|
error.value = 'Failed to check authentication status'
|
||||||
|
user.value = null
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
clearError()
|
||||||
|
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response?.success && response?.data?.authUrl) {
|
||||||
|
console.log('🔗 Redirecting to Keycloak login...')
|
||||||
|
window.location.href = response.data.authUrl
|
||||||
|
} else {
|
||||||
|
const errorMsg = response?.error || 'Failed to get authorization URL'
|
||||||
|
error.value = errorMsg
|
||||||
|
throw new Error(errorMsg)
|
||||||
|
}
|
||||||
|
} catch (loginError: any) {
|
||||||
|
console.error('❌ Login error:', loginError)
|
||||||
|
error.value = loginError.message || 'Login failed'
|
||||||
|
throw loginError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
clearError()
|
||||||
|
console.log('🚪 Starting logout process...')
|
||||||
|
|
||||||
|
const response = await $fetch<LogoutResponse>('/api/auth/logout', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear user immediately regardless of response
|
||||||
|
user.value = null
|
||||||
|
|
||||||
|
if (response?.success && response?.logoutUrl) {
|
||||||
|
console.log('🔗 Redirecting to Keycloak logout...')
|
||||||
|
window.location.href = response.logoutUrl
|
||||||
|
} else {
|
||||||
|
const warningMsg = response?.error || response?.message || 'No logout URL received'
|
||||||
|
console.warn('⚠️', warningMsg)
|
||||||
|
error.value = warningMsg
|
||||||
|
await navigateTo('/LoginPage')
|
||||||
|
}
|
||||||
|
} catch (logoutError: any) {
|
||||||
|
console.error('❌ Logout error:', logoutError)
|
||||||
|
error.value = logoutError.message || 'Logout failed'
|
||||||
|
user.value = null
|
||||||
|
await navigateTo('/LoginPage')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to refresh user data
|
||||||
|
const refreshUser = async (): Promise<boolean> => {
|
||||||
|
const userData = await checkAuth()
|
||||||
|
return !!userData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if user has specific role
|
||||||
|
const hasRole = (role: string): boolean => {
|
||||||
|
if (!user.value) return false
|
||||||
|
|
||||||
|
// Check in roles array
|
||||||
|
if (user.value.roles?.includes(role)) return true
|
||||||
|
|
||||||
|
// Check in realm_access.roles
|
||||||
|
if (user.value.realm_access?.roles?.includes(role)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if user has any of the specified roles
|
||||||
|
const hasAnyRole = (roles: string[]): boolean => {
|
||||||
|
return roles.some(role => hasRole(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
user: readonly(user),
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading: readonly(isLoading),
|
||||||
|
error: readonly(error),
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
checkAuth,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshUser,
|
||||||
|
clearError,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
hasRole,
|
||||||
|
hasAnyRole
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,133 +1,37 @@
|
|||||||
|
<!-- layouts/default.vue -->
|
||||||
<template>
|
<template>
|
||||||
<v-app>
|
<v-app id="inspire">
|
||||||
<v-layout>
|
<AppBar @toggle-rail="rail = !rail" />
|
||||||
<!-- App Bar Header -->
|
<SideBar :items="navItemsStore.navItems" v-model:drawer="drawer" :rail="rail" />
|
||||||
<v-app-bar app color="#FFA000" dark>
|
|
||||||
<v-app-bar-nav-icon @click="rail = !rail"></v-app-bar-nav-icon>
|
|
||||||
<v-toolbar-title class="ml-2">Antrian RSSA</v-toolbar-title>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<!-- Profile Popup Component -->
|
|
||||||
<ProfilePopup />
|
|
||||||
<span class="mr-2">Rajal Bayu Nogroho</span>
|
|
||||||
</v-app-bar>
|
|
||||||
|
|
||||||
<!-- Navigation Drawer -->
|
<v-main app>
|
||||||
<v-navigation-drawer v-model="drawer" :rail="rail" permanent app>
|
|
||||||
<v-list density="compact" nav>
|
|
||||||
<template v-for="item in items" :key="item.title">
|
|
||||||
<v-menu
|
|
||||||
v-if="item.children"
|
|
||||||
open-on-hover
|
|
||||||
location="end"
|
|
||||||
:nudge-right="3"
|
|
||||||
>
|
|
||||||
<template v-slot:activator="{ props }">
|
|
||||||
<v-list-item
|
|
||||||
v-bind="props"
|
|
||||||
:prepend-icon="item.icon"
|
|
||||||
:title="item.title"
|
|
||||||
:value="item.title"
|
|
||||||
:class="{ 'v-list-item--active': item.title === currentPage }"
|
|
||||||
>
|
|
||||||
</v-list-item>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<v-list>
|
|
||||||
<v-list-item>
|
|
||||||
<v-list-item-title class="font-weight-bold">{{
|
|
||||||
item.title
|
|
||||||
}}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-divider></v-divider>
|
|
||||||
<v-list-item
|
|
||||||
v-for="child in item.children"
|
|
||||||
:key="child.title"
|
|
||||||
:to="child.to"
|
|
||||||
link
|
|
||||||
>
|
|
||||||
<v-list-item-title>{{ child.title }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
|
|
||||||
<v-tooltip
|
|
||||||
v-else
|
|
||||||
:disabled="!rail"
|
|
||||||
open-on-hover
|
|
||||||
location="end"
|
|
||||||
:text="item.title"
|
|
||||||
>
|
|
||||||
<template v-slot:activator="{ props }">
|
|
||||||
<v-list-item
|
|
||||||
v-bind="props"
|
|
||||||
:prepend-icon="item.icon"
|
|
||||||
:title="item.title"
|
|
||||||
:to="item.to"
|
|
||||||
:value="item.title"
|
|
||||||
:class="{ 'v-list-item--active': item.title === currentPage }"
|
|
||||||
link
|
|
||||||
></v-list-item>
|
|
||||||
</template>
|
|
||||||
</v-tooltip>
|
|
||||||
</template>
|
|
||||||
</v-list>
|
|
||||||
</v-navigation-drawer>
|
|
||||||
|
|
||||||
<!-- Page content will be rendered here -->
|
|
||||||
<slot />
|
<slot />
|
||||||
|
</v-main>
|
||||||
</v-layout>
|
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref, watchEffect } from "vue";
|
||||||
|
import AppBar from "../components/AppBar.vue";
|
||||||
|
import SideBar from "../components/SideBar.vue";
|
||||||
|
import { useNavItemsStore } from '@/stores/navItems'; // Import the new store
|
||||||
|
|
||||||
// Reactive data
|
definePageMeta({
|
||||||
const drawer = ref(true);
|
middleware: 'auth'
|
||||||
const rail = ref(true);
|
})
|
||||||
const currentPage = ref("Klinik Admin");
|
|
||||||
|
|
||||||
// Navigation items
|
const drawer = ref(true);
|
||||||
const items = ref([
|
const rail = ref(true);
|
||||||
{ title: "Dashboard", icon: "mdi-view-dashboard", to: "/dashboard" },
|
|
||||||
{
|
const navItemsStore = useNavItemsStore();
|
||||||
title: "Setting",
|
|
||||||
icon: "mdi-cog",
|
// Your logic to check user access and filter the menu can go here
|
||||||
children: [
|
// For example:
|
||||||
{ title: "Hak Akses", to: "/setting/hak-akses" },
|
// const filteredItems = computed(() => {
|
||||||
{ title: "User Login", to: "/setting/user-login" },
|
// return navItemsStore.navItems.filter(item => userHasAccess(item.path));
|
||||||
{ title: "Master Loket", to: "/setting/master-loket" },
|
// });
|
||||||
{ title: "Master Klinik", to: "/setting/master-klinik" },
|
|
||||||
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
|
|
||||||
{ title: "Screen", to: "/setting/screen" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ title: "Loket Admin", icon: "mdi-account-supervisor", to: "/LoketAdmin" },
|
|
||||||
{ title: "Ranap Admin", icon: "mdi-bed" },
|
|
||||||
{ title: "Klinik Admin", icon: "mdi-hospital-box", to: "/KlinikAdmin" },
|
|
||||||
{ title: "Klinik Ruang Admin", icon: "mdi-hospital-marker", to: "/KlinikRuangAdmin" },
|
|
||||||
{
|
|
||||||
title: "Anjungan",
|
|
||||||
icon: "mdi-account-box-multiple",
|
|
||||||
children: [
|
|
||||||
{ title: "Anjungan", to: "/Anjungan/Anjungan" },
|
|
||||||
{ title: "Admin Anjungan", to: "/Anjungan/AdminAnjungan" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ title: "Fast Track", icon: "mdi-clock-fast", to: "/FastTrack" },
|
|
||||||
{ title: "Data Pasien", icon: "mdi-account-multiple" },
|
|
||||||
{ title: "Screen", icon: "mdi-monitor" },
|
|
||||||
{ title: "List Pasien", icon: "mdi-format-list-bulleted" },
|
|
||||||
]);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
#inspire .v-navigation-drawer__content {
|
/* Global styles for layout */
|
||||||
background-color: #f5f5f5;
|
</style>
|
||||||
}
|
|
||||||
|
|
||||||
#inspire .v-app-bar {
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
14
layouts/empty.vue
Normal file
14
layouts/empty.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<v-main>
|
||||||
|
<slot />
|
||||||
|
</v-main>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Pastikan v-main mengisi seluruh tinggi halaman untuk mengaktifkan centering */
|
||||||
|
.v-main {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
108
middleware/auth.ts
Normal file
108
middleware/auth.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
// console.log('🛡️ Auth middleware triggered for:', to.path)
|
||||||
|
|
||||||
|
// // Skip middleware on server-side during build/generation
|
||||||
|
// if (process.server && process.env.NODE_ENV === 'development') {
|
||||||
|
// console.log('⏭️ Skipping auth check on server-side during development')
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Allow the login page to handle its own checks
|
||||||
|
// if (to.path === '/LoginPage') {
|
||||||
|
// console.log('⏭️ Allowing access to LoginPage')
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // This is the crucial change: check for the authentication signal
|
||||||
|
// const isAuthRedirect = to.query.authenticated === 'true';
|
||||||
|
|
||||||
|
// // If this is a redirect from a successful login, we need to let the route load
|
||||||
|
// if (isAuthRedirect) {
|
||||||
|
// console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
|
||||||
|
// // We navigate to a clean URL to remove the query parameter
|
||||||
|
// return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// console.log('🔍 Checking authentication status...')
|
||||||
|
|
||||||
|
// const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null)
|
||||||
|
|
||||||
|
// if (session && session.user) {
|
||||||
|
// console.log('✅ User is authenticated:', session.user.name || session.user.email)
|
||||||
|
// return
|
||||||
|
// } else {
|
||||||
|
// console.log('❌ No valid session found, redirecting to login')
|
||||||
|
// return navigateTo('/LoginPage')
|
||||||
|
// }
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('❌ Auth middleware error:', error)
|
||||||
|
// console.log('🔄 Redirecting to login due to error')
|
||||||
|
// return navigateTo('/LoginPage')
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
|
||||||
|
|
||||||
|
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
|
||||||
|
import type { RouteLocationNormalized } from 'vue-router';
|
||||||
|
|
||||||
|
// Define the shape of the user object returned by your authentication API.
|
||||||
|
// This provides type safety for session.user.
|
||||||
|
interface User {
|
||||||
|
name?: string | null;
|
||||||
|
email: string;
|
||||||
|
// Add other properties from your user object as needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the shape of the full session object returned by the API.
|
||||||
|
interface Session {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
|
||||||
|
console.log('🛡️ Auth middleware triggered for:', to.path);
|
||||||
|
|
||||||
|
// Skip middleware on server-side during development build/generation
|
||||||
|
if (process.server && process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('⏭️ Skipping auth check on server-side during development');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow the login page to handle its own checks without redirection loops
|
||||||
|
if (to.path === '/LoginPage') {
|
||||||
|
console.log('⏭️ Allowing access to LoginPage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for the authentication signal from a successful login redirect
|
||||||
|
const isAuthRedirect: boolean = to.query.authenticated === 'true';
|
||||||
|
|
||||||
|
// If this is a redirect from a successful login, allow the route to load.
|
||||||
|
// We navigate to a clean URL to remove the query parameter.
|
||||||
|
if (isAuthRedirect) {
|
||||||
|
console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
|
||||||
|
return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔍 Checking authentication status...');
|
||||||
|
|
||||||
|
// Use the defined Session interface to type the fetch response
|
||||||
|
const session: Session | null = await $fetch<Session>('/api/auth/session').catch(() => null);
|
||||||
|
|
||||||
|
// Check if a valid session and user exist using optional chaining
|
||||||
|
if (session?.user) {
|
||||||
|
console.log('✅ User is authenticated:', session.user.name || session.user.email);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log('❌ No valid session found, redirecting to login');
|
||||||
|
return navigateTo('/LoginPage');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Auth middleware error:', error);
|
||||||
|
console.log('🔄 Redirecting to login due to error');
|
||||||
|
return navigateTo('/LoginPage');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
44
middleware/guest.ts
Normal file
44
middleware/guest.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// middleware/guest.ts
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
console.log('👋 Guest middleware triggered for:', to.path);
|
||||||
|
|
||||||
|
// Skip middleware on server-side during build/generation
|
||||||
|
if (process.server && process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('⏭️ Skipping guest check on server-side during development');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthRedirect = to.query.authenticated === 'true';
|
||||||
|
const isLogoutSuccess = to.query.logout === 'success';
|
||||||
|
|
||||||
|
// If this is a logout success, allow access to login page
|
||||||
|
if (isLogoutSuccess) {
|
||||||
|
console.log('✅ Logout success detected, allowing access to login page');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a redirect from a successful login, we need to wait
|
||||||
|
if (isAuthRedirect) {
|
||||||
|
console.log('⏳ Client-side is processing a new login session, waiting for session to be established...');
|
||||||
|
// We navigate to a clean URL to remove the query parameter from the user's view
|
||||||
|
return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔍 Checking if user is already authenticated...');
|
||||||
|
|
||||||
|
// The $fetch will automatically send the new user_session cookie
|
||||||
|
const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null);
|
||||||
|
|
||||||
|
if (session && session.user) {
|
||||||
|
console.log('✅ User already authenticated, redirecting to dashboard');
|
||||||
|
return navigateTo('/dashboard');
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ No session found, staying on login page');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('ℹ️ Auth check failed (expected for login), staying on login page');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
// nuxt.config.ts
|
||||||
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-05-15',
|
compatibilityDate: '2025-05-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
@@ -12,6 +14,8 @@ export default defineNuxtConfig({
|
|||||||
'@nuxt/scripts',
|
'@nuxt/scripts',
|
||||||
'@nuxt/test-utils',
|
'@nuxt/test-utils',
|
||||||
'@nuxt/ui',
|
'@nuxt/ui',
|
||||||
|
'@pinia/nuxt',
|
||||||
|
// Remove '@sidebase/nuxt-auth' completely
|
||||||
(_options, nuxt) => {
|
(_options, nuxt) => {
|
||||||
nuxt.hooks.hook('vite:extendConfig', (config) => {
|
nuxt.hooks.hook('vite:extendConfig', (config) => {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -19,11 +23,25 @@ export default defineNuxtConfig({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Remove the auth configuration completely
|
||||||
|
// auth: { ... } <- Remove this entire block
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
authSecret: process.env.NUXT_AUTH_SECRET,
|
||||||
|
keycloakClientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||||
|
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||||
|
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
|
||||||
|
public: {
|
||||||
|
authUrl: process.env.AUTH_ORIGIN || 'http://localhost:3001' || 'http://localhost:3000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
transpile: ['vuetify'] // Important for Nuxt 3 with Vuetify
|
transpile: ['vuetify']
|
||||||
},
|
},
|
||||||
css: [
|
css: [
|
||||||
'vuetify/lib/styles/main.sass', // Or 'vuetify/styles' depending on version
|
'vuetify/lib/styles/main.sass',
|
||||||
'@mdi/font/css/materialdesignicons.min.css',
|
'@mdi/font/css/materialdesignicons.min.css',
|
||||||
],
|
],
|
||||||
vite: {
|
vite: {
|
||||||
@@ -31,7 +49,7 @@ export default defineNuxtConfig({
|
|||||||
noExternal: ['vuetify']
|
noExternal: ['vuetify']
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
vuetify({ autoImport: true }) // If using vite-plugin-vuetify
|
vuetify({ autoImport: true })
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
143
package-lock.json
generated
143
package-lock.json
generated
@@ -14,12 +14,18 @@
|
|||||||
"@nuxt/scripts": "^0.11.10",
|
"@nuxt/scripts": "^0.11.10",
|
||||||
"@nuxt/test-utils": "^3.19.2",
|
"@nuxt/test-utils": "^3.19.2",
|
||||||
"@nuxt/ui": "^3.3.0",
|
"@nuxt/ui": "^3.3.0",
|
||||||
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@unhead/vue": "^2.0.13",
|
"@unhead/vue": "^2.0.13",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"dayjs": "^1.11.18",
|
||||||
"eslint": "^9.32.0",
|
"eslint": "^9.32.0",
|
||||||
"nuxt": "^3.17.7",
|
"nuxt": "^3.17.7",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
|
"vue-chartjs": "^5.3.2",
|
||||||
|
"vue-draggable-next": "^2.3.0",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1594,6 +1600,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@kwsites/file-exists": {
|
"node_modules/@kwsites/file-exists": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||||
@@ -4497,6 +4509,21 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pinia/nuxt": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-CgvSWpbktxxWBV7ModhAcsExsQZqpPq6vMYEe9DexmmY6959ev8ukL4iFhr/qov2Nb9cQAWd7niFDnaWkN+FHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/kit": "^3.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pinia": "^3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -7892,6 +7919,18 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -8604,6 +8643,12 @@
|
|||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
|
||||||
|
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/db0": {
|
"node_modules/db0": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz",
|
||||||
@@ -8953,9 +8998,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.1.1",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
|
||||||
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
|
"integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/devlop": {
|
"node_modules/devlop": {
|
||||||
@@ -10286,10 +10331,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.4.6",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"picomatch": "^3 || ^4"
|
"picomatch": "^3 || ^4"
|
||||||
},
|
},
|
||||||
@@ -14715,6 +14763,36 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pinia": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^7.7.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.4.4",
|
||||||
|
"vue": "^2.7.0 || ^3.5.11"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||||
|
"version": "7.7.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz",
|
||||||
|
"integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-kit": "^7.7.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pkg-types": {
|
"node_modules/pkg-types": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
|
||||||
@@ -17158,6 +17236,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sortablejs": {
|
||||||
|
"version": "1.15.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
|
||||||
|
"integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.7.6",
|
"version": "0.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
||||||
@@ -19042,17 +19127,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
|
||||||
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
|
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.6",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rollup": "^4.43.0",
|
"rollup": "^4.43.0",
|
||||||
"tinyglobby": "^0.2.14"
|
"tinyglobby": "^0.2.15"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -19355,6 +19440,22 @@
|
|||||||
"vuetify": "^3.0.0"
|
"vuetify": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite/node_modules/tinyglobby": {
|
||||||
|
"version": "0.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fdir": "^6.5.0",
|
||||||
|
"picomatch": "^4.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitest-environment-nuxt": {
|
"node_modules/vitest-environment-nuxt": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vitest-environment-nuxt/-/vitest-environment-nuxt-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/vitest-environment-nuxt/-/vitest-environment-nuxt-1.0.1.tgz",
|
||||||
@@ -19400,6 +19501,16 @@
|
|||||||
"ufo": "^1.6.1"
|
"ufo": "^1.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-chartjs": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-component-type-helpers": {
|
"node_modules/vue-component-type-helpers": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.0.5.tgz",
|
||||||
@@ -19412,6 +19523,16 @@
|
|||||||
"integrity": "sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==",
|
"integrity": "sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-draggable-next": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-draggable-next/-/vue-draggable-next-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-ymbY0UIwfSdg0iDN/iyNNwUrTqZ/6KbPryzsvTNXBLuDCuOBdNijSK8yynNtmiSj6RapTPQfjLGQdJrZkzBd2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"sortablejs": "^1.14.0",
|
||||||
|
"vue": "^3.5.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-eslint-parser": {
|
"node_modules/vue-eslint-parser": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz",
|
||||||
|
|||||||
@@ -17,12 +17,18 @@
|
|||||||
"@nuxt/scripts": "^0.11.10",
|
"@nuxt/scripts": "^0.11.10",
|
||||||
"@nuxt/test-utils": "^3.19.2",
|
"@nuxt/test-utils": "^3.19.2",
|
||||||
"@nuxt/ui": "^3.3.0",
|
"@nuxt/ui": "^3.3.0",
|
||||||
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@unhead/vue": "^2.0.13",
|
"@unhead/vue": "^2.0.13",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"dayjs": "^1.11.18",
|
||||||
"eslint": "^9.32.0",
|
"eslint": "^9.32.0",
|
||||||
"nuxt": "^3.17.7",
|
"nuxt": "^3.17.7",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
|
"vue-chartjs": "^5.3.2",
|
||||||
|
"vue-draggable-next": "^2.3.0",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
476
pages/Dashboard.vue
Normal file
476
pages/Dashboard.vue
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
<template>
|
||||||
|
<v-container fluid class="pa-6 bg-grey-lighten-4">
|
||||||
|
<div class="d-flex justify-space-between align-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h4 font-weight-bold">Dashboard</h1>
|
||||||
|
<p v-if="user" class="text-subtitle-1 text-grey-darken-1 mt-1">
|
||||||
|
Selamat Datang, {{ user.name || user.preferred_username }}!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-chip color="green-lighten-1" class="mr-2 pa-3 font-weight-bold">
|
||||||
|
<v-icon start icon="mdi-calendar"></v-icon>
|
||||||
|
{{ currentDate }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row class="mb-6">
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="pa-4 rounded-xl elevation-6" color="blue-lighten-1" theme="dark">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="64" class="mr-4">mdi-account-group</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h4 font-weight-black">2635</div>
|
||||||
|
<div class="text-subtitle-1">Total Visitors</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="pa-4 rounded-xl elevation-6" color="cyan-lighten-1" theme="dark">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="64" class="mr-4">mdi-account-multiple-plus</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h4 font-weight-black">759</div>
|
||||||
|
<div class="text-subtitle-1">Offline Registrants</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="pa-4 rounded-xl elevation-6" color="green-lighten-1" theme="dark">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="64" class="mr-4">mdi-account-multiple-plus-outline</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h4 font-weight-black">1876</div>
|
||||||
|
<div class="text-subtitle-1">Online Registrants</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="pa-4 rounded-xl elevation-6" color="orange-lighten-1" theme="dark">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="64" class="mr-4">mdi-ticket</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h4 font-weight-black">248</div>
|
||||||
|
<div class="text-subtitle-1">Total Tickets Printed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="pa-4 rounded-xl elevation-6">
|
||||||
|
<v-card-title class="d-flex justify-space-between align-center">
|
||||||
|
<span class="text-h6 font-weight-bold">Grafik Jumlah Antrean</span>
|
||||||
|
<v-btn-toggle v-model="queueTimePeriod" mandatory divided variant="outlined" color="primary" class="text-caption">
|
||||||
|
<v-btn size="small" value="day">Hari</v-btn>
|
||||||
|
<v-btn size="small" value="week">Minggu</v-btn>
|
||||||
|
<v-btn size="small" value="month">Bulan</v-btn>
|
||||||
|
<v-btn size="small" value="year">Tahun</v-btn>
|
||||||
|
</v-btn-toggle>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<Bar
|
||||||
|
:data="queueChartData"
|
||||||
|
:options="queueChartOptions"
|
||||||
|
style="height: 300px"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="pa-4 rounded-xl elevation-6">
|
||||||
|
<v-card-title class="d-flex justify-space-between align-center">
|
||||||
|
<span class="text-h6 font-weight-bold">Monthly Visitor Data</span>
|
||||||
|
<div>
|
||||||
|
<v-chip
|
||||||
|
:color="activeYear === '2024' ? 'green-lighten-1' : 'grey-lighten-2'"
|
||||||
|
class="mr-2 text-caption font-weight-bold cursor-pointer"
|
||||||
|
@click="changeYear('2024')"
|
||||||
|
>
|
||||||
|
2024
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
:color="activeYear === '2025' ? 'green-lighten-1' : 'grey-lighten-2'"
|
||||||
|
class="text-caption font-weight-bold cursor-pointer"
|
||||||
|
@click="changeYear('2025')"
|
||||||
|
>
|
||||||
|
2025
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<Bar
|
||||||
|
:data="barData"
|
||||||
|
:options="barOptions"
|
||||||
|
style="height: 300px"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="pa-4 rounded-xl elevation-6">
|
||||||
|
<v-card-title class="text-h6 font-weight-bold">Realtime Ticket Queue</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<Line
|
||||||
|
ref="realtimeChart"
|
||||||
|
:data="initialLineData"
|
||||||
|
:options="lineOptions"
|
||||||
|
style="height: 300px"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="pa-4 rounded-xl elevation-6">
|
||||||
|
<v-card-title class="text-h6 font-weight-bold">Registrant Breakdown</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<Pie
|
||||||
|
:data="pieData"
|
||||||
|
:options="pieOptions"
|
||||||
|
style="height: 300px"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-overlay v-model="isLoading" contained class="align-center justify-center">
|
||||||
|
<v-progress-circular indeterminate size="64" color="primary"></v-progress-circular>
|
||||||
|
<p class="mt-4">Loading dashboard...</p>
|
||||||
|
</v-overlay>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed, onUnmounted } from 'vue';
|
||||||
|
import { Bar, Pie, Line } from 'vue-chartjs';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
// Tambahkan plugin Dayjs
|
||||||
|
import weekday from 'dayjs/plugin/weekday';
|
||||||
|
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||||
|
dayjs.extend(weekday);
|
||||||
|
dayjs.extend(weekOfYear);
|
||||||
|
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
ArcElement,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
} from 'chart.js';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware:['auth']
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register necessary Chart.js elements
|
||||||
|
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
|
||||||
|
|
||||||
|
// Use the auth composable
|
||||||
|
const { user, isLoading, checkAuth, logout } = useAuth()
|
||||||
|
const isLoggingOut = ref(false)
|
||||||
|
|
||||||
|
// Dashboard data
|
||||||
|
const currentDate = ref('');
|
||||||
|
const activeYear = ref('2025');
|
||||||
|
const queueTimePeriod = ref('month');
|
||||||
|
|
||||||
|
// --- MOCK DATA DINAMIS ANTREEAN (Tetap) ---
|
||||||
|
const mockQueueData = ref([
|
||||||
|
// ... (data tetap sama)
|
||||||
|
{ date: '2025-09-22', count: 120 },
|
||||||
|
{ date: '2025-09-23', count: 150 },
|
||||||
|
{ date: '2025-09-24', count: 135 },
|
||||||
|
{ date: '2025-09-25', count: 160 },
|
||||||
|
{ date: '2025-09-26', count: 145 },
|
||||||
|
{ date: '2025-09-27', count: 170 },
|
||||||
|
{ date: '2025-09-28', count: 180 },
|
||||||
|
{ date: '2025-08-10', count: 300 },
|
||||||
|
{ date: '2025-08-20', count: 350 },
|
||||||
|
{ date: '2025-09-01', count: 400 },
|
||||||
|
{ date: '2025-09-15', count: 450 },
|
||||||
|
{ date: '2024-01-01', count: 1200 },
|
||||||
|
{ date: '2024-06-01', count: 1800 },
|
||||||
|
{ date: '2025-01-01', count: 2000 },
|
||||||
|
{ date: '2025-06-01', count: 2500 },
|
||||||
|
]);
|
||||||
|
// --- AKHIR MOCK DATA ANTREEAN ---
|
||||||
|
|
||||||
|
// --- REFACTORED REALTIME LOGIC ---
|
||||||
|
const realtimeChart = ref(null); // Ref untuk mengakses komponen Line
|
||||||
|
const maxDataPoints = 10;
|
||||||
|
let realtimeInterval = null;
|
||||||
|
|
||||||
|
// Initial data structure (non-reactive for update function)
|
||||||
|
const initialLineData = {
|
||||||
|
labels: Array.from({ length: maxDataPoints }, (_, i) =>
|
||||||
|
dayjs().subtract((maxDataPoints - 1 - i) * 5, 'second').format('HH:mm:ss')
|
||||||
|
),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Tickets Processed',
|
||||||
|
backgroundColor: '#FF5722',
|
||||||
|
borderColor: '#FF5722',
|
||||||
|
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 50) + 100),
|
||||||
|
fill: false,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineOptions = ref({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: {
|
||||||
|
duration: 0 // Crucial: Disable animation for smooth realtime scrolling
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
title: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
suggestedMax: 200,
|
||||||
|
title: { display: true, text: 'Count' },
|
||||||
|
grid: { display: true }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: { display: true, text: 'Time (HH:MM:SS)' },
|
||||||
|
grid: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to simulate realtime data update using chart.update()
|
||||||
|
const updateRealtimeData = () => {
|
||||||
|
const chart = realtimeChart.value?.chart;
|
||||||
|
if (!chart) return;
|
||||||
|
|
||||||
|
// 1. Get the current data arrays
|
||||||
|
const dataArray = chart.data.datasets[0].data;
|
||||||
|
const labelArray = chart.data.labels;
|
||||||
|
|
||||||
|
// 2. Shift (remove) the oldest data point and label
|
||||||
|
dataArray.shift();
|
||||||
|
labelArray.shift();
|
||||||
|
|
||||||
|
// 3. Generate new data point and time label
|
||||||
|
const newDataPoint = Math.floor(Math.random() * 50) + 100;
|
||||||
|
const newTimeLabel = dayjs().format('HH:mm:ss');
|
||||||
|
|
||||||
|
// 4. Push the new data and label
|
||||||
|
dataArray.push(newDataPoint);
|
||||||
|
labelArray.push(newTimeLabel);
|
||||||
|
|
||||||
|
// 5. CRUCIAL: Tell Chart.js to redraw itself without destroying the instance
|
||||||
|
chart.update();
|
||||||
|
};
|
||||||
|
// --- END REFACTORED REALTIME LOGIC ---
|
||||||
|
|
||||||
|
|
||||||
|
// Example data for both years (Tetap)
|
||||||
|
const visitorData2024 = [150, 200, 350, 400, 380, 500, 550, 600, 520, 480, 650, 700];
|
||||||
|
const visitorData2025 = [200, 250, 400, 450, 420, 550, 600, 650, 570, 520, 700, 750];
|
||||||
|
|
||||||
|
// Check authentication and setup on page load
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const sessionUser = await checkAuth()
|
||||||
|
if (sessionUser) {
|
||||||
|
// Set current date
|
||||||
|
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||||
|
currentDate.value = new Date().toLocaleDateString('id-ID', options);
|
||||||
|
|
||||||
|
// Start realtime data update interval
|
||||||
|
// Pastikan chart instance sudah siap sebelum memulai interval
|
||||||
|
realtimeInterval = setInterval(updateRealtimeData, 5000);
|
||||||
|
} else {
|
||||||
|
await navigateTo('/LoginPage');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check error:', error);
|
||||||
|
await navigateTo('/LoginPage');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the interval when the component is unmounted
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (realtimeInterval) {
|
||||||
|
clearInterval(realtimeInterval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Updated logout handler (Tetap)
|
||||||
|
const handleLogout = async () => {
|
||||||
|
if (isLoggingOut.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoggingOut.value = true
|
||||||
|
console.log('🚪 Dashboard logout initiated...')
|
||||||
|
await logout()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Dashboard logout error:', error)
|
||||||
|
} finally {
|
||||||
|
isLoggingOut.value = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to change the active year (Tetap)
|
||||||
|
const changeYear = (year) => {
|
||||||
|
activeYear.value = year;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- LOGIKA UTAMA UNTUK GRAFIK ANTREEAN (Tetap) ---
|
||||||
|
const processQueueData = (data, period) => {
|
||||||
|
const grouped = {};
|
||||||
|
const sortedDates = data.map(item => ({
|
||||||
|
...item,
|
||||||
|
date: dayjs(item.date)
|
||||||
|
})).sort((a, b) => a.date.valueOf() - b.date.valueOf());
|
||||||
|
|
||||||
|
sortedDates.forEach(item => {
|
||||||
|
let key;
|
||||||
|
let label;
|
||||||
|
|
||||||
|
if (period === 'day') {
|
||||||
|
key = item.date.format('YYYY-MM-DD');
|
||||||
|
label = item.date.format('DD/MM');
|
||||||
|
} else if (period === 'week') {
|
||||||
|
key = item.date.format('YYYY-WW');
|
||||||
|
label = `Wk ${item.date.week()} ${item.date.year()}`;
|
||||||
|
} else if (period === 'month') {
|
||||||
|
key = item.date.format('YYYY-MM');
|
||||||
|
label = item.date.format('MMM YYYY');
|
||||||
|
} else if (period === 'year') {
|
||||||
|
key = item.date.format('YYYY');
|
||||||
|
label = item.date.format('YYYY');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grouped[key]) {
|
||||||
|
grouped[key] = { label: label, count: 0 };
|
||||||
|
}
|
||||||
|
grouped[key].count += item.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalLabels = Object.values(grouped).map(g => g.label);
|
||||||
|
const finalCounts = Object.values(grouped).map(g => g.count);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: finalLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: `Total Antrean per ${period}`,
|
||||||
|
backgroundColor: '#FFB300',
|
||||||
|
data: finalCounts,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueChartData = computed(() => {
|
||||||
|
if (!mockQueueData.value.length) return { labels: [], datasets: [] };
|
||||||
|
return processQueueData(mockQueueData.value, queueTimePeriod.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const queueChartOptions = ref({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { display: true }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// --- AKHIR LOGIKA GRAFIK ANTREEAN ---
|
||||||
|
|
||||||
|
|
||||||
|
// Computed property Monthly Visitor (Tetap)
|
||||||
|
const barData = computed(() => {
|
||||||
|
const dataForYear = activeYear.value === '2024' ? visitorData2024 : visitorData2025;
|
||||||
|
return {
|
||||||
|
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: `Total Visitors ${activeYear.value}`,
|
||||||
|
backgroundColor: '#2196F3',
|
||||||
|
data: dataForYear,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bar chart options (Tetap)
|
||||||
|
const barOptions = ref({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { display: false }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pie chart data (Tetap)
|
||||||
|
const pieData = ref({
|
||||||
|
labels: ['Offline Registrants', 'Online Registrants'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
backgroundColor: ['#2196F3', '#4CAF50'],
|
||||||
|
data: [759, 1876],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pie chart options (Tetap)
|
||||||
|
const pieOptions = ref({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const label = context.label || '';
|
||||||
|
const value = context.parsed || 0;
|
||||||
|
return `${label}: ${value}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-main class="bg-grey-lighten-3">
|
|
||||||
<v-container>
|
|
||||||
<!-- Fast Track Content -->
|
|
||||||
<div class="d-flex align-center justify-space-between mb-4">
|
|
||||||
<h1 class="text-h4">Fast Track</h1>
|
|
||||||
<div class="d-flex">
|
|
||||||
<v-btn
|
|
||||||
prepend-icon="mdi-view-dashboard"
|
|
||||||
variant="text"
|
|
||||||
class="text-capitalize"
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
prepend-icon="mdi-fast-forward"
|
|
||||||
variant="text"
|
|
||||||
class="text-capitalize"
|
|
||||||
>
|
|
||||||
Fast Track
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fast Track Table -->
|
|
||||||
<v-card class="pa-4">
|
|
||||||
<div class="d-flex justify-space-between align-center my-3">
|
|
||||||
<div class="d-flex align-center">
|
|
||||||
<span>Show</span>
|
|
||||||
<v-select
|
|
||||||
density="compact"
|
|
||||||
variant="outlined"
|
|
||||||
:items="[10, 25, 50, 100]"
|
|
||||||
class="mx-2"
|
|
||||||
style="width: 90px"
|
|
||||||
></v-select>
|
|
||||||
<span>entries</span>
|
|
||||||
</div>
|
|
||||||
<v-text-field
|
|
||||||
label="Search"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
style="max-width: 200px"
|
|
||||||
></v-text-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-table class="mt-3">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
v-for="header in fastTrackHeaders"
|
|
||||||
:key="header.text"
|
|
||||||
>
|
|
||||||
{{ header.text }}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(item, index) in fastTrackData" :key="index">
|
|
||||||
<td>{{ item.no }}</td>
|
|
||||||
<td>{{ item.barcode }}</td>
|
|
||||||
<td>{{ item.tanggal }}</td>
|
|
||||||
<td>{{ item.noAntrian }}</td>
|
|
||||||
<td>{{ item.rm }}</td>
|
|
||||||
<td>{{ item.shift }}</td>
|
|
||||||
<td>{{ item.klinik }}</td>
|
|
||||||
<td>{{ item.pj }}</td>
|
|
||||||
<td>{{ item.keterangan }}</td>
|
|
||||||
<td>{{ item.pembayaran }}</td>
|
|
||||||
<td>{{ item.status }}</td>
|
|
||||||
<td>
|
|
||||||
<v-btn color="blue">Fast Track</v-btn>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</v-table>
|
|
||||||
|
|
||||||
<div class="d-flex justify-space-between align-center mt-3">
|
|
||||||
<span>Showing 1 to 10 of 1,434 entries</span>
|
|
||||||
<v-pagination
|
|
||||||
v-model="page"
|
|
||||||
:length="144"
|
|
||||||
:total-visible="5"
|
|
||||||
></v-pagination>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
|
||||||
</v-container>
|
|
||||||
</v-main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from "vue";
|
|
||||||
|
|
||||||
// Reactive data
|
|
||||||
const search = ref("");
|
|
||||||
const itemsPerPage = ref(10);
|
|
||||||
const page = ref(1);
|
|
||||||
|
|
||||||
// Table headers
|
|
||||||
const fastTrackHeaders = [
|
|
||||||
{ text: 'No' },
|
|
||||||
{ text: 'Barcode' },
|
|
||||||
{ text: 'Tanggal / Jam Daftar' },
|
|
||||||
{ text: 'No Antrian' },
|
|
||||||
{ text: 'RM' },
|
|
||||||
{ text: 'Shift' },
|
|
||||||
{ text: 'Klinik' },
|
|
||||||
{ text: 'PJ' },
|
|
||||||
{ text: 'Keterangan' },
|
|
||||||
{ text: 'Pembayaran' },
|
|
||||||
{ text: 'Status' },
|
|
||||||
{ text: 'Aksi' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mock data
|
|
||||||
const fastTrackData = [
|
|
||||||
{ no: 1, barcode: '250813100928', tanggal: '13-08-2025 / 07:17', noAntrian: 'ON1045', rm: '', shift: 'Shift 1', klinik: 'ONKOLOGI', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
|
|
||||||
{ no: 2, barcode: '250813100930', tanggal: '13-08-2025 / 07:19', noAntrian: 'GI1018', rm: '', shift: 'Shift 1', klinik: 'GIGI DAN MULUT', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
|
|
||||||
{ no: 3, barcode: '250813100937', tanggal: '13-08-2025 / 07:19', noAntrian: 'MT1073', rm: '', shift: 'Shift 1', klinik: 'MATA', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
|
|
||||||
{ no: 4, barcode: '250813100936', tanggal: '13-08-2025 / 07:19', noAntrian: 'ON1047', rm: '', shift: 'Shift 1', klinik: 'ONKOLOGI', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
|
|
||||||
{ no: 5, barcode: '250813100935', tanggal: '13-08-2025 / 07:18', noAntrian: 'ON1046', rm: '', shift: 'Shift 1', klinik: 'ONKOLOGI', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
|
|
||||||
{ no: 6, barcode: '250813100934', tanggal: '13-08-2025 / 07:18', noAntrian: 'IP1101', rm: '', shift: 'Shift 1', klinik: 'IPD', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Tunggu Daftar' },
|
|
||||||
{ no: 7, barcode: '250813100933', tanggal: '13-08-2025 / 07:18', noAntrian: 'UM1031', rm: '', shift: 'Shift 1', klinik: 'IPD', pj: '', keterangan: '', pembayaran: 'UMUM', status: 'Proses Barcode' },
|
|
||||||
{ no: 8, barcode: '250813100932', tanggal: '13-08-2025 / 07:17', noAntrian: 'AN1122', rm: '', shift: 'Shift 1', klinik: 'ANAK', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Proses Barcode' },
|
|
||||||
{ no: 9, barcode: '250813100931', tanggal: '13-08-2025 / 07:17', noAntrian: 'PR1033', rm: '', shift: 'Shift 1', klinik: 'PARU', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Anjungan' },
|
|
||||||
{ no: 10, barcode: '250813100930', tanggal: '13-08-2025 / 07:17', noAntrian: 'TH1035', rm: '', shift: 'Shift 1', klinik: 'THT', pj: '', keterangan: '', pembayaran: 'JKN', status: 'Anjungan' },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
@@ -1,75 +1,93 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-divider class="my-8"></v-divider>
|
|
||||||
<v-main class="bg-grey-lighten-3">
|
<v-main class="bg-grey-lighten-3">
|
||||||
<v-container>
|
<v-container fluid class="pa-6">
|
||||||
<div class="d-flex align-center justify-space-between mb-4 mt-10">
|
|
||||||
<h1 class="text-h4">Klinik Admin</h1>
|
<!-- Colored Header with Quota Chip -->
|
||||||
<v-tooltip text="Jumlah Maksimal Bangku Tersedia">
|
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center justify-space-between pa-4">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="48" color="white" class="mr-4">mdi-account-group-outline</v-icon>
|
||||||
|
<h1 class="text-h4 font-weight-bold text-white">Klinik Admin</h1>
|
||||||
|
</div>
|
||||||
|
<v-tooltip text="Jumlah Maksimal Bangku Tersedia" location="bottom">
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-chip
|
<v-chip
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
class="text-white"
|
class="text-white px-4 py-2"
|
||||||
color="green-darken-1"
|
color="green-lighten-1"
|
||||||
|
variant="flat"
|
||||||
|
rounded="xl"
|
||||||
>
|
>
|
||||||
<v-icon left>mdi-circle-small</v-icon>
|
<v-icon start>mdi-chair-rolling</v-icon>
|
||||||
Max Quota Bangku 0
|
Max Quota Bangku 0
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</div>
|
</v-card>
|
||||||
|
|
||||||
<!-- Loket Admin Table -->
|
<!-- Loket Admin Table -->
|
||||||
<v-card class="mb-5 pa-4">
|
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||||
<v-card-title class="d-flex justify-space-between align-center">
|
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||||
Loket Admin
|
Loket Admin
|
||||||
<div>
|
<div>
|
||||||
<v-tooltip text="Panggil 1 Antrian">
|
<v-btn
|
||||||
<template v-slot:activator="{ props }">
|
color="green-lighten-1"
|
||||||
<v-btn
|
variant="flat"
|
||||||
v-bind="props"
|
rounded="xl"
|
||||||
color="green"
|
class="text-white elevation-4 mr-2 btn-call-group"
|
||||||
class="mr-2 clickable-btn"
|
@click="handleCallClick(1)"
|
||||||
@click="handleCallClick(1)"
|
>
|
||||||
>1</v-btn
|
<v-icon start>mdi-numeric-1-box</v-icon>
|
||||||
>
|
<span class="d-none d-md-inline">Panggil 1 Antrian</span>
|
||||||
</template>
|
</v-btn>
|
||||||
</v-tooltip>
|
<v-btn
|
||||||
<v-tooltip text="Panggil 5 Antrian">
|
color="blue-lighten-1"
|
||||||
<template v-slot:activator="{ props }">
|
variant="flat"
|
||||||
<v-btn
|
rounded="xl"
|
||||||
v-bind="props"
|
class="text-white elevation-4 mr-2 btn-call-group"
|
||||||
color="blue"
|
@click="handleCallClick(5)"
|
||||||
class="mr-2 clickable-btn"
|
>
|
||||||
@click="handleCallClick(5)"
|
<v-icon start>mdi-numeric-5-box</v-icon>
|
||||||
>5</v-btn
|
<span class="d-none d-md-inline">Panggil 5 Antrian</span>
|
||||||
>
|
</v-btn>
|
||||||
</template>
|
<v-btn
|
||||||
</v-tooltip>
|
color="orange-lighten-1"
|
||||||
<v-tooltip text="Panggil 10 Antrian">
|
variant="flat"
|
||||||
<template v-slot:activator="{ props }">
|
rounded="xl"
|
||||||
<v-btn
|
class="text-white elevation-4 mr-2 btn-call-group"
|
||||||
v-bind="props"
|
@click="handleCallClick(10)"
|
||||||
color="orange"
|
>
|
||||||
class="mr-2 clickable-btn"
|
<v-icon start>mdi-numeric-10-box</v-icon>
|
||||||
@click="handleCallClick(10)"
|
<span class="d-none d-md-inline">Panggil 10 Antrian</span>
|
||||||
>10</v-btn
|
</v-btn>
|
||||||
>
|
<v-btn
|
||||||
</template>
|
color="red-lighten-1"
|
||||||
</v-tooltip>
|
variant="flat"
|
||||||
<v-tooltip text="Panggil 20 Antrian">
|
rounded="xl"
|
||||||
<template v-slot:activator="{ props }">
|
class="text-white elevation-4 btn-call-group"
|
||||||
<v-btn
|
@click="handleCallClick(20)"
|
||||||
v-bind="props"
|
>
|
||||||
color="red"
|
<v-icon start>mdi-numeric-20-box</v-icon>
|
||||||
class="clickable-btn"
|
<span class="d-none d-md-inline">Panggil 20 Antrian</span>
|
||||||
@click="handleCallClick(20)"
|
</v-btn>
|
||||||
>20</v-btn
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
</v-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-table class="mt-3">
|
|
||||||
|
<!-- Pilihan Show Entries untuk Loket Admin -->
|
||||||
|
<div class="d-flex justify-end mb-4">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<span class="mr-2 text-subtitle-1">Show Entries:</span>
|
||||||
|
<v-select
|
||||||
|
:items="[10, 25, 50]"
|
||||||
|
v-model="itemsPerPageLoket"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="show-entries-select"
|
||||||
|
></v-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-table class="mt-3 custom-table rounded-lg elevation-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th v-for="header in loketHeaders" :key="header.text">
|
<th v-for="header in loketHeaders" :key="header.text">
|
||||||
@@ -78,7 +96,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(item, index) in loketData" :key="index">
|
<tr v-for="(item, index) in paginatedLoketData" :key="index">
|
||||||
<td>{{ item.no }}</td>
|
<td>{{ item.no }}</td>
|
||||||
<td>{{ item.barcode }}</td>
|
<td>{{ item.barcode }}</td>
|
||||||
<td>{{ item.noRekamedik }}</td>
|
<td>{{ item.noRekamedik }}</td>
|
||||||
@@ -87,26 +105,56 @@
|
|||||||
<td>{{ item.ket }}</td>
|
<td>{{ item.ket }}</td>
|
||||||
<td>{{ item.fastTrack }}</td>
|
<td>{{ item.fastTrack }}</td>
|
||||||
<td>{{ item.pembayaran }}</td>
|
<td>{{ item.pembayaran }}</td>
|
||||||
<td><v-btn size="x-small" color="primary">Panggil</v-btn></td>
|
|
||||||
<td>
|
<td>
|
||||||
<v-btn size="x-small" color="red">Batalkan</v-btn>
|
<v-btn size="small" color="primary" class="text-white rounded-lg" @click="handlePanggil(item)">
|
||||||
|
<v-icon start>mdi-phone-incoming</v-icon>
|
||||||
|
Panggil
|
||||||
|
</v-btn>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<v-btn size="small" color="red-darken-1" class="text-white rounded-lg" @click="handleBatalkan(item)">
|
||||||
|
<v-icon start>mdi-close</v-icon>
|
||||||
|
Batalkan
|
||||||
|
</v-btn>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</v-table>
|
</v-table>
|
||||||
<div class="d-flex justify-space-between align-center mt-3">
|
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||||
<span>Showing {{ loketData.length > 0 ? 1 : 0 }} to {{ loketData.length }} of {{ loketData.length }} entries</span>
|
<span>
|
||||||
|
Menampilkan {{ (currentPageLoket - 1) * itemsPerPageLoket + 1 }} hingga
|
||||||
|
{{ Math.min(currentPageLoket * itemsPerPageLoket, loketData.length) }} dari
|
||||||
|
{{ loketData.length }} entri
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<v-btn size="small" variant="text" disabled>Previous</v-btn>
|
<v-btn size="small" variant="flat" :disabled="currentPageLoket === 1" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket - 1)">Previous</v-btn>
|
||||||
<v-btn size="small" variant="text" disabled>Next</v-btn>
|
<v-btn size="small" variant="flat" :disabled="currentPageLoket >= totalPagesLoket" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket + 1)">Next</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- Data Pengunjung Table -->
|
<!-- Data Pengunjung Table -->
|
||||||
<v-card class="pa-4">
|
<v-card class="pa-6 rounded-xl elevation-2">
|
||||||
<v-card-title>Data Pengunjung: Loket</v-card-title>
|
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||||
<v-table class="mt-3">
|
Data Pengunjung: Loket
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<!-- Pilihan Show Entries untuk Data Pengunjung -->
|
||||||
|
<div class="d-flex justify-end mb-4">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<span class="mr-2 text-subtitle-1">Show Entries:</span>
|
||||||
|
<v-select
|
||||||
|
:items="[10, 25, 50]"
|
||||||
|
v-model="itemsPerPagePengunjung"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="show-entries-select"
|
||||||
|
></v-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-table class="mt-3 custom-table rounded-lg elevation-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th v-for="header in pengunjungHeaders" :key="header.text">
|
<th v-for="header in pengunjungHeaders" :key="header.text">
|
||||||
@@ -115,7 +163,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(item, index) in pengunjungData" :key="index">
|
<tr v-for="(item, index) in paginatedPengunjungData" :key="index">
|
||||||
<td>{{ item.no }}</td>
|
<td>{{ item.no }}</td>
|
||||||
<td>{{ item.barcode }}</td>
|
<td>{{ item.barcode }}</td>
|
||||||
<td>{{ item.noRekamedik }}</td>
|
<td>{{ item.noRekamedik }}</td>
|
||||||
@@ -123,15 +171,23 @@
|
|||||||
<td>{{ item.noAntrianKlinik }}</td>
|
<td>{{ item.noAntrianKlinik }}</td>
|
||||||
<td>{{ item.shift }}</td>
|
<td>{{ item.shift }}</td>
|
||||||
<td>{{ item.pembayaran }}</td>
|
<td>{{ item.pembayaran }}</td>
|
||||||
<td>{{ item.status }}</td>
|
<td>
|
||||||
|
<v-chip :color="item.status === 'Selesai' ? 'green' : 'orange'" size="small">
|
||||||
|
{{ item.status }}
|
||||||
|
</v-chip>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</v-table>
|
</v-table>
|
||||||
<div class="d-flex justify-space-between align-center mt-3">
|
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||||
<span>Showing {{ pengunjungData.length > 0 ? 1 : 0 }} to {{ pengunjungData.length }} of {{ pengunjungData.length }} entries</span>
|
<span>
|
||||||
|
Menampilkan {{ (currentPagePengunjung - 1) * itemsPerPagePengunjung + 1 }} hingga
|
||||||
|
{{ Math.min(currentPagePengunjung * itemsPerPagePengunjung, pengunjungData.length) }} dari
|
||||||
|
{{ pengunjungData.length }} entri
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<v-btn size="small" variant="text" disabled>Previous</v-btn>
|
<v-btn size="small" variant="flat" :disabled="currentPagePengunjung === 1" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung - 1)">Previous</v-btn>
|
||||||
<v-btn size="small" variant="text" disabled>Next</v-btn>
|
<v-btn size="small" variant="flat" :disabled="currentPagePengunjung >= totalPagesPengunjung" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung + 1)">Next</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -140,55 +196,83 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, computed } from "vue";
|
||||||
|
|
||||||
// Sample data for the tables
|
definePageMeta({
|
||||||
const loketData = ref([
|
middleware:['auth']
|
||||||
{
|
})
|
||||||
no: 1,
|
|
||||||
barcode: "1234567890",
|
// Generate dummy data
|
||||||
noRekamedik: "RM001",
|
const generateDummyData = (count) => {
|
||||||
noAntrian: "A001",
|
const data = [];
|
||||||
shift: "Pagi",
|
const shiftOptions = ['Pagi', 'Siang', 'Sore'];
|
||||||
ket: "Normal",
|
const pembayaranOptions = ['BPJS', 'Umum', 'Asuransi'];
|
||||||
fastTrack: "Tidak",
|
const statusOptions = ['Menunggu', 'Selesai', 'Di-cancel'];
|
||||||
pembayaran: "BPJS"
|
|
||||||
},
|
for (let i = 1; i <= count; i++) {
|
||||||
{
|
data.push({
|
||||||
no: 2,
|
no: i,
|
||||||
barcode: "0987654321",
|
barcode: `B${1000 + i}`,
|
||||||
noRekamedik: "RM002",
|
noRekamedik: `RM${100 + i}`,
|
||||||
noAntrian: "A002",
|
noAntrian: `A${100 + i}`,
|
||||||
shift: "Pagi",
|
noAntrianKlinik: `K${100 + i}`,
|
||||||
ket: "Normal",
|
shift: shiftOptions[Math.floor(Math.random() * shiftOptions.length)],
|
||||||
fastTrack: "Ya",
|
ket: "Dummy Data",
|
||||||
pembayaran: "Umum"
|
fastTrack: Math.random() > 0.5 ? "Ya" : "Tidak",
|
||||||
|
pembayaran: pembayaranOptions[Math.floor(Math.random() * pembayaranOptions.length)],
|
||||||
|
status: statusOptions[Math.floor(Math.random() * statusOptions.length)]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
]);
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
const pengunjungData = ref([
|
// State for Loket Admin table
|
||||||
{
|
const loketData = ref(generateDummyData(50));
|
||||||
no: 1,
|
const currentPageLoket = ref(1);
|
||||||
barcode: "1234567890",
|
const itemsPerPageLoket = ref(10);
|
||||||
noRekamedik: "RM001",
|
|
||||||
noAntrian: "A001",
|
// State for Data Pengunjung table
|
||||||
noAntrianKlinik: "K001",
|
const pengunjungData = ref(generateDummyData(50));
|
||||||
shift: "Pagi",
|
const currentPagePengunjung = ref(1);
|
||||||
pembayaran: "BPJS",
|
const itemsPerPagePengunjung = ref(10);
|
||||||
status: "Menunggu"
|
|
||||||
},
|
// Computed properties for Loket Admin table
|
||||||
{
|
const paginatedLoketData = computed(() => {
|
||||||
no: 2,
|
const start = (currentPageLoket.value - 1) * itemsPerPageLoket.value;
|
||||||
barcode: "0987654321",
|
const end = start + itemsPerPageLoket.value;
|
||||||
noRekamedik: "RM002",
|
return loketData.value.slice(start, end);
|
||||||
noAntrian: "A002",
|
});
|
||||||
noAntrianKlinik: "K002",
|
|
||||||
shift: "Pagi",
|
const totalPagesLoket = computed(() => {
|
||||||
pembayaran: "Umum",
|
return Math.ceil(loketData.value.length / itemsPerPageLoket.value);
|
||||||
status: "Selesai"
|
});
|
||||||
|
|
||||||
|
// Computed properties for Data Pengunjung table
|
||||||
|
const paginatedPengunjungData = computed(() => {
|
||||||
|
const start = (currentPagePengunjung.value - 1) * itemsPerPagePengunjung.value;
|
||||||
|
const end = start + itemsPerPagePengunjung.value;
|
||||||
|
return pengunjungData.value.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPagesPengunjung = computed(() => {
|
||||||
|
return Math.ceil(pengunjungData.value.length / itemsPerPagePengunjung.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Method to handle page change for Loket Admin table
|
||||||
|
const handlePageChangeLoket = (page) => {
|
||||||
|
if (page >= 1 && page <= totalPagesLoket.value) {
|
||||||
|
currentPageLoket.value = page;
|
||||||
}
|
}
|
||||||
]);
|
};
|
||||||
|
|
||||||
|
// Method to handle page change for Data Pengunjung table
|
||||||
|
const handlePageChangePengunjung = (page) => {
|
||||||
|
if (page >= 1 && page <= totalPagesPengunjung.value) {
|
||||||
|
currentPagePengunjung.value = page;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Methods to handle button clicks (unchanged)
|
||||||
const loketHeaders = [
|
const loketHeaders = [
|
||||||
{ text: 'No' },
|
{ text: 'No' },
|
||||||
{ text: 'Barcode' },
|
{ text: 'Barcode' },
|
||||||
@@ -200,7 +284,7 @@ const loketHeaders = [
|
|||||||
{ text: 'Pembayaran' },
|
{ text: 'Pembayaran' },
|
||||||
{ text: 'Panggil' },
|
{ text: 'Panggil' },
|
||||||
{ text: 'Aksi' },
|
{ text: 'Aksi' },
|
||||||
]
|
];
|
||||||
|
|
||||||
const pengunjungHeaders = [
|
const pengunjungHeaders = [
|
||||||
{ text: 'No' },
|
{ text: 'No' },
|
||||||
@@ -211,22 +295,78 @@ const pengunjungHeaders = [
|
|||||||
{ text: 'Shift' },
|
{ text: 'Shift' },
|
||||||
{ text: 'Pembayaran' },
|
{ text: 'Pembayaran' },
|
||||||
{ text: 'Status' },
|
{ text: 'Status' },
|
||||||
]
|
];
|
||||||
|
|
||||||
// Methods to handle clicks
|
|
||||||
const handleCallClick = (value) => {
|
const handleCallClick = (value) => {
|
||||||
console.log(`Panggil ${value} antrian diklik!`);
|
console.log(`Panggil ${value} antrian diklik!`);
|
||||||
// Tambahkan logika untuk memanggil antrian di sini
|
};
|
||||||
|
|
||||||
|
const handlePanggil = (item) => {
|
||||||
|
console.log('Panggil pasien:', item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatalkan = (item) => {
|
||||||
|
console.log('Batalkan pasien:', item);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.clickable-btn {
|
/* Main container padding */
|
||||||
cursor: pointer;
|
.v-container {
|
||||||
transition: transform 0.2s ease-in-out;
|
max-width: 1400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable-btn:hover {
|
.header-banner {
|
||||||
transform: translateY(-2px);
|
background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* General card styling */
|
||||||
|
.v-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Call buttons group */
|
||||||
|
.btn-call-group {
|
||||||
|
min-width: 50px;
|
||||||
|
height: 40px !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styling */
|
||||||
|
.custom-table {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table :deep(th) {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table :deep(tr:hover) {
|
||||||
|
background-color: #e8eaf6 !important; /* Light blue on hover */
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table :deep(tbody tr:nth-of-type(odd)) {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn {
|
||||||
|
margin: 0 4px;
|
||||||
|
background-color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom styling for status chip */
|
||||||
|
.v-chip.v-chip--size-small {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-entries-select {
|
||||||
|
max-width: 100px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,83 +1,121 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-divider class="my-8"></v-divider>
|
|
||||||
<v-main class="bg-grey-lighten-3">
|
<v-main class="bg-grey-lighten-3">
|
||||||
<v-container>
|
<v-container fluid class="pa-6">
|
||||||
<!-- Klinik Ruang Admin Content -->
|
|
||||||
<h1 class="text-h4 mb-4 mt-10">Klinik Ruang Admin</h1>
|
<!-- Colored Header Banner -->
|
||||||
<v-card class="pa-4 mb-4">
|
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
|
||||||
<v-card-title>GENERATE TIKET</v-card-title>
|
<v-icon size="48" color="white" class="mr-4">mdi-clipboard-list-outline</v-icon>
|
||||||
<div class="d-flex align-center">
|
<h1 class="text-h4 font-weight-bold text-white">Klinik Ruang Admin</h1>
|
||||||
<v-text-field
|
|
||||||
label="Masukkan Barcode"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
class="mr-4"
|
|
||||||
></v-text-field>
|
|
||||||
<v-col cols="12 " md="6">
|
|
||||||
<v-chip color="#B71C1C" class="text-caption">
|
|
||||||
Tekan Enter: (Apabila barcode depan nomor ada huruf lain, ex: J008730180085 "hiraukan huruf 'J' nya")
|
|
||||||
</v-chip>
|
|
||||||
</v-col>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-card class="pa-4">
|
<!-- Main Content Area -->
|
||||||
<v-card-title>Pasien Klinik Ruang Admin</v-card-title>
|
<v-row>
|
||||||
<div class="d-flex justify-space-between align-center my-3">
|
<v-col cols="12">
|
||||||
<div class="d-flex align-center">
|
<v-card class="pa-6 rounded-xl elevation-2 mb-4">
|
||||||
<span>Show</span>
|
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||||
<v-select
|
GENERATE TIKET
|
||||||
density="compact"
|
</v-card-title>
|
||||||
variant="outlined"
|
<div class="d-flex align-center">
|
||||||
:items="[10, 25, 50, 100]"
|
<v-text-field
|
||||||
class="mx-2"
|
label="Masukkan Barcode"
|
||||||
style="width: 80px"
|
variant="solo"
|
||||||
></v-select>
|
density="compact"
|
||||||
<span>entries</span>
|
hide-details
|
||||||
</div>
|
flat
|
||||||
<v-text-field
|
class="mr-4 barcode-input"
|
||||||
label="Search"
|
v-model="barcodeInput"
|
||||||
variant="outlined"
|
@keyup.enter="generateTicket"
|
||||||
density="compact"
|
></v-text-field>
|
||||||
style="max-width: 200px"
|
<v-chip color="#B71C1C" class="text-caption font-weight-bold text-white chip-warning">
|
||||||
></v-text-field>
|
Tekan Enter: (Apabila barcode depan nomor ada huruf lain, ex: J008730180085 "hiraukan huruf 'J' nya")
|
||||||
</div>
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
<v-table class="mt-3">
|
<v-card class="pa-6 rounded-xl elevation-2">
|
||||||
<thead>
|
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||||
<tr>
|
Pasien Klinik Ruang Admin
|
||||||
<th
|
</v-card-title>
|
||||||
v-for="header in klinikRuangAdminHeaders"
|
|
||||||
:key="header.text"
|
<div class="d-flex justify-space-between align-center my-3">
|
||||||
>
|
<div class="d-flex align-center">
|
||||||
{{ header.text }}
|
<span>Show</span>
|
||||||
</th>
|
<v-select
|
||||||
</tr>
|
density="compact"
|
||||||
</thead>
|
variant="solo"
|
||||||
<tbody>
|
flat
|
||||||
<tr>
|
:items="[10, 25, 50, 100]"
|
||||||
<td :colspan="klinikRuangAdminHeaders.length" class="text-center">
|
class="mx-2 select-items"
|
||||||
No data available in table
|
hide-details
|
||||||
</td>
|
v-model="itemsPerPage"
|
||||||
</tr>
|
></v-select>
|
||||||
</tbody>
|
<span>entries</span>
|
||||||
</v-table>
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
label="Search"
|
||||||
|
variant="solo"
|
||||||
|
density="compact"
|
||||||
|
flat
|
||||||
|
hide-details
|
||||||
|
append-inner-icon="mdi-magnify"
|
||||||
|
style="max-width: 200px"
|
||||||
|
v-model="searchQuery"
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-space-between align-center mt-3">
|
<v-table class="mt-3 custom-table">
|
||||||
<span>Showing 0 to 0 of 0 entries</span>
|
<thead>
|
||||||
<div>
|
<tr>
|
||||||
<v-btn size="small" variant="text" disabled>Previous</v-btn>
|
<th v-for="header in klinikRuangAdminHeaders" :key="header.text">
|
||||||
<v-btn size="small" variant="text" disabled>Next</v-btn>
|
{{ header.text }}
|
||||||
</div>
|
</th>
|
||||||
</div>
|
</tr>
|
||||||
</v-card>
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="paginatedItems.length === 0">
|
||||||
|
<td :colspan="klinikRuangAdminHeaders.length" class="text-center text-grey">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="(item, index) in paginatedItems" :key="item.no">
|
||||||
|
<td>{{ (currentPage - 1) * itemsPerPage + index + 1 }}</td>
|
||||||
|
<td>{{ item.barcode }}</td>
|
||||||
|
<td>{{ item.noRekamedik }}</td>
|
||||||
|
<td>{{ item.noAntrian }}</td>
|
||||||
|
<td>{{ item.noAntrianKlinik }}</td>
|
||||||
|
<td>{{ item.noAntrianRuang }}</td>
|
||||||
|
<td>{{ item.shift }}</td>
|
||||||
|
<td>{{ item.pembayaran }}</td>
|
||||||
|
<td>
|
||||||
|
<v-btn color="success" size="small" class="text-white">Action</v-btn>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.status }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
|
||||||
|
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||||
|
<span>Showing {{ showingFrom }} to {{ showingTo }} of {{ filteredItems.length }} entries</span>
|
||||||
|
<div class="d-flex">
|
||||||
|
<v-btn size="small" variant="flat" :disabled="currentPage === 1" @click="prevPage" class="pagination-btn">Previous</v-btn>
|
||||||
|
<v-btn size="small" variant="flat" :disabled="currentPage === totalPages" @click="nextPage" class="pagination-btn ml-2">Next</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-main>
|
</v-main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware:['auth']
|
||||||
|
})
|
||||||
|
|
||||||
|
// === Data Dummy untuk Tabel ===
|
||||||
const klinikRuangAdminHeaders = [
|
const klinikRuangAdminHeaders = [
|
||||||
{ text: 'No' },
|
{ text: 'No' },
|
||||||
{ text: 'Barcode' },
|
{ text: 'Barcode' },
|
||||||
@@ -89,9 +127,167 @@ const klinikRuangAdminHeaders = [
|
|||||||
{ text: 'Pembayaran' },
|
{ text: 'Pembayaran' },
|
||||||
{ text: 'Action' },
|
{ text: 'Action' },
|
||||||
{ text: 'Status' },
|
{ text: 'Status' },
|
||||||
]
|
];
|
||||||
|
|
||||||
|
const allItems = ref([
|
||||||
|
{ no: 1, barcode: '008730180085', noRekamedik: 'RM001', noAntrian: 'A001', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Selesai' },
|
||||||
|
{ no: 2, barcode: '008730180086', noRekamedik: 'RM002', noAntrian: 'A002', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'BPJS', status: 'Proses' },
|
||||||
|
{ no: 3, barcode: '008730180087', noRekamedik: 'RM003', noAntrian: 'A003', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'Tunai', status: 'Menunggu' },
|
||||||
|
{ no: 4, barcode: '008730180088', noRekamedik: 'RM004', noAntrian: 'A004', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Selesai' },
|
||||||
|
{ no: 5, barcode: '008730180089', noRekamedik: 'RM005', noAntrian: 'A005', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'BPJS', status: 'Proses' },
|
||||||
|
{ no: 6, barcode: '008730180090', noRekamedik: 'RM006', noAntrian: 'A006', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Menunggu' },
|
||||||
|
{ no: 7, barcode: '008730180091', noRekamedik: 'RM007', noAntrian: 'A007', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'BPJS', status: 'Selesai' },
|
||||||
|
{ no: 8, barcode: '008730180092', noRekamedik: 'RM008', noAntrian: 'A008', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'Tunai', status: 'Proses' },
|
||||||
|
{ no: 9, barcode: '008730180093', noRekamedik: 'RM009', noAntrian: 'A009', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Menunggu' },
|
||||||
|
{ no: 10, barcode: '008730180094', noRekamedik: 'RM010', noAntrian: 'A010', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'BPJS', status: 'Selesai' },
|
||||||
|
{ no: 11, barcode: '008730180095', noRekamedik: 'RM011', noAntrian: 'A011', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Proses' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// === State untuk Paginasi dan Pencarian ===
|
||||||
|
const itemsPerPage = ref(10);
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const barcodeInput = ref('');
|
||||||
|
|
||||||
|
// === Computed Properties untuk Filter dan Paginasi ===
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
return allItems.value;
|
||||||
|
}
|
||||||
|
const searchLower = searchQuery.value.toLowerCase();
|
||||||
|
return allItems.value.filter(item => {
|
||||||
|
return Object.values(item).some(value =>
|
||||||
|
String(value).toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedItems = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage.value;
|
||||||
|
const end = start + itemsPerPage.value;
|
||||||
|
return filteredItems.value.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showingFrom = computed(() => {
|
||||||
|
if (filteredItems.value.length === 0) return 0;
|
||||||
|
return (currentPage.value - 1) * itemsPerPage.value + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showingTo = computed(() => {
|
||||||
|
const end = currentPage.value * itemsPerPage.value;
|
||||||
|
return Math.min(end, filteredItems.value.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Fungsi untuk Paginasi dan Pencarian ===
|
||||||
|
const prevPage = () => {
|
||||||
|
if (currentPage.value > 1) {
|
||||||
|
currentPage.value--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextPage = () => {
|
||||||
|
if (currentPage.value < totalPages.value) {
|
||||||
|
currentPage.value++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTicket = () => {
|
||||||
|
// Logika untuk menambahkan data ke tabel
|
||||||
|
if (barcodeInput.value) {
|
||||||
|
// Hapus karakter non-digit jika ada
|
||||||
|
const cleanedBarcode = barcodeInput.value.replace(/\D/g, '');
|
||||||
|
const newNo = allItems.value.length + 1;
|
||||||
|
const newItem = {
|
||||||
|
no: newNo,
|
||||||
|
barcode: cleanedBarcode,
|
||||||
|
noRekamedik: `RM${String(newNo).padStart(3, '0')}`,
|
||||||
|
noAntrian: `A${String(newNo).padStart(3, '0')}`,
|
||||||
|
noAntrianKlinik: 'K1',
|
||||||
|
noAntrianRuang: 'R1',
|
||||||
|
shift: 'Pagi',
|
||||||
|
pembayaran: 'Tunai',
|
||||||
|
status: 'Menunggu'
|
||||||
|
};
|
||||||
|
allItems.value.unshift(newItem); // Tambahkan item baru di paling depan
|
||||||
|
barcodeInput.value = ''; // Reset input
|
||||||
|
currentPage.value = 1; // Kembali ke halaman pertama setelah menambahkan data
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Watcher untuk mereset halaman saat filter atau items per page berubah ===
|
||||||
|
watch([searchQuery, itemsPerPage], () => {
|
||||||
|
currentPage.value = 1;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Scoped styles can be added here if needed for this specific component */
|
/* Scoped styles to make the page more lively */
|
||||||
|
.v-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .header-banner {
|
||||||
|
background: linear-gradient(45deg, #42a5f5, #1565c0); /* Blue gradient */
|
||||||
|
/* color: white;
|
||||||
|
padding: 24px;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.header-banner {
|
||||||
|
background: linear-gradient(45deg, #1A237E, #283593); /* Dark Blue gradient */
|
||||||
|
color: white;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.barcode-input .v-field--variant-solo {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-warning {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-items {
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-items .v-field--variant-solo {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table :deep(th) {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table :deep(tr) {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table :deep(tbody tr:nth-of-type(odd)) {
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn {
|
||||||
|
margin: 0 4px;
|
||||||
|
background-color: #ffb38a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-queue-card {
|
||||||
|
background: linear-gradient(135deg, #00A896, #00796B); /* Teal gradient */
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-queue-number {
|
||||||
|
background-color: white;
|
||||||
|
border: 4px solid #00A896;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: #00A896 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
868
pages/LoginPage.vue
Normal file
868
pages/LoginPage.vue
Normal file
@@ -0,0 +1,868 @@
|
|||||||
|
<!-- // Pages/LoginPage.vue -->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-container fluid fill-height class="login-background">
|
||||||
|
|
||||||
|
<!-- Navigation Bar -->
|
||||||
|
<v-app-bar class="navbar" flat>
|
||||||
|
<v-toolbar-title class="brand-logo">
|
||||||
|
<v-icon class="mr-2" color="white">mdi-hospital-building</v-icon>
|
||||||
|
<span class="font-weight-bold text-blue-darken-3">ANTREAN</span> <span class="font-weight-bold" >RSSA</span>
|
||||||
|
</v-toolbar-title>
|
||||||
|
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
|
<!-- Navigation with Dropdown Menus -->
|
||||||
|
<v-menu offset-y>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
color="white"
|
||||||
|
class="nav-link"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
Tentang
|
||||||
|
<v-icon right small>mdi-chevron-down</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list class="nav-dropdown">
|
||||||
|
<v-list-item class="dropdown-item">
|
||||||
|
<v-icon class="mr-3">mdi-hospital-building</v-icon>
|
||||||
|
<v-list-item-title>Profil Rumah Sakit</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<v-menu offset-y>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
color="white"
|
||||||
|
class="nav-link"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
Kontak
|
||||||
|
<v-icon right small>mdi-chevron-down</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list class="nav-dropdown">
|
||||||
|
<v-list-item class="dropdown-item">
|
||||||
|
<v-icon class="mr-3">mdi-phone</v-icon>
|
||||||
|
<v-list-item-title>Hubungi Kami</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item class="dropdown-item">
|
||||||
|
<v-icon class="mr-3">mdi-map-marker</v-icon>
|
||||||
|
<v-list-item-title>Alamat & Lokasi</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item class="dropdown-item">
|
||||||
|
<v-icon class="mr-3">mdi-email</v-icon>
|
||||||
|
<v-list-item-title>Email</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item class="dropdown-item">
|
||||||
|
<v-icon class="mr-3">mdi-help-circle</v-icon>
|
||||||
|
<v-list-item-title>Bantuan</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<v-btn icon color="white" class="ml-4">
|
||||||
|
<v-icon>mdi-menu</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-app-bar>
|
||||||
|
|
||||||
|
<!-- Floating Medical Icons Background -->
|
||||||
|
<div class="floating-medical-icon icon-1">
|
||||||
|
<v-icon size="144">mdi-heart-pulse</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="floating-medical-icon icon-2">
|
||||||
|
<v-icon size="108">mdi-medical-bag</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="floating-medical-icon icon-3">
|
||||||
|
<v-icon size="126">mdi-stethoscope</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="floating-medical-icon icon-4">
|
||||||
|
<v-icon size="120">mdi-hospital-box</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="floating-medical-icon icon-5">
|
||||||
|
<v-icon size="96">mdi-pill</v-icon>
|
||||||
|
</div>
|
||||||
|
<div class="floating-medical-icon icon-6">
|
||||||
|
<v-icon size="114">mdi-bandage</v-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row class="fill-height align-center justify-center">
|
||||||
|
<!-- Left Content -->
|
||||||
|
<v-col cols="12" md="6" class="text-section">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="logo-section mb-6">
|
||||||
|
<img
|
||||||
|
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
|
||||||
|
alt="Logo Rumah Sakit"
|
||||||
|
class="mt-3 hospital-logo"
|
||||||
|
/>
|
||||||
|
<h1 class="hero-title">Sistem Terbaik</h1>
|
||||||
|
<h1 class="hero-title">Untuk Pelayanan</h1>
|
||||||
|
<h1 class="hero-title">Kesehatan</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hero-description">
|
||||||
|
Tingkatkan efisiensi layanan rumah sakit dengan sistem antrean RSSA yang canggih dan intuitif.
|
||||||
|
Dirancang dengan kesederhanaan, keamanan, dan kecepatan, memastikan perjalanan
|
||||||
|
Anda ke platform kami semudah mungkin. Mari kita buat inovasi menjadi sederhana!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<!-- Right Login Card -->
|
||||||
|
<v-col cols="12" md="6" class="d-flex justify-center">
|
||||||
|
<v-card class="login-card white-card rounded-xl pa-8" max-width="450" width="100%">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
|
||||||
|
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logo Section -->
|
||||||
|
<div class="d-flex flex-column align-center text-center mb-6">
|
||||||
|
<span class="text-h5 font-weight-bold app-title-dark text-blue-darken-3">Antrean RSSA</span>
|
||||||
|
<img
|
||||||
|
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
|
||||||
|
alt="Logo Rumah Sakit"
|
||||||
|
class="mt-3 hospital-logo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert Messages -->
|
||||||
|
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-alert v-if="successMessage" type="success" class="mb-4">
|
||||||
|
{{ successMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<!-- SSO Info -->
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keycloak Login Button -->
|
||||||
|
<v-btn
|
||||||
|
@click="handleLogin"
|
||||||
|
class="login-btn"
|
||||||
|
block
|
||||||
|
rounded="lg"
|
||||||
|
size="large"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<v-icon left>mdi-shield-key</v-icon>
|
||||||
|
<span class="font-weight-bold">
|
||||||
|
{{ isLoading ? 'Connecting to Keycloak...' : 'Login dengan Keycloak' }}
|
||||||
|
</span>
|
||||||
|
<v-icon right>mdi-arrow-right</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<!-- Registration Section -->
|
||||||
|
<v-divider class="my-6 custom-divider-dark"></v-divider>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<v-btn
|
||||||
|
@click="showRegistrationDialog = true"
|
||||||
|
class="register-btn-dark"
|
||||||
|
variant="outlined"
|
||||||
|
block
|
||||||
|
rounded="lg"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<v-icon left>mdi-account-plus</v-icon>
|
||||||
|
<span class="font-weight-bold">Daftar Akun Baru</span>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<span class="help-text-dark">
|
||||||
|
Belum memiliki akun?
|
||||||
|
</span>
|
||||||
|
<br>
|
||||||
|
<v-btn
|
||||||
|
@click="showAdminContact = true"
|
||||||
|
variant="text"
|
||||||
|
color="#0053AD"
|
||||||
|
size="small"
|
||||||
|
class="contact-link-dark mt-1"
|
||||||
|
>
|
||||||
|
Hubungi Administrator
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Help -->
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- Registration Information Dialog -->
|
||||||
|
<v-dialog v-model="showRegistrationDialog" max-width="500">
|
||||||
|
<v-card class="white-dialog rounded-xl">
|
||||||
|
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
|
||||||
|
<v-icon left color="#0053AD">mdi-account-plus</v-icon>
|
||||||
|
Pendaftaran Akun Baru
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="text-grey-darken-2 pa-6">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<v-icon size="64" color="#0053AD" class="mb-4">mdi-information</v-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-body-1 mb-4">
|
||||||
|
Untuk mendaftar akun baru pada sistem Antrean RSSA, silakan ikuti langkah berikut:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-list class="transparent">
|
||||||
|
<v-list-item class="text-grey-darken-2 px-0">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon color="#0053AD">mdi-numeric-1-circle</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title class="text-grey-darken-2">
|
||||||
|
Hubungi Administrator IT Rumah Sakit
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item class="text-grey-darken-2 px-0">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon color="#0053AD">mdi-numeric-2-circle</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title class="text-grey-darken-2">
|
||||||
|
Siapkan dokumen identitas dan surat penugasan
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item class="text-grey-darken-2 px-0">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon color="#0053AD">mdi-numeric-3-circle</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title class="text-grey-darken-2">
|
||||||
|
Tunggu proses verifikasi dan aktivasi akun
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="pa-6 bg-grey-lighten-5">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
@click="showRegistrationDialog = false"
|
||||||
|
color="grey"
|
||||||
|
variant="outlined"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
Tutup
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
@click="showRegistrationDialog = false; showAdminContact = true"
|
||||||
|
color="#0053AD"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<v-icon left>mdi-phone</v-icon>
|
||||||
|
Hubungi Admin
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Admin Contact Dialog -->
|
||||||
|
<v-dialog v-model="showAdminContact" max-width="500">
|
||||||
|
<v-card class="white-dialog rounded-xl">
|
||||||
|
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
|
||||||
|
<v-icon left color="#0053AD">mdi-account-tie</v-icon>
|
||||||
|
Kontak Administrator
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="text-grey-darken-2 pa-6">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<v-icon size="64" color="#0053AD" class="mb-4">mdi-phone-settings</v-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-list class="transparent">
|
||||||
|
<v-list-item class="text-grey-darken-2 px-0 mb-2">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon color="#0053AD">mdi-office-building</v-icon>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<v-list-item-title class="text-grey-darken-2 font-weight-bold">
|
||||||
|
IT Support RSSA
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-grey-darken-1">
|
||||||
|
Bagian Teknologi Informasi
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item class="text-grey-darken-2 px-0 mb-2">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon color="#0053AD">mdi-phone</v-icon>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<v-list-item-title class="text-grey-darken-2">
|
||||||
|
(0341) 343343 ext. 1234
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-grey-darken-1">
|
||||||
|
Telepon Internal
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item class="text-grey-darken-2 px-0 mb-2">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon color="#0053AD">mdi-email</v-icon>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<v-list-item-title class="text-grey-darken-2">
|
||||||
|
it-support@rssa.malang.go.id
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-grey-darken-1">
|
||||||
|
Email Resmi
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item class="text-grey-darken-2 px-0">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon color="#0053AD">mdi-clock</v-icon>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<v-list-item-title class="text-grey-darken-2">
|
||||||
|
Senin - Jumat: 07:00 - 15:00
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-grey-darken-1">
|
||||||
|
Jam Operasional
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-4"
|
||||||
|
color="blue"
|
||||||
|
>
|
||||||
|
<div class="text-grey-darken-2">
|
||||||
|
<strong>Catatan:</strong> Pendaftaran akun memerlukan verifikasi dokumen dan dapat memakan waktu 1-2 hari kerja.
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="pa-6 bg-grey-lighten-5">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
@click="showAdminContact = false"
|
||||||
|
color="grey"
|
||||||
|
variant="outlined"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
Tutup
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
@click="copyContactInfo"
|
||||||
|
color="#0053AD"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<v-icon left>mdi-content-copy</v-icon>
|
||||||
|
Salin Info
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { LoginResponse } from '~/types/auth'
|
||||||
|
|
||||||
|
// Use guest middleware to redirect if already authenticated
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'empty',
|
||||||
|
middleware:['auth','guest']
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
|
const isLoading = ref<boolean>(false)
|
||||||
|
const errorMessage = ref<string>('')
|
||||||
|
const successMessage = ref<string>('')
|
||||||
|
const showRegistrationDialog = ref<boolean>(false)
|
||||||
|
const showAdminContact = ref<boolean>(false)
|
||||||
|
|
||||||
|
// Check URL parameters for errors
|
||||||
|
const route = useRoute()
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.error) {
|
||||||
|
errorMessage.value = decodeURIComponent(route.query.error as string)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Custom login handler
|
||||||
|
const handleLogin = async (): Promise<void> => {
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Starting login process...')
|
||||||
|
|
||||||
|
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response?.success && response?.data?.authUrl) {
|
||||||
|
console.log('Redirecting to Keycloak...')
|
||||||
|
successMessage.value = 'Redirecting to Keycloak...'
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = response.data!.authUrl
|
||||||
|
}, 500)
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to get authorization URL')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Login error:', error)
|
||||||
|
errorMessage.value = `Login failed: ${error.message || 'Please try again.'}`
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy contact information to clipboard
|
||||||
|
const copyContactInfo = async (): Promise<void> => {
|
||||||
|
const contactInfo = `
|
||||||
|
IT Support RSSA
|
||||||
|
Telepon: (0341) 343343 ext. 1234
|
||||||
|
Email: it-support@rssa.malang.go.id
|
||||||
|
Jam Operasional: Senin - Jumat, 08:00 - 16:00
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(contactInfo)
|
||||||
|
successMessage.value = 'Informasi kontak berhasil disalin!'
|
||||||
|
showAdminContact.value = false
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
successMessage.value = ''
|
||||||
|
}, 3000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy contact info:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Main Background */
|
||||||
|
.login-background {
|
||||||
|
background: linear-gradient(135deg, #f1b464 0%, #faa22e 25%, #e49458 50%, #e46f30 75%, #e26450 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Bar */
|
||||||
|
.navbar {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: white !important;
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Medical Icons */
|
||||||
|
.floating-medical-icon {
|
||||||
|
position: absolute;
|
||||||
|
animation: dvdBounce 20s linear infinite;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 1;
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-medical-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-medical-icon .v-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-1 {
|
||||||
|
top: 0%;
|
||||||
|
left: 0%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
animation-duration: 25s;
|
||||||
|
animation-name: dvdBounce1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-1 .v-icon {
|
||||||
|
color: rgba(6, 37, 83, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-2 {
|
||||||
|
top: 0%;
|
||||||
|
right: 0%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
animation-duration: 30s;
|
||||||
|
animation-name: dvdBounce2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-2 .v-icon {
|
||||||
|
color: rgba(15, 180, 70, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-3 {
|
||||||
|
bottom: 0%;
|
||||||
|
left: 0%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
animation-duration: 22s;
|
||||||
|
animation-name: dvdBounce3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-3 .v-icon {
|
||||||
|
color: rgba(23, 178, 206, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-4 {
|
||||||
|
top: 50%;
|
||||||
|
left: 0%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
animation-duration: 28s;
|
||||||
|
animation-name: dvdBounce4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-4 .v-icon {
|
||||||
|
color: rgba(223, 8, 8, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-5 {
|
||||||
|
bottom: 0%;
|
||||||
|
right: 0%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
animation-duration: 26s;
|
||||||
|
animation-name: dvdBounce5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-5 .v-icon {
|
||||||
|
color: rgba(148, 15, 236, 0.16) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-6 {
|
||||||
|
top: 25%;
|
||||||
|
left: 0%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
animation-duration: 24s;
|
||||||
|
animation-name: dvdBounce6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-6 .v-icon {
|
||||||
|
color: rgba(87, 48, 5, 0.13) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DVD Bounce Animation 1 - Top Left to Bottom Right */
|
||||||
|
@keyframes dvdBounce1 {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
25% { transform: translate(80vw, 70vh); }
|
||||||
|
50% { transform: translate(20vw, 10vh); }
|
||||||
|
75% { transform: translate(70vw, 80vh); }
|
||||||
|
100% { transform: translate(0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DVD Bounce Animation 2 - Top Right to Bottom Left */
|
||||||
|
@keyframes dvdBounce2 {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
25% { transform: translate(-75vw, 60vh); }
|
||||||
|
50% { transform: translate(-30vw, 20vh); }
|
||||||
|
75% { transform: translate(-85vw, 75vh); }
|
||||||
|
100% { transform: translate(0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DVD Bounce Animation 3 - Bottom Left to Top Right */
|
||||||
|
@keyframes dvdBounce3 {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
25% { transform: translate(70vw, -60vh); }
|
||||||
|
50% { transform: translate(40vw, -80vh); }
|
||||||
|
75% { transform: translate(90vw, -30vh); }
|
||||||
|
100% { transform: translate(0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DVD Bounce Animation 4 - Middle Left across screen */
|
||||||
|
@keyframes dvdBounce4 {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
16.6% { transform: translate(60vw, -30vh); }
|
||||||
|
33.3% { transform: translate(90vw, 20vh); }
|
||||||
|
50% { transform: translate(50vw, 40vh); }
|
||||||
|
66.6% { transform: translate(10vw, -20vh); }
|
||||||
|
83.3% { transform: translate(80vw, -40vh); }
|
||||||
|
100% { transform: translate(0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DVD Bounce Animation 5 - Bottom Right to Top Left */
|
||||||
|
@keyframes dvdBounce5 {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
25% { transform: translate(-60vw, -70vh); }
|
||||||
|
50% { transform: translate(-90vw, -20vh); }
|
||||||
|
75% { transform: translate(-40vw, -80vh); }
|
||||||
|
100% { transform: translate(0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DVD Bounce Animation 6 - Complex zigzag pattern */
|
||||||
|
@keyframes dvdBounce6 {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
14.3% { transform: translate(50vw, 30vh); }
|
||||||
|
28.6% { transform: translate(85vw, -20vh); }
|
||||||
|
42.9% { transform: translate(30vw, 60vh); }
|
||||||
|
57.1% { transform: translate(70vw, 10vh); }
|
||||||
|
71.4% { transform: translate(15vw, 70vh); }
|
||||||
|
85.7% { transform: translate(80vw, 40vh); }
|
||||||
|
100% { transform: translate(0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Section */
|
||||||
|
.text-section {
|
||||||
|
padding-left: 4rem;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
color: white;
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-description {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 2rem;
|
||||||
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Card - White Background */
|
||||||
|
.login-card {
|
||||||
|
z-index: 3;
|
||||||
|
max-width: 450px;
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.white-card {
|
||||||
|
background: white !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 50px rgba(0, 0, 0, 0.15),
|
||||||
|
0 8px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* White Dialog Styles */
|
||||||
|
.white-dialog {
|
||||||
|
background: white !important;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Header - Dark Text */
|
||||||
|
.welcome-title-dark {
|
||||||
|
color: #37474F;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-instruction-dark {
|
||||||
|
color: #546E7A;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App Title - Dark */
|
||||||
|
.app-title-dark {
|
||||||
|
color: #0053AD;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hospital-logo {
|
||||||
|
height: 64px;
|
||||||
|
width: auto;
|
||||||
|
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sso-text-dark {
|
||||||
|
color: #546E7A;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.login-btn {
|
||||||
|
background: linear-gradient(135deg, #0053AD 0%, #0663C7 50%, #0671E0 100%) !important;
|
||||||
|
color: white !important;
|
||||||
|
border: none;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 25px rgba(0, 83, 173, 0.3),
|
||||||
|
0 4px 12px rgba(0, 83, 173, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-transform: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: linear-gradient(135deg, #004A9B 0%, #0558B0 50%, #0661CA 100%) !important;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 30px rgba(0, 83, 173, 0.4),
|
||||||
|
0 6px 15px rgba(0, 83, 173, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-btn-dark {
|
||||||
|
color: #0053AD !important;
|
||||||
|
border: 2px solid #0053AD !important;
|
||||||
|
background: transparent !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-transform: none;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-btn-dark:hover {
|
||||||
|
background: rgba(0, 83, 173, 0.05) !important;
|
||||||
|
border-color: #0663C7 !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Divider - Dark */
|
||||||
|
.custom-divider-dark {
|
||||||
|
border-color: rgba(0, 0, 0, 0.12) !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help Text and Links - Dark */
|
||||||
|
.help-text-dark {
|
||||||
|
color: #546E7A;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link-dark {
|
||||||
|
color: #0053AD !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-transform: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-link-dark {
|
||||||
|
color: #78909C;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-link-dark:hover {
|
||||||
|
color: #0053AD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transparent Background */
|
||||||
|
.transparent {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Dropdown Styles */
|
||||||
|
.nav-dropdown {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
min-width: 220px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
color: #FF9B1B;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 4px 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: linear-gradient(135deg, #FF9B1B 0%, #FF8F00 100%);
|
||||||
|
color: white;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item .v-icon {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover .v-icon {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.text-section {
|
||||||
|
padding-left: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-description {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.hero-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-section {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,89 +1,118 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<v-main>
|
<v-main class="bg-grey-lighten-3">
|
||||||
<v-container fluid class="pa-4 main-content-padding">
|
<v-container fluid class="pa-6 main-content-padding">
|
||||||
<!-- Header Stats -->
|
<!-- Header Banner & Stats -->
|
||||||
<div class="d-flex justify-space-between align-center mb-4">
|
<v-card class="d-flex justify-space-between align-center pa-5 rounded-xl elevation-4 mb-6 header-banner">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<span class="text-h6 mr-4">Total 0</span>
|
<v-icon size="40" class="mr-3 text-white">mdi-hospital-box-outline</v-icon>
|
||||||
<span class="text-body-2">Max 150 Pasien</span>
|
<span class="text-h4 font-weight-bold text-white">Loket Admin </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center text-white text-end flex-wrap justify-end">
|
||||||
<span class="mr-4">Dashboard</span>
|
<span class="mr-4">Loket 24</span>
|
||||||
<span class="mr-4">Loket 24 | Senin, 11 Agustus 2025</span>
|
<span class="mr-4">{{ currentDateLongFormatted }}</span>
|
||||||
<span class="mr-4">11 Agustus 2025 - Pelayanan</span>
|
<span>{{ currentDateShortFormatted }} - Pelayanan</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</v-card>
|
||||||
|
|
||||||
<!-- Status Cards -->
|
<!-- Status Cards Section -->
|
||||||
<v-card class="pa-5 mb-5" color="white" flat></v-card>
|
<v-row class="mb-6">
|
||||||
<v-row align="center">
|
<!-- Panggil 1 Antrian Card -->
|
||||||
<v-col cols="12" md="1">
|
<v-col cols="12" sm="6" md="3">
|
||||||
<v-card color="green" dark class="text-center clickable-card" @click="handleStatusCardClick('1')">
|
<v-card
|
||||||
<v-card-text>
|
class="pa-4 rounded-xl elevation-2 text-center"
|
||||||
<div class="text-h4">1</div>
|
color="#4CAF50"
|
||||||
|
@click="handleStatusCardClick(1)"
|
||||||
|
>
|
||||||
|
<v-card-text class="text-white">
|
||||||
|
<div class="text-h4 font-weight-bold">1</div>
|
||||||
|
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="1">
|
<!-- Panggil 5 Antrian Card -->
|
||||||
<v-card color="blue" dark class="text-center clickable-card" @click="handleStatusCardClick('5')">
|
<v-col cols="12" sm="6" md="3">
|
||||||
<v-card-text>
|
<v-card
|
||||||
<div class="text-h4">5</div>
|
class="pa-4 rounded-xl elevation-2 text-center"
|
||||||
|
color="#4CAF50"
|
||||||
|
@click="handleStatusCardClick(5)"
|
||||||
|
>
|
||||||
|
<v-card-text class="text-white">
|
||||||
|
<div class="text-h4 font-weight-bold">5</div>
|
||||||
|
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="1">
|
<!-- Panggil 10 Antrian Card -->
|
||||||
<v-card color="orange" dark class="text-center clickable-card" @click="handleStatusCardClick('10')">
|
<v-col cols="12" sm="6" md="3">
|
||||||
<v-card-text>
|
<v-card
|
||||||
<div class="text-h4">10</div>
|
class="pa-4 rounded-xl elevation-2 text-center"
|
||||||
|
color="#4CAF50"
|
||||||
|
@click="handleStatusCardClick(10)"
|
||||||
|
>
|
||||||
|
<v-card-text class="text-white">
|
||||||
|
<div class="text-h4 font-weight-bold">10</div>
|
||||||
|
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="1">
|
<!-- Panggil 20 Antrian Card -->
|
||||||
<v-card color="red" dark class="text-center clickable-card" @click="handleStatusCardClick('20')">
|
<v-col cols="12" sm="6" md="3">
|
||||||
<v-card-text>
|
<v-card
|
||||||
<div class="text-h4">20</div>
|
class="pa-4 rounded-xl elevation-2 text-center"
|
||||||
|
color="#4CAF50"
|
||||||
|
@click="handleStatusCardClick(20)"
|
||||||
|
>
|
||||||
|
<v-card-text class="text-white">
|
||||||
|
<div class="text-h4 font-weight-bold">20</div>
|
||||||
|
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
|
|
||||||
<!-- Next Patient Card -->
|
<!-- Next Patient Card -->
|
||||||
<v-col cols="12" md="4">
|
<v-card class="next-patient-card d-flex align-center justify-center pa-8 text-center rounded-xl elevation-6 mb-6">
|
||||||
<v-card color="green" dark class="mb-4 clickable-card" @click="handleNextPatientClick">
|
<div class="text-center">
|
||||||
<v-card-text class="text-center">
|
<div class="text-h4 text-white">NEXT PATIENT</div>
|
||||||
<div class="text-h4 mb-2">NEXT</div>
|
<div class="text-h2 font-weight-bold text-white mt-2">UM1001</div>
|
||||||
<div class="text-h6 mb-1">Pasien : UM1001</div>
|
<v-btn
|
||||||
<div class="text-body-2">
|
size="large"
|
||||||
Klik untuk memanggil pasien selanjutnya
|
color="#00A896"
|
||||||
</div>
|
class="mt-4 text-white"
|
||||||
</v-card-text>
|
@click="handleNextPatientClick"
|
||||||
</v-card>
|
>
|
||||||
</v-col>
|
<v-icon start>mdi-arrow-right-circle</v-icon>
|
||||||
|
Panggil Pasien Selanjutnya
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
<!-- Main Data Table -->
|
<!-- Main Data Table -->
|
||||||
<v-card class="mb-4">
|
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||||
<v-card-title class="d-flex justify-space-between align-center">
|
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||||
<span>Data Pasien</span>
|
Data Pasien
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<span class="mr-2">Show</span>
|
<span class="mr-2 text-caption">Show</span>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="itemsPerPage"
|
v-model="itemsPerPage"
|
||||||
:items="[10, 25, 50, 100]"
|
:items="[10, 25, 50, 100]"
|
||||||
density="compact"
|
density="compact"
|
||||||
variant="outlined"
|
variant="solo"
|
||||||
style="max-width: 80px"
|
flat
|
||||||
class="mr-4"
|
hide-details
|
||||||
|
class="mx-2 select-items"
|
||||||
></v-select>
|
></v-select>
|
||||||
<span class="mr-2">entries</span>
|
<span class="mr-2 text-caption">entries</span>
|
||||||
<span class="mr-4">Search:</span>
|
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="search"
|
v-model="search"
|
||||||
density="compact"
|
density="compact"
|
||||||
variant="outlined"
|
variant="solo"
|
||||||
style="max-width: 200px"
|
flat
|
||||||
hide-details
|
hide-details
|
||||||
|
append-inner-icon="mdi-magnify"
|
||||||
|
label="Search"
|
||||||
|
style="max-width: 200px"
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
</div>
|
</div>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
@@ -92,57 +121,88 @@
|
|||||||
:items="mainPatients"
|
:items="mainPatients"
|
||||||
:search="search"
|
:search="search"
|
||||||
:items-per-page="itemsPerPage"
|
:items-per-page="itemsPerPage"
|
||||||
class="elevation-1"
|
:row-class="getRowClass"
|
||||||
|
class="custom-table"
|
||||||
>
|
>
|
||||||
|
<!-- Custom template for the 'aksi' column -->
|
||||||
<template v-slot:item.aksi="{ item }">
|
<template v-slot:item.aksi="{ item }">
|
||||||
<div class="d-flex ga-1">
|
<div class="d-flex ga-1">
|
||||||
<v-btn size="small" color="success" variant="flat"
|
<!-- Show different buttons based on the item's status -->
|
||||||
>Panggil</v-btn
|
<template v-if="item.status === 'dipanggil'">
|
||||||
>
|
<v-btn size="small" color="primary" variant="flat" class="rounded-lg" @click="handleProsesClick(item)">
|
||||||
<v-btn size="small" color="info" variant="flat">Cancel</v-btn>
|
<v-icon start>mdi-cogs</v-icon>Proses
|
||||||
<v-btn size="small" color="primary" variant="flat"
|
</v-btn>
|
||||||
>Selesai</v-btn
|
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
|
||||||
>
|
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'dalam_proses'">
|
||||||
|
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
|
||||||
|
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
|
||||||
|
</v-btn>
|
||||||
|
<v-btn size="small" color="warning" variant="flat" class="rounded-lg">
|
||||||
|
<v-icon start>mdi-pause-circle-outline</v-icon>Tunda
|
||||||
|
</v-btn>
|
||||||
|
<v-btn size="small" color="error" variant="flat" class="rounded-lg">
|
||||||
|
<v-icon start>mdi-close-circle-outline</v-icon>Batal
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<v-btn size="small" color="primary" variant="flat" class="rounded-lg">
|
||||||
|
<v-icon start>mdi-cogs</v-icon>Proses
|
||||||
|
</v-btn>
|
||||||
|
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
|
||||||
|
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:item.jamPanggil="{ item }">
|
<!-- Custom template for the 'panggil' column -->
|
||||||
<span :class="getRowClass(item)">{{ item.jamPanggil }}</span>
|
<template v-slot:item.panggil="{ item }">
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
:color="item.status === 'dalam_proses' ? 'grey' : 'info'"
|
||||||
|
variant="flat"
|
||||||
|
class="rounded-lg"
|
||||||
|
@click="handlePanggilClick(item)"
|
||||||
|
:disabled="item.status === 'dalam_proses'"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-phone</v-icon>Panggil
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<!-- Custom template for the 'noAntrian' column -->
|
||||||
|
<template v-slot:item.noAntrian="{ item }">
|
||||||
|
<span :class="{'online-antrian': item.status === 'dipanggil'}">{{ item.noAntrian }}</span>
|
||||||
</template>
|
</template>
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- Total Quota Used -->
|
|
||||||
<v-card color="cyan" dark class="mb-4">
|
|
||||||
<v-card-text class="text-center">
|
|
||||||
<div class="text-h6">Total Quota Terpakai 5</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<!-- Late Patients Table -->
|
<!-- Late Patients Table -->
|
||||||
<v-card class="mb-4">
|
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||||
<v-card-title class="d-flex justify-space-between align-center">
|
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||||
<span>Info Pasien Lapor Terlambat</span>
|
Info Pasien Lapor Terlambat
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<span class="mr-2 text-caption text-orange"
|
<span class="mr-2 text-caption text-orange">KETERANGAN: PASIEN MASUK PADA TANGGAL</span>
|
||||||
>KETERANGAN: PASIEN MASUK PADA TANGGAL</span
|
<span class="mr-2 text-caption">Show</span>
|
||||||
>
|
|
||||||
<span class="mr-2">Show</span>
|
|
||||||
<v-select
|
<v-select
|
||||||
v-model="lateItemsPerPage"
|
v-model="lateItemsPerPage"
|
||||||
:items="[10, 25, 50, 100]"
|
:items="[10, 25, 50, 100]"
|
||||||
density="compact"
|
density="compact"
|
||||||
variant="outlined"
|
variant="solo"
|
||||||
style="max-width: 80px"
|
flat
|
||||||
class="mr-4"
|
hide-details
|
||||||
|
class="mx-2 select-items"
|
||||||
></v-select>
|
></v-select>
|
||||||
<span class="mr-2">entries</span>
|
<span class="mr-2 text-caption">entries</span>
|
||||||
<span class="mr-4">Search:</span>
|
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="lateSearch"
|
v-model="lateSearch"
|
||||||
density="compact"
|
density="compact"
|
||||||
variant="outlined"
|
variant="solo"
|
||||||
style="max-width: 200px"
|
flat
|
||||||
hide-details
|
hide-details
|
||||||
|
append-inner-icon="mdi-magnify"
|
||||||
|
label="Search"
|
||||||
|
style="max-width: 200px"
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
</div>
|
</div>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
@@ -151,48 +211,50 @@
|
|||||||
:items="latePatients"
|
:items="latePatients"
|
||||||
:search="lateSearch"
|
:search="lateSearch"
|
||||||
:items-per-page="lateItemsPerPage"
|
:items-per-page="lateItemsPerPage"
|
||||||
class="elevation-1"
|
class="custom-table"
|
||||||
>
|
>
|
||||||
<template v-slot:no-data>
|
<template v-slot:no-data>
|
||||||
<div class="text-center pa-4">No data available in table</div>
|
<div class="text-center pa-4">Tidak ada data yang tersedia</div>
|
||||||
</template>
|
</template>
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- Clinic Entry Patients Table -->
|
<!-- Clinic Entry Patients Table -->
|
||||||
<v-card>
|
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||||
<v-card-title class="d-flex justify-space-between align-center">
|
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||||
<span>Info Pasien Masuk Klinik</span>
|
Info Pasien Masuk Klinik
|
||||||
<div class="d-flex align-center">
|
|
||||||
<span class="mr-2">Show</span>
|
|
||||||
<v-select
|
|
||||||
v-model="clinicItemsPerPage"
|
|
||||||
:items="[10, 25, 50, 100]"
|
|
||||||
density="compact"
|
|
||||||
variant="outlined"
|
|
||||||
style="max-width: 80px"
|
|
||||||
class="mr-4"
|
|
||||||
></v-select>
|
|
||||||
<span class="mr-2">entries</span>
|
|
||||||
<span class="mr-4">Search:</span>
|
|
||||||
<v-text-field
|
|
||||||
v-model="clinicSearch"
|
|
||||||
density="compact"
|
|
||||||
variant="outlined"
|
|
||||||
style="max-width: 200px"
|
|
||||||
hide-details
|
|
||||||
></v-text-field>
|
|
||||||
</div>
|
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-data-table
|
<v-data-table
|
||||||
:headers="clinicHeaders"
|
:headers="clinicHeaders"
|
||||||
:items="clinicPatients"
|
:items="clinicPatients"
|
||||||
:search="clinicSearch"
|
:search="clinicSearch"
|
||||||
:items-per-page="clinicItemsPerPage"
|
:items-per-page="clinicItemsPerPage"
|
||||||
class="elevation-1"
|
class="custom-table"
|
||||||
>
|
>
|
||||||
<template v-slot:no-data>
|
<template v-slot:no-data>
|
||||||
<div class="text-center pa-4">No data available in table</div>
|
<div class="text-center pa-4">Tidak ada data yang tersedia</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Info Klinik Table -->
|
||||||
|
<v-card class="pa-6 rounded-xl elevation-2">
|
||||||
|
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||||
|
Info Klinik
|
||||||
|
</v-card-title>
|
||||||
|
<v-data-table
|
||||||
|
:headers="infoKlinikHeaders"
|
||||||
|
:items="infoKlinikData"
|
||||||
|
class="custom-table"
|
||||||
|
hide-default-footer
|
||||||
|
disable-pagination
|
||||||
|
>
|
||||||
|
<template v-slot:bottom>
|
||||||
|
<v-card-text class="d-flex justify-end text-right">
|
||||||
|
<span class="mr-4 font-weight-bold text-h6">Total:</span>
|
||||||
|
<span class="mr-12 font-weight-bold text-h6 text-primary">{{ totalDapatDipanggil }}</span>
|
||||||
|
<span class="font-weight-bold text-h6 text-primary">{{ totalShiftBelumBuka }}</span>
|
||||||
|
</v-card-text>
|
||||||
</template>
|
</template>
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -201,7 +263,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, onMounted, computed } from "vue";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware:['auth']
|
||||||
|
})
|
||||||
|
|
||||||
// Reactive data
|
// Reactive data
|
||||||
const search = ref("");
|
const search = ref("");
|
||||||
@@ -210,6 +276,8 @@ const clinicSearch = ref("");
|
|||||||
const itemsPerPage = ref(10);
|
const itemsPerPage = ref(10);
|
||||||
const lateItemsPerPage = ref(10);
|
const lateItemsPerPage = ref(10);
|
||||||
const clinicItemsPerPage = ref(10);
|
const clinicItemsPerPage = ref(10);
|
||||||
|
const currentDateLongFormatted = ref("");
|
||||||
|
const currentDateShortFormatted = ref("");
|
||||||
|
|
||||||
// Table headers
|
// Table headers
|
||||||
const mainHeaders = ref([
|
const mainHeaders = ref([
|
||||||
@@ -221,8 +289,8 @@ const mainHeaders = ref([
|
|||||||
{ title: "Klinik", value: "klinik" },
|
{ title: "Klinik", value: "klinik" },
|
||||||
{ title: "Fast Track", value: "fastTrack" },
|
{ title: "Fast Track", value: "fastTrack" },
|
||||||
{ title: "Pembayaran", value: "pembayaran" },
|
{ title: "Pembayaran", value: "pembayaran" },
|
||||||
{ title: "Panggil", value: "panggil" },
|
{ title: "Panggil", align: 'center', value: "panggil", sortable: false },
|
||||||
{ title: "Aksi", value: "aksi", sortable: false },
|
{ title: "Aksi", value: "aksi", sortable: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lateHeaders = ref([
|
const lateHeaders = ref([
|
||||||
@@ -246,128 +314,213 @@ const clinicHeaders = ref([
|
|||||||
{ title: "Aksi", value: "aksi", sortable: false },
|
{ title: "Aksi", value: "aksi", sortable: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Sample data
|
const infoKlinikHeaders = ref([
|
||||||
const mainPatients = ref([
|
{ title: "#", value: "no" },
|
||||||
{
|
{ title: "Klinik", value: "klinik" },
|
||||||
no: 1,
|
{ title: "Jumlah Shift", value: "jumlahShift" },
|
||||||
jamPanggil: "12:49",
|
{ title: "Quota Per Shift", value: "quotaPerShift" },
|
||||||
barcode: "250811100163",
|
{ title: "Status", value: "status" },
|
||||||
noAntrian: "UM1001 | Online - 250811100163",
|
{ title: "Dapat Di Panggil", value: "dapatDiPanggil" },
|
||||||
shift: "Shift 1",
|
{ title: "Shift Belum Buka", value: "shiftBelumBuka" },
|
||||||
klinik: "KANDUNGAN",
|
|
||||||
fastTrack: "UMUM",
|
|
||||||
pembayaran: "UMUM",
|
|
||||||
panggil: "Panggil",
|
|
||||||
status: "current",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: 2,
|
|
||||||
jamPanggil: "10:52",
|
|
||||||
barcode: "250811100155",
|
|
||||||
noAntrian: "UM1002 | Online - 250811100155",
|
|
||||||
shift: "Shift 1",
|
|
||||||
klinik: "IPD",
|
|
||||||
fastTrack: "UMUM",
|
|
||||||
pembayaran: "UMUM",
|
|
||||||
panggil: "Cancel",
|
|
||||||
status: "normal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: 3,
|
|
||||||
jamPanggil: "07:35",
|
|
||||||
barcode: "250811100355",
|
|
||||||
noAntrian: "UM1005 | Online - 250811100355",
|
|
||||||
shift: "Shift 1",
|
|
||||||
klinik: "THT",
|
|
||||||
fastTrack: "UMUM",
|
|
||||||
pembayaran: "UMUM",
|
|
||||||
panggil: "Panggil",
|
|
||||||
status: "normal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: 4,
|
|
||||||
jamPanggil: "08:05",
|
|
||||||
barcode: "250811100355",
|
|
||||||
noAntrian: "UM1006 | Online - 250811100355",
|
|
||||||
shift: "Shift 1",
|
|
||||||
klinik: "THT",
|
|
||||||
fastTrack: "UMUM",
|
|
||||||
pembayaran: "UMUM",
|
|
||||||
panggil: "Panggil",
|
|
||||||
status: "normal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: 5,
|
|
||||||
jamPanggil: "12:43",
|
|
||||||
barcode: "250811100402",
|
|
||||||
noAntrian: "UM1004 | Online - 250811100402",
|
|
||||||
shift: "Shift 1",
|
|
||||||
klinik: "SARAF",
|
|
||||||
fastTrack: "UMUM",
|
|
||||||
pembayaran: "UMUM",
|
|
||||||
panggil: "Panggil",
|
|
||||||
status: "normal",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const latePatients = ref([]);
|
// Sample data with new 'originalAntrian' and 'status' properties
|
||||||
|
const mainPatients = ref([
|
||||||
|
{ no: 1, jamPanggil: "11:46", barcode: "250826100362", noAntrian: "UM1002 | Online - 250826100362", originalAntrian: "UM1002", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "dipanggil" },
|
||||||
|
{ no: 2, jamPanggil: "06:47", barcode: "250826100140", noAntrian: "UM1003", originalAntrian: "UM1003", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||||
|
{ no: 3, jamPanggil: "06:47", barcode: "250826100143", noAntrian: "UM1004", originalAntrian: "UM1004", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||||
|
{ no: 4, jamPanggil: "06:47", barcode: "250826100500", noAntrian: "UM1005", originalAntrian: "UM1005", shift: "Shift 1", klinik: "MATA", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||||
|
{ no: 5, jamPanggil: "06:47", barcode: "250826100525", noAntrian: "UM1006", originalAntrian: "UM1006", shift: "Shift 1", klinik: "ONKOLOGI", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||||
|
{ no: 6, jamPanggil: "06:47", barcode: "250826100536", noAntrian: "UM1007", originalAntrian: "UM1007", shift: "Shift 1", klinik: "THT", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tambahkan lebih banyak data pasien untuk demonstrasi "Panggil 20"
|
||||||
|
for (let i = 7; i <= 25; i++) {
|
||||||
|
mainPatients.value.push({
|
||||||
|
no: i,
|
||||||
|
jamPanggil: "07:00",
|
||||||
|
barcode: `250826100${100 + i}`,
|
||||||
|
noAntrian: `UM100${i}`,
|
||||||
|
originalAntrian: `UM100${i}`,
|
||||||
|
shift: "Shift 1",
|
||||||
|
klinik: "UMUM",
|
||||||
|
fastTrack: "",
|
||||||
|
pembayaran: "UMUM",
|
||||||
|
panggil: "Panggil",
|
||||||
|
status: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const latePatients = ref([]);
|
||||||
const clinicPatients = ref([]);
|
const clinicPatients = ref([]);
|
||||||
|
const infoKlinikData = ref([
|
||||||
|
{ no: 1, klinik: "ANESTESI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 2, klinik: "GERIATRI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 3, klinik: "GIGI DAN MULUT", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 4, klinik: "HOM", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 5, klinik: "IPD", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 6, klinik: "JANTUNG", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 7, klinik: "KANDUNGAN", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 8, klinik: "KOMPLEMENTER", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBukan: "-" },
|
||||||
|
{ no: 9, klinik: "KUL.KEL", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 10, klinik: "MATA", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 11, klinik: "ONKOLOGI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 12, klinik: "PARU", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 13, klinik: "SARAF", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
{ no: 14, klinik: "THT", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Computed properties for totals
|
||||||
|
const totalDapatDipanggil = computed(() => {
|
||||||
|
return infoKlinikData.value.reduce((total, item) => {
|
||||||
|
return total + (item.dapatDiPanggil === "-" ? 0 : parseInt(item.dapatDiPanggil));
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalShiftBelumBuka = computed(() => {
|
||||||
|
return infoKlinikData.value.reduce((total, item) => {
|
||||||
|
return total + (item.shiftBelumBuka === "-" ? 0 : parseInt(item.shiftBelumBuka));
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const getRowClass = (item) => {
|
const getRowClass = (item) => {
|
||||||
if (item.status === "current") {
|
if (item.status === 'dipanggil') {
|
||||||
return "text-green font-weight-bold";
|
return 'called-row';
|
||||||
}
|
}
|
||||||
return "";
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusCardClick = (value) => {
|
const handleStatusCardClick = (count) => {
|
||||||
console.log(`Status card with value ${value} was clicked!`);
|
console.log(`Memanggil ${count} antrean pasien.`);
|
||||||
// Tambahkan logika Anda di sini
|
const updatedPatients = mainPatients.value.map((patient, index) => {
|
||||||
|
const isCalled = index < count;
|
||||||
|
return {
|
||||||
|
...patient,
|
||||||
|
status: isCalled ? 'dipanggil' : '',
|
||||||
|
noAntrian: isCalled ? `${patient.originalAntrian} | Online - ${patient.barcode}` : patient.originalAntrian
|
||||||
|
};
|
||||||
|
});
|
||||||
|
mainPatients.value = updatedPatients;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextPatientClick = () => {
|
const handleNextPatientClick = () => {
|
||||||
console.log("Next Patient card was clicked!");
|
console.log("Tombol Panggil Pasien Selanjutnya diklik! (Mengambil pasien pertama dari antrean)");
|
||||||
// Tambahkan logika untuk memanggil pasien selanjutnya di sini
|
const updatedPatients = mainPatients.value.map(patient => {
|
||||||
|
return { ...patient, status: '', noAntrian: patient.originalAntrian };
|
||||||
|
});
|
||||||
|
mainPatients.value = updatedPatients;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePanggilClick = (item) => {
|
||||||
|
console.log(`Tombol Panggil untuk pasien: ${item.noAntrian} diklik!`);
|
||||||
|
|
||||||
|
// Membuat salinan baru dari seluruh array untuk memicu reaktivitas
|
||||||
|
const updatedPatients = mainPatients.value.map(p => {
|
||||||
|
// Jika pasien cocok, buat salinan baru dengan status 'dipanggil' dan tambahkan "Online"
|
||||||
|
if (p.no === item.no) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
status: 'dipanggil',
|
||||||
|
noAntrian: `${p.originalAntrian} | Online - ${p.barcode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Jika tidak, kembalikan objek pasien aslinya
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ganti seluruh array data dengan salinan yang baru.
|
||||||
|
mainPatients.value = updatedPatients;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProsesClick = (item) => {
|
||||||
|
console.log(`Tombol Proses untuk pasien: ${item.noAntrian} diklik!`);
|
||||||
|
|
||||||
|
const updatedPatients = mainPatients.value.map(p => {
|
||||||
|
if (p.no === item.no) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
status: 'dalam_proses'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
mainPatients.value = updatedPatients;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mengatur tanggal saat komponen dimuat
|
||||||
|
onMounted(() => {
|
||||||
|
const today = new Date();
|
||||||
|
const optionsLong = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||||
|
currentDateLongFormatted.value = today.toLocaleDateString('id-ID', optionsLong);
|
||||||
|
|
||||||
|
const optionsShort = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||||
|
currentDateShortFormatted.value = today.toLocaleDateString('id-ID', optionsShort);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.v-list-item--active {
|
/* Scoped styles for a cleaner look */
|
||||||
background-color: rgba(25, 118, 210, 0.12);
|
|
||||||
color: #1976d2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-green {
|
|
||||||
color: #4caf50 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
:deep(.v-data-table) {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.v-data-table__wrapper) {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Row highlighting */
|
|
||||||
:deep(.v-data-table tbody tr:nth-child(1)) {
|
|
||||||
background-color: #fff3cd !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content-padding {
|
.main-content-padding {
|
||||||
padding-left: 64px !important;
|
padding-left: 24px !important;
|
||||||
|
padding-right: 24px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable-card {
|
/* Header Banner */
|
||||||
|
.header-banner {
|
||||||
|
background: linear-gradient(90deg, #1565C0, #1976D2);
|
||||||
|
color: white;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Next Patient Card */
|
||||||
|
.next-patient-card {
|
||||||
|
background: linear-gradient(45deg, #00A896, #00796B);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Cards */
|
||||||
|
.v-card.text-center {
|
||||||
|
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s ease-in-out;
|
}
|
||||||
|
.v-card.text-center:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable-card:hover {
|
/* Table styling */
|
||||||
transform: translateY(-4px);
|
.custom-table :deep(thead th) {
|
||||||
|
background-color: #E8EAF6; /* Light gray-blue header */
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table :deep(tbody tr:nth-of-type(odd)) {
|
||||||
|
background-color: #F5F5F5; /* Light gray for odd rows */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlighted row for "dipanggil" status */
|
||||||
|
.custom-table :deep(tbody tr.called-row) {
|
||||||
|
background-color: #998479 !important; /* Light green background */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Field and Select styling */
|
||||||
|
.select-items .v-field--variant-solo,
|
||||||
|
.v-text-field .v-field--variant-solo {
|
||||||
|
background-color: #ECEFF1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-blue {
|
||||||
|
color: #1976D2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: #1976D2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-antrian {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1976D2;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
175
pages/RanapAdmin.vue
Normal file
175
pages/RanapAdmin.vue
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<v-main class="ranap-admin-page">
|
||||||
|
<v-container fluid class="pa-6">
|
||||||
|
<!-- Modern Header -->
|
||||||
|
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
|
||||||
|
<v-icon size="48" color="white" class="mr-4">mdi-hospital-box-outline</v-icon>
|
||||||
|
<h1 class="text-h4 font-weight-bold text-white">Ranap Admin</h1>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="elevation-2 rounded-xl pa-4">
|
||||||
|
<div class="d-flex justify-space-between align-center mb-4">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<span class="text-caption mr-2">Show</span>
|
||||||
|
<v-select
|
||||||
|
v-model="itemsPerPage"
|
||||||
|
:items="[10, 25, 50, 100]"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
variant="solo"
|
||||||
|
flat
|
||||||
|
class="pagination-select"
|
||||||
|
:menu-props="{ attach: true }"
|
||||||
|
></v-select>
|
||||||
|
<span class="text-caption ml-2">entries</span>
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
v-model="search"
|
||||||
|
label="Search"
|
||||||
|
append-inner-icon="mdi-magnify"
|
||||||
|
single-line
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
variant="solo"
|
||||||
|
flat
|
||||||
|
class="search-field"
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-data-table
|
||||||
|
:headers="headers"
|
||||||
|
:items="filteredItems"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
class="elevation-0"
|
||||||
|
item-key="noAntrean"
|
||||||
|
>
|
||||||
|
<template v-slot:item.aksi="{ item }">
|
||||||
|
<v-btn small color="success" class="text-white" @click="selectItem(item)">
|
||||||
|
Selesai
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<template v-slot:bottom>
|
||||||
|
<div class="d-flex justify-space-between align-center pa-2">
|
||||||
|
<span class="text-caption">Showing {{ (page - 1) * itemsPerPage + 1 }} to {{ Math.min(page * itemsPerPage, filteredItems.length) }} of {{ filteredItems.length }} entries</span>
|
||||||
|
<v-pagination
|
||||||
|
v-model="page"
|
||||||
|
:length="pageCount"
|
||||||
|
:total-visible="5"
|
||||||
|
rounded="circle"
|
||||||
|
></v-pagination>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-row class="mt-6">
|
||||||
|
<v-col cols="12" md="4" class="pr-md-2">
|
||||||
|
<v-card class="elevation-2 rounded-xl next-card pa-4 d-flex flex-column align-center text-center">
|
||||||
|
<h2 class="text-h3 font-weight-bold next-title">NEXT</h2>
|
||||||
|
<span class="text-h4 font-weight-medium my-4 next-content">{{ nextAntrean || 'Kosong' }}</span>
|
||||||
|
<p class="text-caption">Klik untuk memanggil pasien selanjutnya</p>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware:['auth']
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ title: 'No', align: 'start', sortable: false, key: 'no' },
|
||||||
|
{ title: 'No. Antrean', key: 'noAntrean' },
|
||||||
|
{ title: 'Daftar', key: 'daftar' },
|
||||||
|
{ title: 'Pelayanan', key: 'pelayanan' },
|
||||||
|
{ title: 'Aksi', key: 'aksi' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const items = ref([
|
||||||
|
{ no: 1, noAntrean: '001', daftar: '26 Aug 2025 07:10:31', pelayanan: 'Belum Dilayani' },
|
||||||
|
{ no: 2, noAntrean: '002', daftar: '26 Aug 2025 07:10:35', pelayanan: 'Belum Dilayani' },
|
||||||
|
{ no: 3, noAntrean: '003', daftar: '26 Aug 2025 07:10:44', pelayanan: 'Belum Dilayani' },
|
||||||
|
{ no: 4, noAntrean: '004', daftar: '26 Aug 2025 07:10:46', pelayanan: 'Belum Dilayani' },
|
||||||
|
{ no: 5, noAntrean: '005', daftar: '26 Aug 2025 07:10:47', pelayanan: 'Belum Dilayani' },
|
||||||
|
{ no: 6, noAntrean: '006', daftar: '26 Aug 2025 07:10:49', pelayanan: 'Belum Dilayani' },
|
||||||
|
{ no: 7, noAntrean: '007', daftar: '26 Aug 2025 07:10:51', pelayanan: 'Belum Dilayani' },
|
||||||
|
{ no: 8, noAntrean: '008', daftar: '26 Aug 2025 07:10:53', pelayanan: 'Belum Dilayani' },
|
||||||
|
{ no: 9, noAntrean: '009', daftar: '26 Aug 2025 07:10:54', pelayanan: 'Belum Dilayani' },
|
||||||
|
{ no: 10, noAntrean: '010', daftar: '26 Aug 2025 07:10:55', pelayanan: 'Belum Dilayani' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const search = ref('');
|
||||||
|
const itemsPerPage = ref(10);
|
||||||
|
const page = ref(1);
|
||||||
|
|
||||||
|
const nextAntrean = ref('001');
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
if (!search.value) {
|
||||||
|
return items.value;
|
||||||
|
}
|
||||||
|
return items.value.filter(item =>
|
||||||
|
Object.values(item).some(val =>
|
||||||
|
String(val).toLowerCase().includes(search.value.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageCount = computed(() => {
|
||||||
|
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectItem = (item) => {
|
||||||
|
console.log('Item selected:', item);
|
||||||
|
// Di sini Anda bisa menambahkan logika untuk mengubah status pasien menjadi "Dilayani" atau memindahkannya ke antrean berikutnya.
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ranap-admin-page {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-banner {
|
||||||
|
background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
|
||||||
|
color: white;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field, .pagination-select {
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-data-table :deep(table) {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-data-table :deep(tbody tr) {
|
||||||
|
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-card {
|
||||||
|
background-color: #00A896;
|
||||||
|
color: white;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-title {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-content {
|
||||||
|
font-size: 3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
571
pages/Setting/HakAkses.vue
Normal file
571
pages/Setting/HakAkses.vue
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hak-akses-page pa-6">
|
||||||
|
<v-breadcrumbs :items="breadcrumbs" class="pl-0 mb-4">
|
||||||
|
<template v-slot:divider>
|
||||||
|
<v-icon icon="mdi-chevron-right"></v-icon>
|
||||||
|
</template>
|
||||||
|
</v-breadcrumbs>
|
||||||
|
|
||||||
|
<v-card v-if="viewMode === 'add' || viewMode === 'editName'" class="pa-6 rounded-xl elevation-4">
|
||||||
|
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
|
||||||
|
<v-icon :icon="viewMode === 'add' ? 'mdi-plus' : 'mdi-pencil'" class="mr-2 text-primary" size="28"></v-icon>
|
||||||
|
<span>{{ formTitle }}</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider class="mb-4"></v-divider>
|
||||||
|
<v-card-text class="px-0">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.namaTipeUser"
|
||||||
|
placeholder="Masukkan Nama Tipe User"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mt-1"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-end pa-0 mt-4">
|
||||||
|
<v-btn
|
||||||
|
color="grey-darken-1"
|
||||||
|
variant="flat"
|
||||||
|
rounded="lg"
|
||||||
|
class="text-capitalize mr-2"
|
||||||
|
@click="cancelForm"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
rounded="lg"
|
||||||
|
class="text-capitalize"
|
||||||
|
@click="saveItem"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<EditHakAkses
|
||||||
|
v-else-if="viewMode === 'editAccess'"
|
||||||
|
:item="editedItem"
|
||||||
|
@save="updateItemAccess"
|
||||||
|
@cancel="cancelForm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-card v-else-if="viewMode === 'view'" class="pa-6 rounded-xl elevation-4">
|
||||||
|
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
|
||||||
|
<v-icon icon="mdi-eye-outline" class="mr-2 text-info" size="28"></v-icon>
|
||||||
|
<span>{{ formTitle }}</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-divider class="mb-4"></v-divider>
|
||||||
|
<v-card-text class="px-0">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.namaTipeUser"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mt-1"
|
||||||
|
readonly
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
|
||||||
|
<v-table density="comfortable" class="elevation-1 rounded-xl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">No</th>
|
||||||
|
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
|
||||||
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Akses</th>
|
||||||
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Lihat</th>
|
||||||
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Tambah</th>
|
||||||
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Edit</th>
|
||||||
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Hapus</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(menu, index) in editedItem.hakAksesMenu" :key="index">
|
||||||
|
<td>{{ index + 1 }}</td>
|
||||||
|
<td>{{ menu.name }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-icon :color="menu.canAccess ? 'green' : 'grey-lighten-2'">{{ menu.canAccess ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-icon :color="menu.canView ? 'green' : 'grey-lighten-2'">{{ menu.canView ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-icon :color="menu.canAdd ? 'green' : 'grey-lighten-2'">{{ menu.canAdd ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-icon :color="menu.canEdit ? 'green' : 'grey-lighten-2'">{{ menu.canEdit ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<v-icon :color="menu.canDelete ? 'green' : 'grey-lighten-2'">{{ menu.canDelete ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-end pa-0 mt-4">
|
||||||
|
<v-btn
|
||||||
|
color="grey-darken-1"
|
||||||
|
variant="flat"
|
||||||
|
rounded="lg"
|
||||||
|
class="text-capitalize"
|
||||||
|
@click="cancelForm"
|
||||||
|
>
|
||||||
|
Tutup
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<v-card class="d-flex flex-column flex-sm-row justify-space-between align-center pa-6 mb-6 rounded-xl bg-blue-lighten-5 elevation-2">
|
||||||
|
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0 text-blue-darken-3">
|
||||||
|
<v-icon icon="mdi-shield-lock-outline" class="mr-2" size="40"></v-icon>
|
||||||
|
<span>Hak Akses</span>
|
||||||
|
</v-card-title>
|
||||||
|
<div class="d-flex mt-4 mt-sm-0">
|
||||||
|
<v-btn
|
||||||
|
color="success"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
rounded="lg"
|
||||||
|
class="text-capitalize mr-2"
|
||||||
|
variant="flat"
|
||||||
|
@click="showAddForm"
|
||||||
|
>
|
||||||
|
Tambah Baru
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="info"
|
||||||
|
prepend-icon="mdi-format-list-numbered"
|
||||||
|
rounded="lg"
|
||||||
|
class="text-capitalize"
|
||||||
|
variant="flat"
|
||||||
|
@click="toggleReorderMode"
|
||||||
|
>
|
||||||
|
{{ reorderMode ? 'Selesai' : 'Atur Urutan' }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="pa-6 rounded-xl elevation-2">
|
||||||
|
<v-card-text class="pa-0">
|
||||||
|
<div class="d-flex flex-column flex-sm-row align-center justify-space-between mb-4">
|
||||||
|
<div class="d-flex align-center mb-4 mb-sm-0">
|
||||||
|
<span class="mr-2 text-subtitle-1 text-medium-emphasis">Show</span>
|
||||||
|
<v-select
|
||||||
|
v-model="itemsPerPage"
|
||||||
|
:items="[10, 25, 50]"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width: 80px;"
|
||||||
|
rounded="lg"
|
||||||
|
class="mr-2"
|
||||||
|
></v-select>
|
||||||
|
<span class="text-subtitle-1 text-medium-emphasis">entries</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-text-field
|
||||||
|
v-model="search"
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
label="Search"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
rounded="lg"
|
||||||
|
clearable
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-data-table
|
||||||
|
:headers="headers"
|
||||||
|
:items="allHakAksesData"
|
||||||
|
:search="search"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
v-model:page="page"
|
||||||
|
class="elevation-0 custom-table"
|
||||||
|
>
|
||||||
|
<template v-slot:[`item.actions`]="{ item }">
|
||||||
|
<div class="d-flex justify-center">
|
||||||
|
<v-btn icon size="small" variant="text" class="mr-1" @click="viewItem(item)">
|
||||||
|
<v-icon color="blue-darken-1">mdi-eye-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon size="small" variant="text" class="mr-1" @click="editItem(item)">
|
||||||
|
<v-icon color="orange-darken-1">mdi-pencil-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon size="small" variant="text" class="mr-1" @click="deleteItem(item)">
|
||||||
|
<v-icon color="red-darken-1">mdi-delete-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon size="small" variant="text" @click="editAccess(item)">
|
||||||
|
<v-icon color="green-darken-1">mdi-lock-check-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:no-data>
|
||||||
|
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
|
||||||
|
Tidak ada data yang tersedia.
|
||||||
|
</v-alert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:item.id="{ item }">
|
||||||
|
<div class="text-center">{{ item.id }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:bottom>
|
||||||
|
<v-row class="ma-2 pa-2">
|
||||||
|
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey-darken-1">
|
||||||
|
{{ showingEntriesText }}
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||||
|
<v-pagination
|
||||||
|
v-model="page"
|
||||||
|
:length="pageCount"
|
||||||
|
rounded="circle"
|
||||||
|
:total-visible="5"
|
||||||
|
></v-pagination>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-dialog v-model="showDeleteDialog" max-width="400px">
|
||||||
|
<v-card class="pa-6 rounded-xl elevation-4">
|
||||||
|
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
|
||||||
|
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-end">
|
||||||
|
<v-btn color="grey-darken-1" variant="text" rounded="lg" @click="closeDeleteDialog">Batal</v-btn>
|
||||||
|
<v-btn color="red-darken-1" variant="text" rounded="lg" @click="confirmDelete">Hapus</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog v-model="reorderMode" max-width="600px">
|
||||||
|
<v-card class="pa-6 rounded-xl elevation-4">
|
||||||
|
<v-card-title class="text-h6 font-weight-bold">Atur Urutan Menu</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<VueDraggableNext
|
||||||
|
v-model="draggableMenus"
|
||||||
|
item-key="name"
|
||||||
|
tag="v-list"
|
||||||
|
class="pa-0"
|
||||||
|
handle=".handle"
|
||||||
|
:animation="200"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<v-list-item class="rounded-lg elevation-1 my-2" :title="element.name">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon icon="mdi-drag-vertical" class="handle"></v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</VueDraggableNext>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-end">
|
||||||
|
<v-btn color="grey-darken-1" variant="text" rounded="lg" @click="cancelReorder">Batal</v-btn>
|
||||||
|
<v-btn color="primary" variant="text" rounded="lg" @click="saveReorder">Simpan Urutan</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { useLocalStorage, useSessionStorage } from '@vueuse/core';
|
||||||
|
import { VueDraggableNext } from 'vue-draggable-next';
|
||||||
|
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
|
||||||
|
import { useNavItemsStore } from '@/stores/navItems';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['auth']
|
||||||
|
})
|
||||||
|
|
||||||
|
interface HakAksesMenu {
|
||||||
|
name: string;
|
||||||
|
canAccess: boolean;
|
||||||
|
canView: boolean;
|
||||||
|
canAdd: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon: string;
|
||||||
|
children?: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HakAksesData {
|
||||||
|
id: number;
|
||||||
|
namaTipeUser: string;
|
||||||
|
hakAksesMenu: HakAksesMenu[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendPermissionItem {
|
||||||
|
id: number;
|
||||||
|
create: boolean;
|
||||||
|
read: boolean;
|
||||||
|
update: boolean;
|
||||||
|
disable: boolean;
|
||||||
|
delete: boolean;
|
||||||
|
active: boolean;
|
||||||
|
page_name: string;
|
||||||
|
pageID: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionData {
|
||||||
|
roles: string[];
|
||||||
|
groups: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// State for views
|
||||||
|
const viewMode = ref<'table' | 'add' | 'editName' | 'editAccess' | 'view'>('table');
|
||||||
|
const reorderMode = ref(false);
|
||||||
|
|
||||||
|
const allHakAksesData = useLocalStorage<HakAksesData[]>('allHakAksesData', []);
|
||||||
|
const navItemsStore = useNavItemsStore();
|
||||||
|
|
||||||
|
const draggableMenus = ref<NavItem[]>([]);
|
||||||
|
|
||||||
|
// Data table headers
|
||||||
|
const headers = ref([
|
||||||
|
{ title: 'No', key: 'id' as const },
|
||||||
|
{ title: 'Nama Tipe User', key: 'namaTipeUser' as const, sortable: true },
|
||||||
|
{ title: 'Aksi', align: 'center' as const, key: 'actions' as const, sortable: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Breadcrumbs
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
const baseCrumbs = [
|
||||||
|
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||||
|
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||||
|
{ title: 'Hak Akses', disabled: false, href: '/setting/hakakses' },
|
||||||
|
];
|
||||||
|
if (viewMode.value === 'add') {
|
||||||
|
return [...baseCrumbs, { title: 'Tambah Hak Akses', disabled: true, href: '/setting/tambahhakakses' }];
|
||||||
|
} else if (viewMode.value === 'editName') {
|
||||||
|
return [...baseCrumbs, { title: 'Edit Nama Tipe User', disabled: true, href: '/setting/editnamahakakses' }];
|
||||||
|
} else if (viewMode.value === 'editAccess') {
|
||||||
|
return [...baseCrumbs, { title: 'Edit Hak Akses', disabled: true, href: '/setting/edithakakses' }];
|
||||||
|
} else if (viewMode.value === 'view') {
|
||||||
|
return [...baseCrumbs, { title: 'Detail Hak Akses', disabled: true, href: '/setting/viewhakakses' }];
|
||||||
|
}
|
||||||
|
return baseCrumbs;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table and pagination state
|
||||||
|
const itemsPerPage = ref(10);
|
||||||
|
const search = ref('');
|
||||||
|
const page = ref(1);
|
||||||
|
|
||||||
|
const editedIndex = ref(-1);
|
||||||
|
const editedItem = ref<HakAksesData>({
|
||||||
|
id: 0,
|
||||||
|
namaTipeUser: '',
|
||||||
|
hakAksesMenu: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Menu management logic ---
|
||||||
|
// This is your list of all possible menus with a path and icon
|
||||||
|
const defaultMenuItems = () => ([
|
||||||
|
{ name: 'Dashboard', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/dashboard', icon: 'mdi-view-dashboard' },
|
||||||
|
{ name: 'Setting', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/setting', icon: 'mdi-cog' },
|
||||||
|
{ name: 'Setting / Hak Akses', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/setting/hakakses', icon: 'mdi-shield-lock-outline' },
|
||||||
|
// ... and so on for all your pages
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const pageCount = computed(() => {
|
||||||
|
return Math.ceil(allHakAksesData.value.length / itemsPerPage.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showingEntriesText = computed(() => {
|
||||||
|
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||||
|
const end = Math.min(page.value * itemsPerPage.value, allHakAksesData.value.length);
|
||||||
|
const total = allHakAksesData.value.length;
|
||||||
|
return `Showing ${start} to ${end} of ${total} entries`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formTitle = computed(() => {
|
||||||
|
if (viewMode.value === 'editName') return 'Edit Nama Tipe User';
|
||||||
|
if (viewMode.value === 'editAccess') return 'Edit Hak Akses';
|
||||||
|
if (viewMode.value === 'view') return 'Detail Hak Akses';
|
||||||
|
return 'Tambah Hak Akses';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete dialog state
|
||||||
|
const showDeleteDialog = ref(false);
|
||||||
|
|
||||||
|
// Functions to reorder data and sync
|
||||||
|
const toggleReorderMode = () => {
|
||||||
|
reorderMode.value = !reorderMode.value;
|
||||||
|
if (reorderMode.value) {
|
||||||
|
draggableMenus.value = navItemsStore.navItems.map(item => ({ ...item }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveReorder = () => {
|
||||||
|
navItemsStore.updateNavItems(draggableMenus.value);
|
||||||
|
reorderMode.value = false;
|
||||||
|
reindexData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelReorder = () => {
|
||||||
|
reorderMode.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-indexes IDs to be sequential
|
||||||
|
const reindexData = () => {
|
||||||
|
allHakAksesData.value.forEach((item, index) => {
|
||||||
|
item.id = index + 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Functions for actions
|
||||||
|
const showAddForm = () => {
|
||||||
|
resetForm();
|
||||||
|
viewMode.value = 'add';
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewItem = (item: HakAksesData) => {
|
||||||
|
editedItem.value = { ...item };
|
||||||
|
viewMode.value = 'view';
|
||||||
|
};
|
||||||
|
|
||||||
|
const editItem = (item: HakAksesData) => {
|
||||||
|
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
|
||||||
|
editedItem.value = { ...item };
|
||||||
|
viewMode.value = 'editName';
|
||||||
|
};
|
||||||
|
|
||||||
|
const editAccess = (item: HakAksesData) => {
|
||||||
|
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
|
||||||
|
const baseMenuItems = defaultMenuItems();
|
||||||
|
const existingAccess = item.hakAksesMenu;
|
||||||
|
|
||||||
|
const mergedMenuItems = baseMenuItems.map(defaultMenu => {
|
||||||
|
const existingMenu = existingAccess.find(exist => exist.name === defaultMenu.name);
|
||||||
|
return existingMenu ? { ...defaultMenu, ...existingMenu } : defaultMenu;
|
||||||
|
});
|
||||||
|
|
||||||
|
editedItem.value = {
|
||||||
|
...item,
|
||||||
|
hakAksesMenu: mergedMenuItems
|
||||||
|
};
|
||||||
|
viewMode.value = 'editAccess';
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = (item: HakAksesData) => {
|
||||||
|
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
|
||||||
|
showDeleteDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeleteDialog = () => {
|
||||||
|
showDeleteDialog.value = false;
|
||||||
|
editedIndex.value = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (editedIndex.value > -1) {
|
||||||
|
allHakAksesData.value.splice(editedIndex.value, 1);
|
||||||
|
reindexData();
|
||||||
|
}
|
||||||
|
closeDeleteDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelForm = () => {
|
||||||
|
viewMode.value = 'table';
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItemAccess = (updatedItem: HakAksesData) => {
|
||||||
|
if (editedIndex.value > -1) {
|
||||||
|
Object.assign(allHakAksesData.value[editedIndex.value], updatedItem);
|
||||||
|
}
|
||||||
|
cancelForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
editedItem.value = {
|
||||||
|
id: 0,
|
||||||
|
namaTipeUser: '',
|
||||||
|
hakAksesMenu: navItemsStore.navItems.map(navItem => ({
|
||||||
|
name: navItem.name,
|
||||||
|
canAccess: false,
|
||||||
|
canView: false,
|
||||||
|
canAdd: false,
|
||||||
|
canEdit: false,
|
||||||
|
canDelete: false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveItem = () => {
|
||||||
|
if (editedIndex.value > -1) {
|
||||||
|
// Edit item
|
||||||
|
Object.assign(allHakAksesData.value[editedIndex.value], editedItem.value);
|
||||||
|
} else {
|
||||||
|
// Add item with a new ID
|
||||||
|
editedItem.value.id = allHakAksesData.value.length + 1;
|
||||||
|
allHakAksesData.value.push(editedItem.value);
|
||||||
|
reindexData(); // Re-index to ensure sequential IDs
|
||||||
|
}
|
||||||
|
cancelForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle initial data load and ID re-indexing
|
||||||
|
onMounted(() => {
|
||||||
|
reindexData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hak-akses-page {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-data-table :deep(th) {
|
||||||
|
font-weight: bold !important;
|
||||||
|
color: #333 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-data-table :deep(td) {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--icon {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select :deep(.v-field__input) {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
491
pages/Setting/MasterKlinik.vue
Normal file
491
pages/Setting/MasterKlinik.vue
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
<template>
|
||||||
|
<div class="master-klinik-page pa-4">
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
|
||||||
|
<template v-slot:divider>
|
||||||
|
<v-icon icon="mdi-chevron-right"></v-icon>
|
||||||
|
</template>
|
||||||
|
</v-breadcrumbs>
|
||||||
|
|
||||||
|
<!-- Tampilan Formulir Tambah/Edit/View -->
|
||||||
|
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
|
||||||
|
<v-card-title class="text-h5 font-weight-bold mb-4">
|
||||||
|
{{ isEditMode ? 'Edit Klinik' : readOnly ? 'Detail Klinik' : 'Tambah Klinik' }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Kode</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.kode"
|
||||||
|
placeholder="Masukkan Kode Klinik"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Nama</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.namaKlinik"
|
||||||
|
placeholder="Masukkan Nama Klinik"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Shift</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.shift"
|
||||||
|
placeholder="Jumlah Shift"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Kuota Shift</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.kuotaShift"
|
||||||
|
placeholder="Kuota Per Shift"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row class="d-flex align-center">
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Jam Buka Shift</v-label>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.jamBuka"
|
||||||
|
placeholder="Jam Buka Shift 1"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mr-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
<span class="text-h6 font-weight-bold mr-2">:</span>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.menitBuka"
|
||||||
|
placeholder="Menit Buka Shift 1"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-switch
|
||||||
|
v-model="editedItem.autoShift"
|
||||||
|
inset
|
||||||
|
color="primary"
|
||||||
|
label="Auto Shift"
|
||||||
|
hide-details
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-switch>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-label class="font-weight-bold">Kuota Bangku</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.kuotaBangku"
|
||||||
|
placeholder="Masukkan Kuota Bangku Klinik"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-label class="font-weight-bold">Jadwal Klinik</v-label>
|
||||||
|
<v-table class="rounded-lg elevation-1 mt-2">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left text-uppercase font-weight-bold">#</th>
|
||||||
|
<th class="text-left text-uppercase font-weight-bold">Hari</th>
|
||||||
|
<th class="text-left text-uppercase font-weight-bold">Pilih</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(day, index) in days" :key="index">
|
||||||
|
<td>{{ index + 1 }}</td>
|
||||||
|
<td>{{ day.name }}</td>
|
||||||
|
<td>
|
||||||
|
<v-checkbox
|
||||||
|
v-model="day.selected"
|
||||||
|
color="primary"
|
||||||
|
hide-details
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-checkbox>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-start pa-4">
|
||||||
|
<v-btn
|
||||||
|
color="grey-darken-1"
|
||||||
|
variant="flat"
|
||||||
|
class="text-capitalize rounded-lg mr-2"
|
||||||
|
@click="cancelForm"
|
||||||
|
>
|
||||||
|
{{ readOnly ? 'Tutup' : 'Batal' }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="!readOnly"
|
||||||
|
color="orange-darken-2"
|
||||||
|
variant="flat"
|
||||||
|
class="text-capitalize rounded-lg"
|
||||||
|
@click="saveItem"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Tampilan Tabel Data -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- Banner biru sebagai pengganti header h1 -->
|
||||||
|
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
|
||||||
|
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
|
||||||
|
<v-icon icon="mdi-hospital" class="mr-2" size="40"></v-icon>
|
||||||
|
<span>Master Klinik</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-btn
|
||||||
|
color="success"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
rounded
|
||||||
|
class="text-capitalize"
|
||||||
|
@click="showForm = true"
|
||||||
|
>
|
||||||
|
Tambah Baru
|
||||||
|
</v-btn>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="pa-4 rounded-lg elevation-2">
|
||||||
|
<v-card-text>
|
||||||
|
<!-- Table controls -->
|
||||||
|
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<span class="mr-2 text-subtitle-1">Show</span>
|
||||||
|
<v-select
|
||||||
|
v-model="itemsPerPage"
|
||||||
|
:items="[10, 25, 50]"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width: 80px;"
|
||||||
|
rounded
|
||||||
|
class="mr-2"
|
||||||
|
></v-select>
|
||||||
|
<span class="text-subtitle-1">entries</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-text-field
|
||||||
|
v-model="search"
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
label="Search"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
rounded
|
||||||
|
clearable
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Table dengan paginasi kustom -->
|
||||||
|
<v-data-table
|
||||||
|
:headers="headers"
|
||||||
|
:items="allKlinikData"
|
||||||
|
:search="search"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
v-model:page="page"
|
||||||
|
class="rounded-lg elevation-0 custom-table"
|
||||||
|
>
|
||||||
|
<!-- Slot untuk aksi di setiap baris -->
|
||||||
|
<template v-slot:[`item.actions`]="{ item }">
|
||||||
|
<!-- Tombol "View" yang hanya menampilkan detail -->
|
||||||
|
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
|
||||||
|
<v-icon>mdi-eye</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
|
||||||
|
<v-icon>mdi-pencil</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon color="red" size="small" @click="deleteItem(item)">
|
||||||
|
<v-icon>mdi-delete</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:no-data>
|
||||||
|
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
|
||||||
|
Tidak ada data yang tersedia.
|
||||||
|
</v-alert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Slot kustom untuk footer tabel (paginasi) -->
|
||||||
|
<template v-slot:bottom>
|
||||||
|
<v-row class="ma-2">
|
||||||
|
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
|
||||||
|
{{ showingEntriesText }}
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||||
|
<v-pagination
|
||||||
|
v-model="page"
|
||||||
|
:length="pageCount"
|
||||||
|
rounded="circle"
|
||||||
|
:total-visible="5"
|
||||||
|
></v-pagination>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Dialog -->
|
||||||
|
<v-dialog v-model="showDeleteDialog" max-width="400px">
|
||||||
|
<v-card class="pa-4 rounded-lg">
|
||||||
|
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
|
||||||
|
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-end">
|
||||||
|
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
|
||||||
|
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware:['auth']
|
||||||
|
})
|
||||||
|
|
||||||
|
// State untuk menampilkan/menyembunyikan formulir
|
||||||
|
const showForm = ref(false);
|
||||||
|
const readOnly = ref(false); // State untuk mode "view"
|
||||||
|
|
||||||
|
// Data dummy untuk Master Klinik
|
||||||
|
const allKlinikData = ref([
|
||||||
|
{ id: 1, kode: 'AN', namaKlinik: 'ANAK', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 2, kode: 'AS', namaKlinik: 'ANESTESI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 3, kode: 'BD', namaKlinik: 'BEDAH', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 4, kode: 'GR', namaKlinik: 'GERIATRI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 5, kode: 'GI', namaKlinik: 'GIGI DAN MULUT', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 6, kode: 'GZ', namaKlinik: 'GIZI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 7, kode: 'HO', namaKlinik: 'HOM', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 8, kode: 'IP', namaKlinik: 'IPD', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 9, kode: 'JT', namaKlinik: 'JANTUNG', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 10, kode: 'JW', namaKlinik: 'JIWA', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 11, kode: 'OB', namaKlinik: 'KANDUNGAN', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 12, kode: 'KT', namaKlinik: 'KEMOTERAPI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 13, kode: 'KN', namaKlinik: 'KOMPLEMENTER', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 14, kode: 'KL', namaKlinik: 'KUL KEL', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 15, kode: 'MT', namaKlinik: 'MATA', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 16, kode: 'MC', namaKlinik: 'MCU', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 17, kode: 'ON', namaKlinik: 'ONKOLOGI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 18, kode: 'PR', namaKlinik: 'PARU', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 19, kode: 'RT', namaKlinik: 'R. TINDAKAN', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 20, kode: 'RD', namaKlinik: 'RADIOTERAPI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 21, kode: 'RM', namaKlinik: 'REHAB MEDIK', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 22, kode: 'NU', namaKlinik: 'SARAF', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
{ id: 23, kode: 'TH', namaKlinik: 'THT', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const headers = ref([
|
||||||
|
{ title: 'No', key: 'id' },
|
||||||
|
{ title: 'Kode', key: 'kode', sortable: true },
|
||||||
|
{ title: 'Nama Klinik', key: 'namaKlinik', sortable: true },
|
||||||
|
{ title: 'Shift', key: 'shift', sortable: true },
|
||||||
|
{ title: 'Kuota Shift', key: 'kuotaShift', sortable: true },
|
||||||
|
{ title: 'Aksi', key: 'actions', sortable: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Breadcrumbs
|
||||||
|
const breadcrumbs = ref([
|
||||||
|
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||||
|
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||||
|
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const days = ref([
|
||||||
|
{ name: 'Senin', selected: false },
|
||||||
|
{ name: 'Selasa', selected: false },
|
||||||
|
{ name: 'Rabu', selected: false },
|
||||||
|
{ name: 'Kamis', selected: false },
|
||||||
|
{ name: 'Jum`at', selected: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// State untuk tabel dan paginasi
|
||||||
|
const itemsPerPage = ref(10);
|
||||||
|
const search = ref('');
|
||||||
|
const page = ref(1);
|
||||||
|
|
||||||
|
// Computed properties untuk paginasi kustom
|
||||||
|
const pageCount = computed(() => {
|
||||||
|
return Math.ceil(allKlinikData.value.length / itemsPerPage.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showingEntriesText = computed(() => {
|
||||||
|
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||||
|
const end = Math.min(page.value * itemsPerPage.value, allKlinikData.value.length);
|
||||||
|
const total = allKlinikData.value.length;
|
||||||
|
return `Showing ${start} to ${end} of ${total} entries`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// State untuk dialog
|
||||||
|
const showDeleteDialog = ref(false);
|
||||||
|
const isEditMode = ref(false);
|
||||||
|
const editedIndex = ref(-1);
|
||||||
|
const editedItem = ref({
|
||||||
|
id: 0,
|
||||||
|
kode: '',
|
||||||
|
namaKlinik: '',
|
||||||
|
shift: 1,
|
||||||
|
kuotaShift: 0,
|
||||||
|
kuotaBangku: 0,
|
||||||
|
jamBuka: '',
|
||||||
|
menitBuka: '',
|
||||||
|
autoShift: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Fungsi untuk tombol aksi
|
||||||
|
// Fungsi untuk tombol View yang hanya menampilkan data tanpa bisa diedit
|
||||||
|
const viewItem = (item) => {
|
||||||
|
editedItem.value = { ...item };
|
||||||
|
readOnly.value = true;
|
||||||
|
isEditMode.value = false;
|
||||||
|
showForm.value = true;
|
||||||
|
// Update breadcrumbs untuk mode view
|
||||||
|
breadcrumbs.value = [
|
||||||
|
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||||
|
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||||
|
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
|
||||||
|
{ title: 'Detail Klinik', disabled: true, href: '/setting/viewklinik' },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const editItem = (item) => {
|
||||||
|
editedIndex.value = allKlinikData.value.findIndex(d => d.id === item.id);
|
||||||
|
editedItem.value = { ...item };
|
||||||
|
isEditMode.value = true;
|
||||||
|
readOnly.value = false;
|
||||||
|
showForm.value = true;
|
||||||
|
// Update breadcrumbs untuk mode edit
|
||||||
|
breadcrumbs.value = [
|
||||||
|
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||||
|
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||||
|
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
|
||||||
|
{ title: 'Edit Klinik', disabled: true, href: '/setting/editklinik' },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = (item) => {
|
||||||
|
editedIndex.value = allKlinikData.value.findIndex(d => d.id === item.id);
|
||||||
|
showDeleteDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (editedIndex.value > -1) {
|
||||||
|
allKlinikData.value.splice(editedIndex.value, 1);
|
||||||
|
}
|
||||||
|
closeDeleteDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelForm = () => {
|
||||||
|
showForm.value = false;
|
||||||
|
isEditMode.value = false;
|
||||||
|
readOnly.value = false;
|
||||||
|
editedItem.value = {
|
||||||
|
id: 0,
|
||||||
|
kode: '',
|
||||||
|
namaKlinik: '',
|
||||||
|
shift: 1,
|
||||||
|
kuotaShift: 0,
|
||||||
|
kuotaBangku: 0,
|
||||||
|
jamBuka: '',
|
||||||
|
menitBuka: '',
|
||||||
|
autoShift: false,
|
||||||
|
};
|
||||||
|
editedIndex.value = -1;
|
||||||
|
// Reset breadcrumbs ke mode tabel
|
||||||
|
breadcrumbs.value = [
|
||||||
|
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||||
|
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||||
|
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeleteDialog = () => {
|
||||||
|
showDeleteDialog.value = false;
|
||||||
|
editedIndex.value = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveItem = () => {
|
||||||
|
if (isEditMode.value) {
|
||||||
|
Object.assign(allKlinikData.value[editedIndex.value], editedItem.value);
|
||||||
|
} else {
|
||||||
|
// Generate new ID
|
||||||
|
const newId = allKlinikData.value.length > 0 ? Math.max(...allKlinikData.value.map(item => item.id)) + 1 : 1;
|
||||||
|
editedItem.value.id = newId;
|
||||||
|
allKlinikData.value.push(editedItem.value);
|
||||||
|
}
|
||||||
|
cancelForm(); // Kembali ke tampilan tabel setelah menyimpan
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.master-klinik-page {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-data-table :deep(th) {
|
||||||
|
font-weight: bold !important;
|
||||||
|
color: #333 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-data-table :deep(td) {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--icon {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select :deep(.v-field__input) {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
376
pages/Setting/MasterKlinikRuang.vue
Normal file
376
pages/Setting/MasterKlinikRuang.vue
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
<template>
|
||||||
|
<div class="master-klinik-ruang-page pa-4">
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
|
||||||
|
<template v-slot:divider>
|
||||||
|
<v-icon icon="mdi-chevron-right"></v-icon>
|
||||||
|
</template>
|
||||||
|
</v-breadcrumbs>
|
||||||
|
|
||||||
|
<!-- Tampilan Formulir Tambah/Edit/View -->
|
||||||
|
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
|
||||||
|
<v-card-title class="text-h5 font-weight-bold mb-4">
|
||||||
|
{{ isEditMode ? 'Edit Ruang Klinik' : readOnly ? 'Detail Ruang Klinik' : 'Tambah Ruang Klinik' }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Nama Klinik</v-label>
|
||||||
|
<v-select
|
||||||
|
v-model="editedItem.namaKlinik"
|
||||||
|
:items="['ANAK', 'ANESTESI', 'BEDAH', 'GERIATRI', 'GIGI DAN MULUT', 'GIZI', 'HOM', 'IPD', 'JANTUNG', 'JIWA', 'KANDUNGAN', 'KEMOTERAPI', 'KOMPLEMENTER', 'KUL KEL', 'MATA', 'MCU', 'ONKOLOGI', 'PARU', 'R. TINDAKAN', 'RADIOTERAPI', 'REHAB MEDIK', 'SARAF', 'THT']"
|
||||||
|
placeholder="Pilih Nama Klinik"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Kode Ruang</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.kode"
|
||||||
|
placeholder="Masukkan Kode Ruang"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-label class="font-weight-bold">Nama Ruang</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.namaRuang"
|
||||||
|
placeholder="Masukkan Nama Ruang"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-start pa-4">
|
||||||
|
<v-btn
|
||||||
|
color="grey-darken-1"
|
||||||
|
variant="flat"
|
||||||
|
class="text-capitalize rounded-lg mr-2"
|
||||||
|
@click="cancelForm"
|
||||||
|
>
|
||||||
|
{{ readOnly ? 'Tutup' : 'Batal' }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="!readOnly"
|
||||||
|
color="orange-darken-2"
|
||||||
|
variant="flat"
|
||||||
|
class="text-capitalize rounded-lg"
|
||||||
|
@click="saveItem"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Tampilan Tabel Data -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- Banner biru sebagai pengganti header h1 -->
|
||||||
|
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
|
||||||
|
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
|
||||||
|
<v-icon icon="mdi-hospital-box-outline" class="mr-2" size="40"></v-icon>
|
||||||
|
<span>Master Klinik Ruang</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-btn
|
||||||
|
color="success"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
rounded
|
||||||
|
class="text-capitalize"
|
||||||
|
@click="showForm = true"
|
||||||
|
>
|
||||||
|
Tambah Baru
|
||||||
|
</v-btn>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="pa-4 rounded-lg elevation-2">
|
||||||
|
<v-card-text>
|
||||||
|
<!-- Table controls -->
|
||||||
|
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<span class="mr-2 text-subtitle-1">Show</span>
|
||||||
|
<v-select
|
||||||
|
v-model="itemsPerPage"
|
||||||
|
:items="[10, 25, 50]"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width: 80px;"
|
||||||
|
rounded
|
||||||
|
class="mr-2"
|
||||||
|
></v-select>
|
||||||
|
<span class="text-subtitle-1">entries</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-text-field
|
||||||
|
v-model="search"
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
label="Search"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
rounded
|
||||||
|
clearable
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Table dengan paginasi kustom -->
|
||||||
|
<v-data-table
|
||||||
|
:headers="headers"
|
||||||
|
:items="allRuangData"
|
||||||
|
:search="search"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
v-model:page="page"
|
||||||
|
class="rounded-lg elevation-0 custom-table"
|
||||||
|
>
|
||||||
|
<!-- Slot untuk aksi di setiap baris -->
|
||||||
|
<template v-slot:[`item.actions`]="{ item }">
|
||||||
|
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
|
||||||
|
<v-icon>mdi-eye</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
|
||||||
|
<v-icon>mdi-pencil</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon color="red" size="small" @click="deleteItem(item)">
|
||||||
|
<v-icon>mdi-delete</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:no-data>
|
||||||
|
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
|
||||||
|
Tidak ada data yang tersedia.
|
||||||
|
</v-alert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Slot kustom untuk footer tabel (paginasi) -->
|
||||||
|
<template v-slot:bottom>
|
||||||
|
<v-row class="ma-2">
|
||||||
|
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
|
||||||
|
{{ showingEntriesText }}
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||||
|
<v-pagination
|
||||||
|
v-model="page"
|
||||||
|
:length="pageCount"
|
||||||
|
rounded="circle"
|
||||||
|
:total-visible="5"
|
||||||
|
></v-pagination>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Dialog -->
|
||||||
|
<v-dialog v-model="showDeleteDialog" max-width="400px">
|
||||||
|
<v-card class="pa-4 rounded-lg">
|
||||||
|
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
|
||||||
|
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-end">
|
||||||
|
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
|
||||||
|
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware:['auth']
|
||||||
|
})
|
||||||
|
|
||||||
|
// State untuk menampilkan/menyembunyikan formulir
|
||||||
|
const showForm = ref(false);
|
||||||
|
const readOnly = ref(false);
|
||||||
|
|
||||||
|
// Data dummy untuk Master Klinik Ruang
|
||||||
|
const allRuangData = ref([
|
||||||
|
{ id: 1, namaKlinik: 'ANAK', kode: 'AN-01', namaRuang: 'RUANG ANAK 1' },
|
||||||
|
{ id: 2, namaKlinik: 'ANAK', kode: 'AN-02', namaRuang: 'RUANG ANAK 2' },
|
||||||
|
{ id: 3, namaKlinik: 'ANESTESI', kode: 'AS-01', namaRuang: 'RUANG ANESTESI 1' },
|
||||||
|
{ id: 4, namaKlinik: 'BEDAH', kode: 'BD-01', namaRuang: 'RUANG BEDAH 1' },
|
||||||
|
{ id: 5, namaKlinik: 'GIGI DAN MULUT', kode: 'GI-01', namaRuang: 'RUANG GIGI DAN MULUT 1' },
|
||||||
|
{ id: 6, namaKlinik: 'GERIATRI', kode: 'GR-01', namaRuang: 'RUANG GERIATRI 1' },
|
||||||
|
{ id: 7, namaKlinik: 'GIZI', kode: 'GZ-01', namaRuang: 'RUANG GIZI 1' },
|
||||||
|
{ id: 8, namaKlinik: 'HOM', kode: 'HO-01', namaRuang: 'RUANG HOM 1' },
|
||||||
|
{ id: 9, namaKlinik: 'IPD', kode: 'IP-01', namaRuang: 'RUANG IPD 1' },
|
||||||
|
{ id: 10, namaKlinik: 'JANTUNG', kode: 'JT-01', namaRuang: 'RUANG JANTUNG 1' },
|
||||||
|
{ id: 11, namaKlinik: 'JIWA', kode: 'JW-01', namaRuang: 'RUANG JIWA 1' },
|
||||||
|
{ id: 12, namaKlinik: 'KANDUNGAN', kode: 'OB-01', namaRuang: 'RUANG KANDUNGAN 1' },
|
||||||
|
{ id: 13, namaKlinik: 'KANDUNGAN', kode: 'OB-02', namaRuang: 'RUANG KANDUNGAN 2' },
|
||||||
|
{ id: 14, namaKlinik: 'KEMOTERAPI', kode: 'KT-01', namaRuang: 'RUANG KEMOTERAPI 1' },
|
||||||
|
{ id: 15, namaKlinik: 'KOMPLEMENTER', kode: 'KN-01', namaRuang: 'RUANG KOMPLEMENTER 1' },
|
||||||
|
{ id: 16, namaKlinik: 'KUL KEL', kode: 'KL-01', namaRuang: 'RUANG KUL KEL 1' },
|
||||||
|
{ id: 17, namaKlinik: 'MATA', kode: 'MT-01', namaRuang: 'RUANG MATA 1' },
|
||||||
|
{ id: 18, namaKlinik: 'MCU', kode: 'MC-01', namaRuang: 'RUANG MCU 1' },
|
||||||
|
{ id: 19, namaKlinik: 'ONKOLOGI', kode: 'ON-01', namaRuang: 'RUANG ONKOLOGI 1' },
|
||||||
|
{ id: 20, namaKlinik: 'PARU', kode: 'PR-01', namaRuang: 'RUANG PARU 1' },
|
||||||
|
{ id: 21, namaKlinik: 'R. TINDAKAN', kode: 'RT-01', namaRuang: 'RUANG R. TINDAKAN 1' },
|
||||||
|
{ id: 22, namaKlinik: 'RADIOTERAPI', kode: 'RD-01', namaRuang: 'RUANG RADIOTERAPI 1' },
|
||||||
|
{ id: 23, namaKlinik: 'REHAB MEDIK', kode: 'RM-01', namaRuang: 'RUANG REHAB MEDIK 1' },
|
||||||
|
{ id: 24, namaKlinik: 'SARAF', kode: 'NU-01', namaRuang: 'RUANG SARAF 1' },
|
||||||
|
{ id: 25, namaKlinik: 'THT', kode: 'TH-01', namaRuang: 'RUANG THT 1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const headers = ref([
|
||||||
|
{ title: 'No', key: 'id' },
|
||||||
|
{ title: 'Nama Klinik', key: 'namaKlinik', sortable: true },
|
||||||
|
{ title: 'Kode Ruang', key: 'kode', sortable: true },
|
||||||
|
{ title: 'Nama Ruang', key: 'namaRuang', sortable: true },
|
||||||
|
{ title: 'Aksi', key: 'actions', sortable: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Breadcrumbs
|
||||||
|
const breadcrumbs = ref([
|
||||||
|
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||||
|
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||||
|
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// State untuk tabel dan paginasi
|
||||||
|
const itemsPerPage = ref(10);
|
||||||
|
const search = ref('');
|
||||||
|
const page = ref(1);
|
||||||
|
|
||||||
|
// Computed properties untuk paginasi kustom
|
||||||
|
const pageCount = computed(() => {
|
||||||
|
return Math.ceil(allRuangData.value.length / itemsPerPage.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showingEntriesText = computed(() => {
|
||||||
|
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||||
|
const end = Math.min(page.value * itemsPerPage.value, allRuangData.value.length);
|
||||||
|
const total = allRuangData.value.length;
|
||||||
|
return `Showing ${start} to ${end} of ${total} entries`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// State untuk dialog
|
||||||
|
const showDeleteDialog = ref(false);
|
||||||
|
const isEditMode = ref(false);
|
||||||
|
const editedIndex = ref(-1);
|
||||||
|
const editedItem = ref({
|
||||||
|
id: 0,
|
||||||
|
namaKlinik: '',
|
||||||
|
kode: '',
|
||||||
|
namaRuang: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fungsi untuk tombol aksi
|
||||||
|
const viewItem = (item) => {
|
||||||
|
editedItem.value = { ...item };
|
||||||
|
readOnly.value = true;
|
||||||
|
isEditMode.value = false;
|
||||||
|
showForm.value = true;
|
||||||
|
breadcrumbs.value = [
|
||||||
|
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||||
|
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||||
|
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
|
||||||
|
{ title: 'Detail Ruang Klinik', disabled: true, href: '/setting/viewruang' },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const editItem = (item) => {
|
||||||
|
editedIndex.value = allRuangData.value.findIndex(d => d.id === item.id);
|
||||||
|
editedItem.value = { ...item };
|
||||||
|
isEditMode.value = true;
|
||||||
|
readOnly.value = false;
|
||||||
|
showForm.value = true;
|
||||||
|
breadcrumbs.value = [
|
||||||
|
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||||
|
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||||
|
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
|
||||||
|
{ title: 'Edit Ruang Klinik', disabled: true, href: '/setting/editruang' },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = (item) => {
|
||||||
|
editedIndex.value = allRuangData.value.findIndex(d => d.id === item.id);
|
||||||
|
showDeleteDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (editedIndex.value > -1) {
|
||||||
|
allRuangData.value.splice(editedIndex.value, 1);
|
||||||
|
}
|
||||||
|
closeDeleteDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelForm = () => {
|
||||||
|
showForm.value = false;
|
||||||
|
isEditMode.value = false;
|
||||||
|
readOnly.value = false;
|
||||||
|
editedItem.value = {
|
||||||
|
id: 0,
|
||||||
|
namaKlinik: '',
|
||||||
|
kode: '',
|
||||||
|
namaRuang: '',
|
||||||
|
};
|
||||||
|
editedIndex.value = -1;
|
||||||
|
breadcrumbs.value = [
|
||||||
|
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||||
|
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||||
|
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeleteDialog = () => {
|
||||||
|
showDeleteDialog.value = false;
|
||||||
|
editedIndex.value = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveItem = () => {
|
||||||
|
if (isEditMode.value) {
|
||||||
|
Object.assign(allRuangData.value[editedIndex.value], editedItem.value);
|
||||||
|
} else {
|
||||||
|
const newId = allRuangData.value.length > 0 ? Math.max(...allRuangData.value.map(item => item.id)) + 1 : 1;
|
||||||
|
editedItem.value.id = newId;
|
||||||
|
allRuangData.value.push(editedItem.value);
|
||||||
|
}
|
||||||
|
cancelForm();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.master-klinik-ruang-page {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-data-table :deep(th) {
|
||||||
|
font-weight: bold !important;
|
||||||
|
color: #333 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-data-table :deep(td) {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--icon {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-select :deep(.v-field__input) {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
496
pages/Setting/UserLogin.vue
Normal file
496
pages/Setting/UserLogin.vue
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-login-page pa-4">
|
||||||
|
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
|
||||||
|
<template v-slot:divider>
|
||||||
|
<v-icon icon="mdi-chevron-right"></v-icon>
|
||||||
|
</template>
|
||||||
|
</v-breadcrumbs>
|
||||||
|
|
||||||
|
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
|
||||||
|
<v-card-title class="text-h5 font-weight-bold mb-4">
|
||||||
|
{{ isEditMode ? 'Edit Pengguna' : readOnly ? 'Detail Pengguna' : 'Tambah Pengguna' }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Nama Lengkap</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.namaLengkap"
|
||||||
|
placeholder="Masukkan Nama Lengkap"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Nama User</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.namaUser"
|
||||||
|
placeholder="Masukkan Nama User"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Tipe User</v-label>
|
||||||
|
<v-select
|
||||||
|
v-model="editedItem.tipeUser"
|
||||||
|
:items="['Super Admin', 'Admin', 'Loket', 'Klinik', 'Admin Barcode', 'INOVA', 'Ranap', 'Report Only', 'Farmasi', 'Manager']"
|
||||||
|
placeholder="Pilih Tipe User"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Keterangan</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.keterangan"
|
||||||
|
placeholder="Masukkan Keterangan"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Roles</v-label>
|
||||||
|
<v-select
|
||||||
|
v-model="editedItem.roles"
|
||||||
|
:items="availableRoles"
|
||||||
|
multiple
|
||||||
|
chips
|
||||||
|
placeholder="Pilih Roles Pengguna"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-label class="font-weight-bold">Groups</v-label>
|
||||||
|
<v-select
|
||||||
|
v-model="editedItem.groups"
|
||||||
|
:items="availableGroups"
|
||||||
|
multiple
|
||||||
|
chips
|
||||||
|
placeholder="Pilih Groups Pengguna"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
:readonly="readOnly"
|
||||||
|
></v-select>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row v-if="!readOnly && !isEditMode">
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-label class="font-weight-bold">Password</v-label>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedItem.password"
|
||||||
|
placeholder="Masukkan Password"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-2"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-start pa-4">
|
||||||
|
<v-btn
|
||||||
|
color="grey-darken-1"
|
||||||
|
variant="flat"
|
||||||
|
class="text-capitalize rounded-lg mr-2"
|
||||||
|
@click="cancelForm"
|
||||||
|
>
|
||||||
|
{{ readOnly ? 'Tutup' : 'Batal' }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="!readOnly"
|
||||||
|
color="orange-darken-2"
|
||||||
|
variant="flat"
|
||||||
|
class="text-capitalize rounded-lg"
|
||||||
|
@click="saveItem"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
|
||||||
|
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
|
||||||
|
<v-icon icon="mdi-account-group-outline" class="mr-2" size="40"></v-icon>
|
||||||
|
<span>User Login</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-btn
|
||||||
|
color="success"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
rounded
|
||||||
|
class="text-capitalize"
|
||||||
|
@click="showAddForm"
|
||||||
|
>
|
||||||
|
Tambah User
|
||||||
|
</v-btn>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="pa-4 rounded-lg elevation-2">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<span class="mr-2 text-subtitle-1">Show</span>
|
||||||
|
<v-select
|
||||||
|
v-model="itemsPerPage"
|
||||||
|
:items="[10, 25, 50, 100]"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width: 80px;"
|
||||||
|
rounded
|
||||||
|
class="mr-2"
|
||||||
|
></v-select>
|
||||||
|
<span class="text-subtitle-1">entries</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-text-field
|
||||||
|
v-model="search"
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
label="Search"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
rounded
|
||||||
|
clearable
|
||||||
|
></v-text-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-data-table
|
||||||
|
:headers="headers"
|
||||||
|
:items="allUserData"
|
||||||
|
:search="search"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
v-model:page="page"
|
||||||
|
class="rounded-lg elevation-0 custom-table"
|
||||||
|
>
|
||||||
|
<template v-slot:[`item.roles`]="{ item }">
|
||||||
|
<v-chip
|
||||||
|
v-for="role in item.roles"
|
||||||
|
:key="role"
|
||||||
|
color="blue-lighten-1"
|
||||||
|
size="small"
|
||||||
|
class="mr-1 mb-1"
|
||||||
|
>
|
||||||
|
{{ role }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:[`item.groups`]="{ item }">
|
||||||
|
<v-chip
|
||||||
|
v-for="group in item.groups"
|
||||||
|
:key="group"
|
||||||
|
color="purple-lighten-1"
|
||||||
|
size="small"
|
||||||
|
class="mr-1 mb-1"
|
||||||
|
>
|
||||||
|
{{ group }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:[`item.actions`]="{ item }">
|
||||||
|
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
|
||||||
|
<v-icon>mdi-eye</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
|
||||||
|
<v-icon>mdi-pencil</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn icon color="red" size="small" @click="deleteItem(item)">
|
||||||
|
<v-icon>mdi-delete</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:no-data>
|
||||||
|
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
|
||||||
|
Tidak ada data yang tersedia.
|
||||||
|
</v-alert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:bottom>
|
||||||
|
<v-row class="ma-2">
|
||||||
|
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
|
||||||
|
{{ showingEntriesText }}
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||||
|
<v-pagination
|
||||||
|
v-model="page"
|
||||||
|
:length="pageCount"
|
||||||
|
rounded="circle"
|
||||||
|
:total-visible="5"
|
||||||
|
></v-pagination>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Modal/Dialog for Confirmation -->
|
||||||
|
<v-dialog v-model="showDeleteDialog" max-width="400px">
|
||||||
|
<v-card class="pa-4 rounded-lg">
|
||||||
|
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
|
||||||
|
<v-card-text>Apakah Anda yakin ingin menghapus data **{{ itemToDelete?.namaLengkap }}**?</v-card-text>
|
||||||
|
<v-card-actions class="d-flex justify-end">
|
||||||
|
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
|
||||||
|
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-snackbar
|
||||||
|
v-model="snackbar.show"
|
||||||
|
:color="snackbar.color"
|
||||||
|
:timeout="snackbar.timeout"
|
||||||
|
>
|
||||||
|
{{ snackbar.message }}
|
||||||
|
</v-snackbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware:['auth']
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- NEW DATA LISTS FOR SELECTION ---
|
||||||
|
const availableRoles = [
|
||||||
|
'User_Standard', 'User_Report', 'User_Klinik', 'User_Farmasi',
|
||||||
|
'Admin_UserManagement', 'Admin_System', 'Manager_View'
|
||||||
|
];
|
||||||
|
const availableGroups = [
|
||||||
|
'/RS/Klinik', '/RS/Loket', '/RS/Farmasi', '/RS/IT', '/RS/Management'
|
||||||
|
];
|
||||||
|
// ------------------------------------
|
||||||
|
|
||||||
|
// Define the core user data structure (without IDs initially)
|
||||||
|
const rawUserData = [
|
||||||
|
{ namaLengkap: 'LOKET 1', namaUser: 'loket1', tipeUser: 'Loket', keterangan: 'Loket 1', roles: ['User_Standard'], groups: ['/RS/Loket'] },
|
||||||
|
{ namaLengkap: 'LOKET 2', namaUser: 'loket2', tipeUser: 'Loket', keterangan: 'Loket 2', roles: ['User_Standard'], groups: ['/RS/Loket'] },
|
||||||
|
{ namaLengkap: 'LOKET 3', namaUser: 'loket3', tipeUser: 'Loket', keterangan: 'Loket 3', roles: ['User_Standard'], groups: ['/RS/Loket'] },
|
||||||
|
{ namaLengkap: 'ANAK', namaUser: 'anak', tipeUser: 'Klinik', keterangan: 'ANAK', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
|
||||||
|
{ namaLengkap: 'ADMIN PRAM', namaUser: 'adminpram', tipeUser: 'Admin', keterangan: 'Administrator Utama', roles: ['Admin_UserManagement', 'User_Report'], groups: ['/RS/IT'] },
|
||||||
|
{ namaLengkap: 'Report Only', namaUser: 'laporan', tipeUser: 'Report Only', keterangan: 'Hanya melihat laporan', roles: ['User_Report'], groups: ['/RS/Management'] },
|
||||||
|
{ namaLengkap: 'Farmasi Utama', namaUser: 'farmasi_utama', tipeUser: 'Farmasi', keterangan: 'Apoteker Penanggung Jawab', roles: ['User_Farmasi'], groups: ['/RS/Farmasi'] },
|
||||||
|
{ namaLengkap: 'Super Admin User', namaUser: 'superadmin', tipeUser: 'Super Admin', keterangan: 'Full Control System', roles: ['Admin_System', 'Admin_UserManagement', 'User_Standard'], groups: ['/RS/IT', '/RS/Management'] },
|
||||||
|
// Adding more generic data to make the list longer and sequential
|
||||||
|
{ namaLengkap: 'Klinik Umum', namaUser: 'klinik_umum', tipeUser: 'Klinik', keterangan: 'Dokter Umum', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
|
||||||
|
{ namaLengkap: 'Manajer Keuangan', namaUser: 'manager_keu', tipeUser: 'Manager', keterangan: 'Pengelola Anggaran', roles: ['Manager_View', 'User_Report'], groups: ['/RS/Management'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map over the raw data to assign sequential IDs starting from 1
|
||||||
|
const allUserData = ref(rawUserData.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
id: index + 1
|
||||||
|
})));
|
||||||
|
|
||||||
|
// State untuk menampilkan/menyembunyikan formulir
|
||||||
|
const showForm = ref(false);
|
||||||
|
const readOnly = ref(false);
|
||||||
|
|
||||||
|
const headers = ref([
|
||||||
|
{ title: 'No', key: 'id' },
|
||||||
|
{ title: 'Nama Lengkap', key: 'namaLengkap', sortable: true },
|
||||||
|
{ title: 'Nama User', key: 'namaUser', sortable: true },
|
||||||
|
{ title: 'Tipe User', key: 'tipeUser', sortable: true },
|
||||||
|
{ title: 'Roles', key: 'roles', sortable: false },
|
||||||
|
{ title: 'Groups', key: 'groups', sortable: false },
|
||||||
|
{ title: 'Keterangan', key: 'keterangan', sortable: true },
|
||||||
|
{ title: 'Aksi', key: 'actions', sortable: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Breadcrumbs
|
||||||
|
const breadcrumbs = ref([
|
||||||
|
{ title: 'Dashboard', disabled: false, href: '#/dashboard' },
|
||||||
|
{ title: 'Setting', disabled: false, href: '#/setting' },
|
||||||
|
{ title: 'User Login', disabled: false, href: '#/setting/userlogin' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// State untuk tabel dan paginasi
|
||||||
|
const itemsPerPage = ref(10);
|
||||||
|
const search = ref('');
|
||||||
|
const page = ref(1);
|
||||||
|
const itemToDelete = ref(null);
|
||||||
|
|
||||||
|
// State untuk dialog dan form
|
||||||
|
const showDeleteDialog = ref(false);
|
||||||
|
const isEditMode = ref(false);
|
||||||
|
const editedIndex = ref(-1);
|
||||||
|
const snackbar = ref({
|
||||||
|
show: false,
|
||||||
|
message: '',
|
||||||
|
color: 'success',
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Updated `editedItem` structure with new fields
|
||||||
|
const editedItem = ref({
|
||||||
|
id: 0,
|
||||||
|
namaLengkap: '',
|
||||||
|
namaUser: '',
|
||||||
|
tipeUser: '',
|
||||||
|
keterangan: '',
|
||||||
|
password: '',
|
||||||
|
roles: [],
|
||||||
|
groups: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed properties untuk paginasi kustom
|
||||||
|
const pageCount = computed(() => {
|
||||||
|
return Math.ceil(allUserData.value.length / itemsPerPage.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showingEntriesText = computed(() => {
|
||||||
|
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||||
|
const end = Math.min(page.value * itemsPerPage.value, allUserData.value.length);
|
||||||
|
const total = allUserData.value.length;
|
||||||
|
// Handle case where total is 0 (no data)
|
||||||
|
if (total === 0) return 'Showing 0 to 0 of 0 entries';
|
||||||
|
return `Showing ${start} to ${end} of ${total} entries`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// FUNCTIONS
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
editedItem.value = {
|
||||||
|
id: 0,
|
||||||
|
namaLengkap: '',
|
||||||
|
namaUser: '',
|
||||||
|
tipeUser: '',
|
||||||
|
keterangan: '',
|
||||||
|
password: '',
|
||||||
|
roles: [],
|
||||||
|
groups: []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const showAddForm = () => {
|
||||||
|
resetForm();
|
||||||
|
isEditMode.value = false;
|
||||||
|
readOnly.value = false;
|
||||||
|
showForm.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewItem = (item) => {
|
||||||
|
// We copy the item data for view mode
|
||||||
|
editedItem.value = Object.assign({}, item);
|
||||||
|
isEditMode.value = false;
|
||||||
|
readOnly.value = true;
|
||||||
|
showForm.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editItem = (item) => {
|
||||||
|
// Find the index of the item in the actual array
|
||||||
|
editedIndex.value = allUserData.value.findIndex(data => data.id === item.id);
|
||||||
|
// Copy the item data to the editedItem
|
||||||
|
editedItem.value = Object.assign({}, item);
|
||||||
|
// Clear password field for security (will only be set if user types a new one)
|
||||||
|
editedItem.value.password = '';
|
||||||
|
isEditMode.value = true;
|
||||||
|
readOnly.value = false;
|
||||||
|
showForm.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveItem = () => {
|
||||||
|
// Basic validation (can be expanded)
|
||||||
|
if (!editedItem.value.namaLengkap || !editedItem.value.namaUser || !editedItem.value.tipeUser) {
|
||||||
|
snackbar.value = { show: true, message: 'Nama Lengkap, Nama User, dan Tipe User wajib diisi!', color: 'error', timeout: 3000 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove password from the final object for display purposes,
|
||||||
|
// as it should be securely handled in a real backend API.
|
||||||
|
const { password, ...itemData } = editedItem.value;
|
||||||
|
|
||||||
|
if (isEditMode.value) {
|
||||||
|
// Edit existing item
|
||||||
|
if (editedIndex.value > -1) {
|
||||||
|
Object.assign(allUserData.value[editedIndex.value], itemData);
|
||||||
|
snackbar.value = { show: true, message: 'Data pengguna berhasil diperbarui!', color: 'success', timeout: 3000 };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add new item: find the highest current ID and add 1
|
||||||
|
const maxId = allUserData.value.length > 0 ? Math.max(...allUserData.value.map(item => item.id)) : 0;
|
||||||
|
itemData.id = maxId + 1;
|
||||||
|
allUserData.value.push(itemData);
|
||||||
|
snackbar.value = { show: true, message: 'Pengguna baru berhasil ditambahkan!', color: 'success', timeout: 3000 };
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = (item) => {
|
||||||
|
itemToDelete.value = item;
|
||||||
|
showDeleteDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeleteDialog = () => {
|
||||||
|
showDeleteDialog.value = false;
|
||||||
|
itemToDelete.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (itemToDelete.value) {
|
||||||
|
// Find the index of the item to delete
|
||||||
|
const index = allUserData.value.findIndex(data => data.id === itemToDelete.value.id);
|
||||||
|
if (index > -1) {
|
||||||
|
// Remove the item
|
||||||
|
allUserData.value.splice(index, 1);
|
||||||
|
|
||||||
|
// Re-index the remaining items to keep the 'id' column sequential visually
|
||||||
|
allUserData.value = allUserData.value.map((item, i) => ({
|
||||||
|
...item,
|
||||||
|
id: i + 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
snackbar.value = { show: true, message: 'Data pengguna berhasil dihapus!', color: 'warning', timeout: 3000 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeDeleteDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelForm = () => {
|
||||||
|
showForm.value = false;
|
||||||
|
resetForm();
|
||||||
|
editedIndex.value = -1;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Custom Table Styling for better visual separation */
|
||||||
|
.custom-table :deep(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table :deep(th) {
|
||||||
|
background-color: #f5f5f5 !important;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.875rem; /* text-sm */
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table :deep(td) {
|
||||||
|
padding-top: 12px !important;
|
||||||
|
padding-bottom: 12px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
352
pages/index.vue
352
pages/index.vue
@@ -1,188 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware:['auth']
|
||||||
|
})
|
||||||
|
|
||||||
|
// Define a type for the data structure you now return from the API
|
||||||
|
interface SessionData {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
// ... add other user fields
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
accessToken: string;
|
||||||
|
idToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
accessTokenPayload: any;
|
||||||
|
idTokenPayload: any;
|
||||||
|
fullSessionObject: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = ref<SessionData | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const authError = ref<any>(null);
|
||||||
|
|
||||||
|
|
||||||
|
// Computeds for easy display
|
||||||
|
const sessionExpiresDate = computed(() => {
|
||||||
|
if (!sessionData.value?.expiresAt) return 'N/A';
|
||||||
|
return new Date(sessionData.value.expiresAt).toLocaleString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionCreatedDate = computed(() => {
|
||||||
|
if (!sessionData.value?.createdAt) return 'N/A';
|
||||||
|
return new Date(sessionData.value.createdAt).toLocaleString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentDateTime = computed(() => new Date().toLocaleString());
|
||||||
|
|
||||||
|
// Helper to display JSON data nicely
|
||||||
|
const formatJson = (data: any) => {
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch the enhanced session data from your API
|
||||||
|
const data = await $fetch<SessionData>('/api/auth/session');
|
||||||
|
sessionData.value = data;
|
||||||
|
authError.value = null;
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to fetch session data:', e);
|
||||||
|
// Store the error status for display
|
||||||
|
authError.value = e.data?.statusMessage || 'Session check failed. Please log in.';
|
||||||
|
sessionData.value = null;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-main class="bg-grey-lighten-3">
|
<div class="container mx-auto p-4 max-w-4xl">
|
||||||
<!-- Konten utama dibungkus dalam div yang menyesuaikan padding kiri -->
|
<h1 class="text-3xl font-bold mb-6 border-b pb-2">Complete Session Data Debug Page</h1>
|
||||||
<div :style="contentStyle">
|
|
||||||
<v-container fluid>
|
|
||||||
<p>Admin Anjungan</p>
|
|
||||||
<v-card class="pa-5 mb-5" color="white" flat>
|
|
||||||
<v-row align="center">
|
|
||||||
<v-col cols="12" md="4">
|
|
||||||
<v-text-field
|
|
||||||
label="Barcode"
|
|
||||||
placeholder="Masukkan Barcode"
|
|
||||||
outlined
|
|
||||||
dense
|
|
||||||
hide-details
|
|
||||||
></v-text-field>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-chip color="#B71C1C" class="text-caption">
|
|
||||||
Tekan Enter. (Barcode depan nomor selalu ada huruf lain, Ex:
|
|
||||||
J20073010005 "Hiraukan huruf 'J' nya")
|
|
||||||
</v-chip>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="2">
|
|
||||||
<v-btn block color="info">Pendaftaran Online</v-btn>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<v-divider class="my-5"></v-divider>
|
<div v-if="loading" class="text-center p-8">
|
||||||
|
<p class="text-xl">Loading session data...</p>
|
||||||
<v-card class="mb-5">
|
|
||||||
<v-toolbar flat color="transparent" dense>
|
|
||||||
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
|
|
||||||
DATA PENGUNJUNG TERLAMBAT
|
|
||||||
</v-toolbar-title>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<v-text-field
|
|
||||||
v-model="searchLate"
|
|
||||||
append-icon="mdi-magnify"
|
|
||||||
label="Search"
|
|
||||||
single-line
|
|
||||||
hide-details
|
|
||||||
dense
|
|
||||||
class="mr-2"
|
|
||||||
variant="outlined"
|
|
||||||
></v-text-field>
|
|
||||||
<v-select
|
|
||||||
:items="[10, 25, 50, 100]"
|
|
||||||
label="Show"
|
|
||||||
dense
|
|
||||||
single-line
|
|
||||||
hide-details
|
|
||||||
class="shrink"
|
|
||||||
variant="outlined"
|
|
||||||
></v-select>
|
|
||||||
</v-toolbar>
|
|
||||||
<v-card-text>
|
|
||||||
<v-data-table
|
|
||||||
:headers="lateHeaders"
|
|
||||||
:items="lateVisitors"
|
|
||||||
:search="searchLate"
|
|
||||||
no-data-text="No data available in table"
|
|
||||||
hide-default-footer
|
|
||||||
class="elevation-1"
|
|
||||||
></v-data-table>
|
|
||||||
<div class="d-flex justify-end pt-2">
|
|
||||||
<v-pagination
|
|
||||||
v-model="page"
|
|
||||||
:length="10"
|
|
||||||
:total-visible="5"
|
|
||||||
></v-pagination>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<v-divider class="my-5"></v-divider>
|
|
||||||
|
|
||||||
<v-card>
|
|
||||||
<v-toolbar flat color="transparent" dense>
|
|
||||||
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
|
|
||||||
DATA PENGUNJUNG
|
|
||||||
</v-toolbar-title>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<v-text-field
|
|
||||||
v-model="search"
|
|
||||||
append-icon="mdi-magnify"
|
|
||||||
label="Search"
|
|
||||||
single-line
|
|
||||||
hide-details
|
|
||||||
dense
|
|
||||||
class="mr-2"
|
|
||||||
variant="outlined"
|
|
||||||
></v-text-field>
|
|
||||||
<v-select
|
|
||||||
:items="[10, 25, 50, 100]"
|
|
||||||
label="Show"
|
|
||||||
dense
|
|
||||||
single-line
|
|
||||||
hide-details
|
|
||||||
class="shrink"
|
|
||||||
variant="outlined"
|
|
||||||
></v-select>
|
|
||||||
</v-toolbar>
|
|
||||||
<v-card-text>
|
|
||||||
<v-data-table
|
|
||||||
:headers="headers"
|
|
||||||
:items="visitors"
|
|
||||||
:search="search"
|
|
||||||
no-data-text="No data available in table"
|
|
||||||
class="elevation-1"
|
|
||||||
>
|
|
||||||
<template v-slot:item.aksi="{ item }">
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<v-btn small color="success" class="my-1">Tiket</v-btn>
|
|
||||||
<v-btn small color="primary" class="my-1">Tiket Pengantar</v-btn>
|
|
||||||
<v-btn small color="warning" class="my-1">ByPass</v-btn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-data-table>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</v-container>
|
|
||||||
</div>
|
</div>
|
||||||
</v-main>
|
|
||||||
|
<div v-else-if="authError" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||||
|
<strong class="font-bold">Authentication Error:</strong>
|
||||||
|
<span class="block sm:inline">{{ authError }}</span>
|
||||||
|
<p class="mt-2">If you expect to be logged in, the session may have expired or the cookie is missing/invalid.</p>
|
||||||
|
<NuxtLink to="/LoginPage" class="text-blue-600 hover:underline">Go to Login Page</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="sessionData">
|
||||||
|
|
||||||
|
<section class="mb-6 border p-4 rounded-lg bg-gray-50">
|
||||||
|
<h2 class="text-xl font-semibold mb-3">Basic User Information</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<p><strong>Name:</strong> {{ sessionData.user.name }}</p>
|
||||||
|
<p><strong>Email:</strong> {{ sessionData.user.email }}</p>
|
||||||
|
<p><strong>Status:</strong> <span class="text-green-600 font-medium">{{ sessionData.status }}</span></p>
|
||||||
|
<p><strong>Session Expires:</strong> {{ sessionExpiresDate }}</p>
|
||||||
|
<p><strong>Created At:</strong> {{ sessionCreatedDate }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6 border p-4 rounded-lg">
|
||||||
|
<h2 class="text-xl font-semibold mb-3">Token Information (Raw)</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer font-medium hover:text-blue-600">ID Token (session.idToken)</summary>
|
||||||
|
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.idToken }}</pre>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer font-medium hover:text-blue-600">Access Token (session.accessToken)</summary>
|
||||||
|
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.accessToken }}</pre>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer font-medium hover:text-blue-600">Refresh Token (session.refreshToken)</summary>
|
||||||
|
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.refreshToken || 'N/A' }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6 border p-4 rounded-lg">
|
||||||
|
<h2 class="text-xl font-semibold mb-3">Parsed Token Payloads</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<details open>
|
||||||
|
<summary class="cursor-pointer font-medium hover:text-blue-600">Access Token Payload</summary>
|
||||||
|
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.accessTokenPayload) }}</pre>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer font-medium hover:text-blue-600">ID Token Payload</summary>
|
||||||
|
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.idTokenPayload) }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6 border p-4 rounded-lg">
|
||||||
|
<h2 class="text-xl font-semibold mb-3">Complete Raw Session Data (Debug)</h2>
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer font-medium hover:text-blue-600">Full Session Object</summary>
|
||||||
|
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.fullSessionObject) }}</pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6 p-4 rounded-lg border-t-2 border-dashed">
|
||||||
|
<h2 class="text-xl font-semibold mb-3">Session Timeline</h2>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li class="flex items-center space-x-2">
|
||||||
|
<span class="w-2 h-2 bg-black rounded-full"></span>
|
||||||
|
<p><strong>Session Created:</strong> {{ sessionCreatedDate }}</p>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center space-x-2">
|
||||||
|
<span class="w-2 h-2 bg-black rounded-full"></span>
|
||||||
|
<p><strong>Current Time:</strong> {{ currentDateTime }}</p>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center space-x-2">
|
||||||
|
<span class="w-2 h-2 bg-black rounded-full"></span>
|
||||||
|
<p><strong>Session Expires:</strong> {{ sessionExpiresDate }}</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<style scoped>
|
||||||
import { ref, computed } from "vue";
|
/* Optional: Basic styling for better visibility */
|
||||||
|
summary {
|
||||||
// Definisikan props untuk menerima status 'rail' dari layout induk
|
list-style: none; /* Removes the default arrow */
|
||||||
const props = defineProps({
|
display: block; /* Allows summary to span full width */
|
||||||
rail: Boolean,
|
padding: 0.5rem 0;
|
||||||
});
|
}
|
||||||
|
/* Adds a custom arrow/chevron */
|
||||||
// Reactive data
|
summary::before {
|
||||||
const search = ref("");
|
content: "▶";
|
||||||
const searchLate = ref("");
|
margin-right: 0.5em;
|
||||||
const page = ref(1);
|
transition: transform 0.2s;
|
||||||
|
display: inline-block;
|
||||||
// Gaya komputasi untuk menyesuaikan padding
|
}
|
||||||
const contentStyle = computed(() => {
|
details[open] summary::before {
|
||||||
return {
|
content: "▼";
|
||||||
paddingLeft: props.rail ? '56px' : '256px',
|
/* transform: rotate(90deg); */
|
||||||
transition: 'padding-left 0.3s ease-in-out',
|
}
|
||||||
};
|
</style>
|
||||||
});
|
|
||||||
|
|
||||||
// Table headers for late visitors
|
|
||||||
const lateHeaders = [
|
|
||||||
{ text: 'No', value: 'no' },
|
|
||||||
{ text: 'Barcode', value: 'barcode' },
|
|
||||||
{ text: 'No Rekamedik', value: 'noRekamedik' },
|
|
||||||
{ text: 'No Antrian', value: 'noAntrian' },
|
|
||||||
{ text: 'No Antrian Klinik', value: 'noAntrianKlinik' },
|
|
||||||
{ text: 'Shift', value: 'shift' },
|
|
||||||
{ text: 'Pembayaran', value: 'pembayaran' },
|
|
||||||
{ text: 'Status', value: 'status' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Table headers for all visitors
|
|
||||||
const headers = [
|
|
||||||
{ text: 'No', value: 'no' },
|
|
||||||
{ text: 'Barcode', value: 'barcode' },
|
|
||||||
{ text: 'No Rekamedik', value: 'noRekamedik' },
|
|
||||||
{ text: 'No Antrian', value: 'noAntrian' },
|
|
||||||
{ text: 'Shift', value: 'shift' },
|
|
||||||
{ text: 'Ket', value: 'ket' },
|
|
||||||
{ text: 'Fast Track', value: 'fastTrack' },
|
|
||||||
{ text: 'Pembayaran', value: 'pembayaran' },
|
|
||||||
{ text: 'Panggil', value: 'panggil' },
|
|
||||||
{ text: 'Aksi', value: 'aksi' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mock data for late visitors
|
|
||||||
const lateVisitors = ref([
|
|
||||||
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', noAntrianKlinik: 'K1', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
|
|
||||||
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', noAntrianKlinik: 'K2', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
|
|
||||||
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', noAntrianKlinik: 'K3', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Mock data for all visitors
|
|
||||||
const visitors = ref([
|
|
||||||
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', shift: 'Shift 1', ket: '', fastTrack: 'Ya', pembayaran: 'JKN', panggil: 'Ya' },
|
|
||||||
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
|
|
||||||
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
BIN
public/DSC03847-scaled.jpg
Normal file
BIN
public/DSC03847-scaled.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
BIN
public/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp
Normal file
BIN
public/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
25
server/api/auth/[...].ts.backup
Normal file
25
server/api/auth/[...].ts.backup
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// server/api/auth/[...].ts
|
||||||
|
import { NuxtAuthHandler } from '#auth'
|
||||||
|
|
||||||
|
export default NuxtAuthHandler({
|
||||||
|
secret: useRuntimeConfig().authSecret,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: 'keycloak',
|
||||||
|
name: 'Keycloak',
|
||||||
|
type: 'oidc',
|
||||||
|
issuer: useRuntimeConfig().keycloakIssuer,
|
||||||
|
clientId: useRuntimeConfig().keycloakClientId,
|
||||||
|
clientSecret: useRuntimeConfig().keycloakClientSecret,
|
||||||
|
checks: ['pkce', 'state'],
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
id: profile.sub,
|
||||||
|
name: profile.name ?? profile.preferred_username,
|
||||||
|
email: profile.email,
|
||||||
|
image: profile.picture,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
141
server/api/auth/keycloak-callback.get.ts
Normal file
141
server/api/auth/keycloak-callback.get.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// server/api/auth/keycloak-callback.ts - FIX APPLIED
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const query = getQuery(event);
|
||||||
|
|
||||||
|
console.log('🔄 === KEYCLOAK CALLBACK STARTED ===');
|
||||||
|
console.log('📋 Query parameters:', query);
|
||||||
|
|
||||||
|
const code = query.code as string;
|
||||||
|
const state = query.state as string;
|
||||||
|
const error = query.error as string;
|
||||||
|
const storedState = getCookie(event, 'oauth_state');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('❌ OAuth error from Keycloak:', error);
|
||||||
|
const errorDescription = query.error_description as string;
|
||||||
|
console.error('❌ Error description:', errorDescription);
|
||||||
|
|
||||||
|
const errorMsg = encodeURIComponent(`Keycloak error: ${error} - ${errorDescription || 'Please try again'}`);
|
||||||
|
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📝 Code received:', !!code);
|
||||||
|
console.log('🎲 State from URL:', state);
|
||||||
|
console.log('🎲 State from cookie:', storedState);
|
||||||
|
console.log('🎲 State validation:', state === storedState);
|
||||||
|
|
||||||
|
if (!state || state !== storedState) {
|
||||||
|
console.error('❌ Invalid state parameter - possible CSRF attack');
|
||||||
|
console.error(' Expected:', storedState);
|
||||||
|
console.error(' Received:', state);
|
||||||
|
|
||||||
|
const errorMsg = encodeURIComponent('Security validation failed. Please try logging in again.');
|
||||||
|
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCookie(event, 'oauth_state');
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
console.error('❌ Authorization code not provided');
|
||||||
|
const errorMsg = encodeURIComponent('No authorization code received from Keycloak.');
|
||||||
|
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenUrl = `${config.keycloakIssuer}/protocol/openid-connect/token`;
|
||||||
|
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`;
|
||||||
|
|
||||||
|
// ... (Token exchange logic remains the same) ...
|
||||||
|
const tokenPayload = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: config.keycloakClientId,
|
||||||
|
client_secret: config.keycloakClientSecret,
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenResponse = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: tokenPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const errorText = await tokenResponse.text();
|
||||||
|
console.error('❌ Token exchange failed:', errorText);
|
||||||
|
const errorMsg = encodeURIComponent(`Token exchange failed: ${tokenResponse.status} - Please check Keycloak configuration`);
|
||||||
|
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
|
// ... (Token decoding and sessionData creation remains the same) ...
|
||||||
|
let idTokenPayload;
|
||||||
|
try {
|
||||||
|
idTokenPayload = JSON.parse(
|
||||||
|
Buffer.from(tokens.id_token.split('.')[1], 'base64').toString()
|
||||||
|
);
|
||||||
|
} catch (decodeError) {
|
||||||
|
console.error('❌ Failed to decode ID token:', decodeError);
|
||||||
|
const errorMsg = encodeURIComponent('Invalid ID token format');
|
||||||
|
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = {
|
||||||
|
user: {
|
||||||
|
id: idTokenPayload.sub,
|
||||||
|
email: idTokenPayload.email,
|
||||||
|
name: idTokenPayload.name || idTokenPayload.preferred_username,
|
||||||
|
preferred_username: idTokenPayload.preferred_username,
|
||||||
|
given_name: idTokenPayload.given_name,
|
||||||
|
family_name: idTokenPayload.family_name,
|
||||||
|
},
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
idToken: tokens.id_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresAt: Date.now() + (tokens.expires_in * 1000),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------
|
||||||
|
// 👇 CRITICAL FIX FOR DEPLOYED HTTPS ENVIRONMENTS 👇
|
||||||
|
// ----------------------------------------------------
|
||||||
|
// Check if the request was originally HTTPS (via proxy)
|
||||||
|
const isSecure = process.env.NODE_ENV === 'production' ||
|
||||||
|
event.node.req.headers['x-forwarded-proto'] === 'https';
|
||||||
|
|
||||||
|
console.log('🔗 Setting session cookie with secure flag:', isSecure);
|
||||||
|
|
||||||
|
setCookie(event, 'user_session', JSON.stringify(sessionData), {
|
||||||
|
httpOnly: true,
|
||||||
|
// CRITICAL: Must be TRUE when operating over HTTPS (deployed)
|
||||||
|
secure: isSecure,
|
||||||
|
// Ensures cookie is sent on cross-site redirects (Keycloak -> Your App)
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: tokens.expires_in,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Session cookie created successfully');
|
||||||
|
|
||||||
|
// Note: The following line will still log false because the cookie
|
||||||
|
// is in the response header, not the request header yet. This is expected.
|
||||||
|
const testCookie = getCookie(event, 'user_session');
|
||||||
|
console.log('🧪 Cookie test - can read back in this handler (Expected False):', !!testCookie);
|
||||||
|
|
||||||
|
console.log('↪️ Redirecting to dashboard...');
|
||||||
|
return sendRedirect(event, '/dashboard?authenticated=true');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ === CALLBACK ERROR ===');
|
||||||
|
console.error('❌ Error message:', error.message);
|
||||||
|
console.error('❌ Error stack:', error.stack);
|
||||||
|
console.error('❌ ==================');
|
||||||
|
|
||||||
|
const errorMsg = encodeURIComponent(`Authentication failed: ${error.message}`);
|
||||||
|
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
65
server/api/auth/keycloak-login.ts
Normal file
65
server/api/auth/keycloak-login.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// server/api/auth/keycloak-login.ts
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
console.log('🔐 Keycloak Login Handler Called')
|
||||||
|
console.log('📍 Method:', getMethod(event))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
// Debug: Log runtime config (without secrets)
|
||||||
|
console.log('🔧 Runtime Config Check:')
|
||||||
|
console.log(' - Has keycloakIssuer:', !!config.keycloakIssuer)
|
||||||
|
console.log(' - Has keycloakClientId:', !!config.keycloakClientId)
|
||||||
|
console.log(' - Has keycloakSecret:', !!config.keycloakClientSecret)
|
||||||
|
console.log(' - Issuer value:', config.keycloakIssuer)
|
||||||
|
|
||||||
|
// Validate required configuration
|
||||||
|
if (!config.keycloakIssuer) {
|
||||||
|
throw new Error('KEYCLOAK_ISSUER is not configured')
|
||||||
|
}
|
||||||
|
if (!config.keycloakClientId) {
|
||||||
|
throw new Error('KEYCLOAK_CLIENT_ID is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate state parameter for security
|
||||||
|
const state = randomBytes(32).toString('hex')
|
||||||
|
console.log('🎲 Generated state:', state.substring(0, 8) + '...')
|
||||||
|
|
||||||
|
// Store state in session cookie
|
||||||
|
setCookie(event, 'oauth_state', state, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 600 // 10 minutes
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build Keycloak authorization URL
|
||||||
|
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`
|
||||||
|
const authUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/auth`)
|
||||||
|
|
||||||
|
authUrl.searchParams.set('client_id', config.keycloakClientId)
|
||||||
|
authUrl.searchParams.set('redirect_uri', redirectUri)
|
||||||
|
authUrl.searchParams.set('response_type', 'code')
|
||||||
|
authUrl.searchParams.set('scope', 'openid profile email')
|
||||||
|
authUrl.searchParams.set('state', state)
|
||||||
|
|
||||||
|
console.log('🏗️ Auth URL built:', authUrl.toString())
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
authUrl: authUrl.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Login Error:', error.message)
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Failed to generate authorization URL: ${error.message}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
74
server/api/auth/logout.post.ts
Normal file
74
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// server/api/auth/logout.post.ts
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
console.log('🚪 Logout handler called');
|
||||||
|
|
||||||
|
// Get the current session to retrieve tokens
|
||||||
|
const sessionCookie = getCookie(event, 'user_session');
|
||||||
|
let idToken = null;
|
||||||
|
|
||||||
|
if (sessionCookie) {
|
||||||
|
try {
|
||||||
|
const session = JSON.parse(sessionCookie);
|
||||||
|
idToken = session.idToken;
|
||||||
|
console.log('🔑 ID token found in session:', !!idToken);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Could not parse session cookie:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ No session cookie found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all auth-related cookies
|
||||||
|
console.log('🧹 Clearing session cookies...');
|
||||||
|
deleteCookie(event, 'user_session');
|
||||||
|
deleteCookie(event, 'oauth_state');
|
||||||
|
|
||||||
|
// Also clear with different path variations to be thorough
|
||||||
|
deleteCookie(event, 'user_session', { path: '/' });
|
||||||
|
deleteCookie(event, 'oauth_state', { path: '/' });
|
||||||
|
|
||||||
|
console.log('✅ Session cleared successfully');
|
||||||
|
|
||||||
|
// Construct the Keycloak logout URL with proper parameters
|
||||||
|
const logoutUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/logout`);
|
||||||
|
|
||||||
|
// Add required parameters for proper Keycloak logout - REDIRECT TO LOGIN PAGE
|
||||||
|
logoutUrl.searchParams.set('client_id', config.keycloakClientId);
|
||||||
|
logoutUrl.searchParams.set('post_logout_redirect_uri', `${config.public.authUrl}/LoginPage?logout=success`);
|
||||||
|
|
||||||
|
// If we have an ID token, add it for proper session termination
|
||||||
|
if (idToken) {
|
||||||
|
logoutUrl.searchParams.set('id_token_hint', idToken);
|
||||||
|
console.log('🔑 Added id_token_hint to logout URL');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ No ID token available for logout hint');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔗 Keycloak logout URL constructed:', logoutUrl.toString());
|
||||||
|
|
||||||
|
// Return the logout URL to the client for redirect
|
||||||
|
// This approach gives better control to the client-side code
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
logoutUrl: logoutUrl.toString(),
|
||||||
|
message: 'Session cleared successfully'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Logout error:', error);
|
||||||
|
console.error('❌ Error stack:', error.stack);
|
||||||
|
|
||||||
|
// Even if there's an error, try to provide a basic logout URL - REDIRECT TO LOGIN PAGE
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const fallbackLogoutUrl = `${config.keycloakIssuer}/protocol/openid-connect/logout?client_id=${config.keycloakClientId}&post_logout_redirect_uri=${encodeURIComponent(config.public.authUrl + '/LoginPage?logout=success')}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
logoutUrl: fallbackLogoutUrl,
|
||||||
|
error: 'Logout encountered an error, but providing fallback logout URL',
|
||||||
|
message: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
95
server/api/auth/session.get.ts
Normal file
95
server/api/auth/session.get.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// server/api/auth/session.get.ts
|
||||||
|
|
||||||
|
// Helper function to safely decode the JWT payload (Access Token or ID Token)
|
||||||
|
const decodeTokenPayload = (token: string | undefined): any | null => {
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
// Tokens are base64 encoded and separated by '.'
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length < 2) return null; // Not a valid JWT format
|
||||||
|
|
||||||
|
const payloadBase64 = parts[1];
|
||||||
|
|
||||||
|
// Decode from base64 and parse the JSON
|
||||||
|
// Note: Using Buffer.from is standard in Node.js server environments (like Nitro/H3)
|
||||||
|
return JSON.parse(Buffer.from(payloadBase64, 'base64').toString());
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Failed to decode token payload:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- START OF THE SINGLE EXPORT DEFAULT HANDLER ---
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
console.log('🔍 Session endpoint called');
|
||||||
|
|
||||||
|
const sessionCookie = getCookie(event, 'user_session');
|
||||||
|
console.log('🍪 Session cookie exists:', !!sessionCookie);
|
||||||
|
|
||||||
|
if (!sessionCookie) {
|
||||||
|
console.log('❌ No session cookie found');
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'No session cookie found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = JSON.parse(sessionCookie);
|
||||||
|
console.log('📋 Session parsed successfully');
|
||||||
|
|
||||||
|
const isExpired = Date.now() > session.expiresAt;
|
||||||
|
console.log(' Is Expired:', isExpired);
|
||||||
|
|
||||||
|
// Check if the token has expired
|
||||||
|
if (isExpired) {
|
||||||
|
console.log('⏰ Session has expired, clearing cookie');
|
||||||
|
deleteCookie(event, 'user_session');
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Session expired'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode tokens and prepare the enhanced response data
|
||||||
|
const idTokenPayload = decodeTokenPayload(session.idToken);
|
||||||
|
const accessTokenPayload = decodeTokenPayload(session.accessToken);
|
||||||
|
|
||||||
|
// Final response object for the frontend debug page
|
||||||
|
const sessionResponse = {
|
||||||
|
// Basic User Info
|
||||||
|
user: session.user,
|
||||||
|
|
||||||
|
// Raw Tokens
|
||||||
|
idToken: session.idToken,
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
refreshToken: session.refreshToken,
|
||||||
|
|
||||||
|
// Session Timestamps
|
||||||
|
expiresAt: session.expiresAt,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
|
||||||
|
// Parsed Payloads
|
||||||
|
idTokenPayload: idTokenPayload,
|
||||||
|
accessTokenPayload: accessTokenPayload,
|
||||||
|
|
||||||
|
// Raw Session Data (for Debug section)
|
||||||
|
fullSessionObject: session,
|
||||||
|
|
||||||
|
status: 'authenticated',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('✅ Session is valid, returning full session data');
|
||||||
|
return sessionResponse;
|
||||||
|
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('❌ Failed to parse session cookie:', parseError);
|
||||||
|
// If JSON parsing fails or any other error occurs, the session is invalid
|
||||||
|
deleteCookie(event, 'user_session');
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Invalid session data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// --- END OF THE SINGLE EXPORT DEFAULT HANDLER ---
|
||||||
82
stores/navItems.ts
Normal file
82
stores/navItems.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// stores/navItems.ts
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon: string;
|
||||||
|
children?: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial default navigation items
|
||||||
|
const defaultNavItems: NavItem[] = [
|
||||||
|
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
|
||||||
|
// Add other main menu items
|
||||||
|
{ id: 2, name: "Loket Admin", icon: "mdi-account-supervisor", path: "/LoketAdmin" },
|
||||||
|
{ id: 3, name: "Ranap Admin", icon: "mdi-bed", path: "/RanapAdmin" },
|
||||||
|
{ id: 4, name: "Klinik Admin", icon: "mdi-hospital-box", path: "/KlinikAdmin" },
|
||||||
|
{ id: 5, name: "Klinik Ruang Admin", icon: "mdi-hospital-marker", path: "/KlinikRuangAdmin" },
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: "Anjungan",
|
||||||
|
icon: "mdi-account-box-multiple",
|
||||||
|
path: "",
|
||||||
|
children: [
|
||||||
|
{ id: 7, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-account-box" },
|
||||||
|
{ id: 8, name: "Admin Anjungan", path: "/Anjungan/AdminAnjungan", icon: "mdi-account-cog" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 9, name: "Fast Track", icon: "mdi-clock-fast", path: "/FastTrack" },
|
||||||
|
{ id: 10, name: "Data Pasien", icon: "mdi-account-multiple", path: "/DataPasien" },
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
name: "Screen",
|
||||||
|
icon: "mdi-monitor",
|
||||||
|
path: "",
|
||||||
|
children: [
|
||||||
|
{ id: 12, name: "Antrian Masuk 1", path: "/Screen/Antrian Masuk 1", icon: "mdi-monitor" },
|
||||||
|
{ id: 13, name: "Antrian Masuk 2", path: "/Screen/Antrian Masuk 2", icon: "mdi-monitor" },
|
||||||
|
// ... more screen pages
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 14, name: "List Pasien", icon: "mdi-format-list-bulleted", path: "/ListPasien" },
|
||||||
|
{
|
||||||
|
id: 15 ,
|
||||||
|
name: "Setting",
|
||||||
|
icon: "mdi-cog",
|
||||||
|
path: "",
|
||||||
|
children: [
|
||||||
|
{ id: 16, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-shield-lock-outline" },
|
||||||
|
{ id: 17, name: "User Login", path: "/setting/UserLogin", icon: "mdi-account-circle" },
|
||||||
|
{ id: 18, name: "Master Loket", path: "/setting/MasterLoket", icon: "mdi-counter" },
|
||||||
|
{ id: 19, name: "Master Klinik", path: "/setting/MasterKlinik", icon: "mdi-hospital" },
|
||||||
|
{ id: 20, name: "Master Klinik Ruang", path: "/setting/MasterKlinikRuang", icon: "mdi-hospital-box" },
|
||||||
|
{ id: 21, name: "Screen", path: "/setting/Screen", icon: "mdi-monitor" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useNavItemsStore = defineStore('navItems', () => {
|
||||||
|
const navItems = useLocalStorage<NavItem[]>('navItems', defaultNavItems);
|
||||||
|
|
||||||
|
function updateNavItems(newItems: NavItem[]) {
|
||||||
|
// This will update the local storage and the state
|
||||||
|
navItems.value = newItems.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
id: index + 1
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNavItem(newItem: Omit<NavItem, 'id'>) {
|
||||||
|
const newId = navItems.value.length > 0 ? Math.max(...navItems.value.map(item => item.id)) + 1 : 1;
|
||||||
|
navItems.value.push({ ...newItem, id: newId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
navItems,
|
||||||
|
updateNavItems,
|
||||||
|
addNavItem,
|
||||||
|
};
|
||||||
|
});
|
||||||
66
types/auth.ts
Normal file
66
types/auth.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// types/auth.ts - Enhanced with better error handling and optional fields
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
preferred_username?: string
|
||||||
|
given_name?: string
|
||||||
|
family_name?: string
|
||||||
|
roles?: string[]
|
||||||
|
realm_access?: {
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
// Add any other Keycloak user properties you might need
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionResponse {
|
||||||
|
success?: boolean // Add success indicator
|
||||||
|
user: User | null
|
||||||
|
accessToken?: string
|
||||||
|
refreshToken?: string // Often useful to track
|
||||||
|
expiresAt?: number
|
||||||
|
error?: string // For error cases
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
success: boolean
|
||||||
|
data?: {
|
||||||
|
authUrl: string
|
||||||
|
}
|
||||||
|
error?: string // Add error message support
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogoutResponse {
|
||||||
|
success: boolean
|
||||||
|
logoutUrl?: string // Make optional in case of errors
|
||||||
|
error?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional utility types for auth state
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token information interface
|
||||||
|
export interface TokenInfo {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken?: string
|
||||||
|
idToken?: string
|
||||||
|
expiresAt: number
|
||||||
|
tokenType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keycloak specific user info (if you need more detailed typing)
|
||||||
|
export interface KeycloakUserInfo extends User {
|
||||||
|
sub: string
|
||||||
|
email_verified?: boolean
|
||||||
|
preferred_username?: string
|
||||||
|
given_name?: string
|
||||||
|
family_name?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user