Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9261df87e0 | ||
|
|
d735824d57 | ||
|
|
55d8920a82 | ||
|
|
81398549e2 | ||
|
|
0ad29ae5fa |
No files matched your search
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<v-app-bar app color="#ff9248" dark>
|
||||
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
|
||||
<v-toolbar-title class="ml-2 font-weight-bold">
|
||||
<span class="text-blue-darken-2">Antrean</span> RSSA
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<!-- Show loading state or user info -->
|
||||
<div v-if="isLoading" class="d-flex align-center">
|
||||
<v-progress-circular indeterminate size="20" class="mr-2"></v-progress-circular>
|
||||
<span class="mr-2">Loading...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="isAuthenticated && user">
|
||||
<ProfilePopup
|
||||
:user="user"
|
||||
@logout="handleLogout"
|
||||
/><template>
|
||||
<v-app-bar app color="blue-grey-darken-3" dark flat>
|
||||
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
|
||||
<v-toolbar-title class="ml-2 font-weight-bold">
|
||||
<span class="text-orange-darken-2">Antrian</span> RSSA
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<div v-if="isLoading" class="d-flex align-center">
|
||||
<v-progress-circular indeterminate color="orange-darken-2" size="20" class="mr-2"></v-progress-circular>
|
||||
<span class="text-caption">Loading...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="isAuthenticated && user">
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
color="transparent"
|
||||
class="pa-2 text-capitalize"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar color="orange-darken-2" size="36" class="mr-2">
|
||||
<span class="text-white font-weight-bold">{{ user.name?.charAt(0) || 'U' }}</span>
|
||||
</v-avatar>
|
||||
<span class="text-subtitle-1 font-weight-bold text-white">{{ user.name || 'User' }}</span>
|
||||
<v-icon right size="20" class="ml-1">mdi-chevron-down</v-icon>
|
||||
</div>
|
||||
</v-btn>
|
||||
</template>
|
||||
<ProfilePopup
|
||||
:user="user"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-btn @click="redirectToLogin" color="orange-darken-2" variant="flat" rounded="lg" class="text-capitalize">
|
||||
<v-icon left>mdi-login</v-icon>
|
||||
Login
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
<span class="mr-2">{{ user.name || user.preferred_username || user.email }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-btn @click="redirectToLogin" color="white" text>
|
||||
Login
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProfilePopup from './ProfilePopup.vue';
|
||||
|
||||
// Emit untuk parent component
|
||||
const emit = defineEmits(['toggle-rail']);
|
||||
|
||||
// Use auth composable
|
||||
const { user, isAuthenticated, isLoading, checkAuth, logout } = useAuth()
|
||||
|
||||
// Handle logout - use the composable's logout method
|
||||
const handleLogout = async () => {
|
||||
console.log("🚪 AppBar logout initiated...")
|
||||
try {
|
||||
await logout()
|
||||
} catch (error) {
|
||||
console.error("❌ AppBar logout error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
const redirectToLogin = () => {
|
||||
navigateTo('/LoginPage')
|
||||
}
|
||||
|
||||
// Check authentication on mount
|
||||
onMounted(async () => {
|
||||
await checkAuth()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<v-card class="pa-4 rounded-lg elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold mb-4">
|
||||
Edit Hak Akses Menu | {{ localItem.namaTipeUser }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
|
||||
<v-table density="compact" class="elevation-1 rounded-lg">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">No</th>
|
||||
<th class="text-left">Menu</th>
|
||||
<th class="text-center">Akses</th>
|
||||
<th class="text-center">Lihat</th>
|
||||
<th class="text-center">Tambah</th>
|
||||
<th class="text-center">Edit</th>
|
||||
<th class="text-center">Hapus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(menu, index) in localItem.hakAksesMenu" :key="menu.name">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td>{{ menu.name }}</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox v-model="menu.canAccess" hide-details></v-checkbox>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox v-model="menu.canView" hide-details></v-checkbox>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox v-model="menu.canAdd" hide-details></v-checkbox>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox v-model="menu.canEdit" hide-details></v-checkbox>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox v-model="menu.canDelete" hide-details></v-checkbox>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-end pa-4">
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
variant="flat"
|
||||
class="text-capitalize rounded-lg mr-2"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="orange-darken-2"
|
||||
variant="flat"
|
||||
class="text-capitalize rounded-lg"
|
||||
@click="$emit('save', localItem)"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
// Define types for better readability and safety
|
||||
interface HakAksesMenu {
|
||||
name: string;
|
||||
canAccess: boolean;
|
||||
canView: boolean;
|
||||
canAdd: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
interface HakAksesData {
|
||||
id: number;
|
||||
namaTipeUser: string;
|
||||
hakAksesMenu: HakAksesMenu[];
|
||||
}
|
||||
|
||||
// Define props with type validation
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as () => HakAksesData,
|
||||
required: true,
|
||||
// Add custom validator for more robust checks
|
||||
validator: (value: HakAksesData) => {
|
||||
return 'namaTipeUser' in value && 'hakAksesMenu' in value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Define emits for clarity
|
||||
const emits = defineEmits(['save', 'cancel']);
|
||||
|
||||
// Use a local copy to avoid mutating the prop directly
|
||||
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
|
||||
|
||||
// Watch the prop for changes and update the local copy
|
||||
watch(() => props.item, (newItem) => {
|
||||
localItem.value = JSON.parse(JSON.stringify(newItem));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-table :deep(th) {
|
||||
font-weight: bold !important;
|
||||
background-color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.v-table :deep(td) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.v-checkbox :deep(.v-selection-control__input) {
|
||||
color: #2196F3 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<v-menu
|
||||
v-model="menu"
|
||||
:close-on-content-click="false"
|
||||
location="bottom right"
|
||||
origin="top right"
|
||||
transition="slide-y-transition"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon v-bind="props">
|
||||
<v-avatar size="40">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card class="rounded-lg elevation-4 pa-4" width="300">
|
||||
<div class="d-flex align-center pb-2">
|
||||
<v-avatar size="48">
|
||||
<v-img
|
||||
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
|
||||
:alt="`${user?.name || 'User'} Profile`"
|
||||
></v-img>
|
||||
</v-avatar>
|
||||
<div class="ml-4">
|
||||
<div class="text-subtitle-1 font-weight-bold">
|
||||
{{ user?.name || user?.preferred_username || 'User' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-darken-1">
|
||||
{{ user?.email || 'No email' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-darken-2">
|
||||
ID: {{ user?.id?.substring(0, 8) }}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider class="my-2"></v-divider>
|
||||
|
||||
<v-list dense>
|
||||
<v-list-item link class="rounded-lg" @click="handleAction('account')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-cog</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Pengaturan Akun</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item link class="rounded-lg" @click="handleAction('darkMode')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-weather-night</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Mode Gelap</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<v-switch
|
||||
v-model="darkMode"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
></v-switch>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item link class="rounded-lg" @click="handleAction('profile')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-account</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Profil Saya</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider class="my-2"></v-divider>
|
||||
|
||||
<v-list-item
|
||||
link
|
||||
class="rounded-lg text-red"
|
||||
@click="signOut"
|
||||
:disabled="isLoggingOut"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="red">mdi-logout</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ isLoggingOut ? 'Logging out...' : 'Keluar' }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const menu = ref(false);
|
||||
const darkMode = ref(false);
|
||||
const isLoggingOut = ref(false);
|
||||
const emit = defineEmits(['logout']);
|
||||
|
||||
/**
|
||||
* Handles the logout action - delegates to parent
|
||||
*/
|
||||
const signOut = async () => {
|
||||
if (isLoggingOut.value) return;
|
||||
|
||||
isLoggingOut.value = true;
|
||||
menu.value = false;
|
||||
|
||||
try {
|
||||
console.log('🚪 ProfilePopup signOut called...')
|
||||
emit('logout');
|
||||
} finally {
|
||||
isLoggingOut.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (action) => {
|
||||
console.log('Action triggered:', action);
|
||||
|
||||
switch(action) {
|
||||
case 'account':
|
||||
// Navigate to account settings
|
||||
navigateTo('/settings/account')
|
||||
break;
|
||||
case 'profile':
|
||||
// Navigate to profile page
|
||||
navigateTo('/profile')
|
||||
break;
|
||||
case 'darkMode':
|
||||
// Dark mode toggle is handled by v-model
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown action:', action);
|
||||
}
|
||||
|
||||
// Close menu for navigation actions
|
||||
if (action !== 'darkMode') {
|
||||
menu.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-red {
|
||||
color: rgb(244, 67, 54) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!-- <template>
|
||||
<v-dialog v-model="dialog" max-width="500px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 font-weight-bold">
|
||||
Atur Urutan Menu
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list dense>
|
||||
<draggable v-model="localMenus" item-key="title" @end="onDragEnd">
|
||||
<template #item="{ element }">
|
||||
<v-list-item class="reorder-item">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ element.title }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-drag</v-icon>
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</draggable>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey-darken-1" text @click="dialog = false">Batal</v-btn>
|
||||
<v-btn color="blue" text @click="saveOrder">Simpan Urutan</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import navigationItems from '~/data/menu.js';
|
||||
|
||||
const dialog = ref(false);
|
||||
const localMenus = ref([]);
|
||||
|
||||
// Watch for changes in the dialog's visibility
|
||||
watch(dialog, (val) => {
|
||||
if (val) {
|
||||
// Make a deep copy to avoid mutating the original data
|
||||
localMenus.value = JSON.parse(JSON.stringify(navigationItems));
|
||||
}
|
||||
});
|
||||
|
||||
const onDragEnd = (event) => {
|
||||
// Logic to handle the end of a drag event
|
||||
};
|
||||
|
||||
const saveOrder = () => {
|
||||
// Emit an event with the new menu order
|
||||
// You would then handle this in the parent component
|
||||
// to update the ~/data/menu.js file (or your state management)
|
||||
// and trigger a UI refresh.
|
||||
dialog.value = false;
|
||||
};
|
||||
|
||||
const openDialog = () => {
|
||||
dialog.value = true;
|
||||
};
|
||||
|
||||
defineExpose({ openDialog });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reorder-item {
|
||||
cursor: grab;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.reorder-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style> -->
|
||||
@@ -0,0 +1,103 @@
|
||||
<!-- components/sideBar.vue -->
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
:model-value="drawer"
|
||||
:rail="rail"
|
||||
permanent
|
||||
app
|
||||
@update:model-value="emit('update:drawer', $event)"
|
||||
>
|
||||
<v-list density="compact" nav>
|
||||
<template v-for="item in items" :key="item.name">
|
||||
<v-menu
|
||||
v-if="item.children"
|
||||
open-on-hover
|
||||
:location="rail ? 'end' : undefined"
|
||||
:offset="10"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.name"
|
||||
:value="item.name"
|
||||
:to="!rail ? item.path : undefined"
|
||||
:link="!rail"
|
||||
></v-list-item>
|
||||
</template>
|
||||
|
||||
<v-card class="py-2" min-width="200">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold px-4 py-2">
|
||||
{{ item.name }}
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item
|
||||
v-for="child in item.children"
|
||||
:key="child.name"
|
||||
:to="child.path"
|
||||
:title="child.name"
|
||||
:prepend-icon="child.icon"
|
||||
link
|
||||
class="px-4"
|
||||
></v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<v-tooltip
|
||||
v-else
|
||||
:disabled="!rail"
|
||||
open-on-hover
|
||||
location="end"
|
||||
:text="item.name"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.name"
|
||||
:to="item.path"
|
||||
link
|
||||
></v-list-item>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
interface NavItem {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array as () => NavItem[],
|
||||
required: true,
|
||||
},
|
||||
rail: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
drawer: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:drawer']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-navigation-drawer__content {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,347 +0,0 @@
|
||||
<!-- components/TabelData.vue -->
|
||||
<template>
|
||||
<v-card-text>
|
||||
<!-- Title Section -->
|
||||
<v-row no-gutters class="mb-3" v-if="title">
|
||||
<v-col cols="12">
|
||||
<v-card-title
|
||||
class="text-subtitle-1 font-weight-bold pa-0"
|
||||
:class="getTitleClass(title)"
|
||||
>
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Controls Section -->
|
||||
<v-row no-gutters class="d-flex align-center mb-4">
|
||||
<v-col cols="12" sm="6" class="d-flex align-center">
|
||||
<div class="d-flex align-center">
|
||||
<span>Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="mx-2"
|
||||
style="width: 80px;"
|
||||
/>
|
||||
<span>entries</span>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" class="d-flex justify-end align-center">
|
||||
<div v-if="showSearch" class="d-flex align-center">
|
||||
<span class="mr-2">Search:</span>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="paginatedItems"
|
||||
:search="search"
|
||||
no-data-text="No data available in table"
|
||||
hide-default-footer
|
||||
class="elevation-1"
|
||||
item-value="no"
|
||||
>
|
||||
<!-- Custom slot untuk nomor urut -->
|
||||
<template v-slot:item.no="{ index }">
|
||||
{{ (currentPage - 1) * itemsPerPage + index + 1 }}
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk jam panggil dengan highlighting -->
|
||||
<template v-slot:item.jamPanggil="{ item }">
|
||||
<slot name="item.jamPanggil" :item="item">
|
||||
<span>{{ item.jamPanggil }}</span>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk status -->
|
||||
<template v-slot:item.status="{ item }">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
text-color="white"
|
||||
>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk barcode dengan formatting -->
|
||||
<template v-slot:item.barcode="{ item }">
|
||||
<span class="font-mono">{{ item.barcode }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk no antrian dengan highlighting -->
|
||||
<template v-slot:item.noAntrian="{ item }">
|
||||
<div>
|
||||
<span class="font-weight-medium">{{ item.noAntrian.split(' |')[0] }}</span>
|
||||
<br>
|
||||
<small class="text-grey-darken-1">{{ item.noAntrian.split(' |')[1] }}</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk klinik dengan chip styling -->
|
||||
<template v-slot:item.klinik="{ item }">
|
||||
<v-chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:color="getKlinikColor(item.klinik)"
|
||||
>
|
||||
{{ item.klinik }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk fast track -->
|
||||
<template v-slot:item.fastTrack="{ item }">
|
||||
<v-chip
|
||||
size="small"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.fastTrack }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk pembayaran -->
|
||||
<template v-slot:item.pembayaran="{ item }">
|
||||
<v-chip
|
||||
size="small"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.pembayaran }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Custom slot untuk keterangan -->
|
||||
<template v-slot:item.keterangan="{ item }">
|
||||
<span v-if="item.keterangan" class="text-green font-weight-medium">
|
||||
{{ item.keterangan }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<!-- Slot untuk aksi -->
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<slot name="actions" :item="item" />
|
||||
</template>
|
||||
|
||||
<template #no-data>
|
||||
<div class="text-center pa-4">No data available in table</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<!-- Footer Pagination -->
|
||||
<div class="d-flex justify-space-between align-center mt-4">
|
||||
<div class="text-body-2 text-grey-darken-1">
|
||||
Showing {{ currentPageStart }} to {{ currentPageEnd }} of {{ filteredTotal }} entries
|
||||
</div>
|
||||
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="totalPages"
|
||||
:total-visible="7"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
headers: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const search = ref("");
|
||||
const itemsPerPage = ref(10);
|
||||
const currentPage = ref(1);
|
||||
|
||||
// Filter items based on search
|
||||
const filteredItems = computed(() => {
|
||||
if (!search.value) {
|
||||
return props.items;
|
||||
}
|
||||
|
||||
const searchLower = search.value.toLowerCase();
|
||||
return props.items.filter(item => {
|
||||
return Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredTotal = computed(() => filteredItems.value.length);
|
||||
const totalPages = computed(() => Math.ceil(filteredTotal.value / itemsPerPage.value));
|
||||
|
||||
// Paginate the filtered items
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value;
|
||||
const end = start + itemsPerPage.value;
|
||||
return filteredItems.value.slice(start, end);
|
||||
});
|
||||
|
||||
const currentPageStart = computed(() => {
|
||||
if (filteredTotal.value === 0) return 0;
|
||||
return (currentPage.value - 1) * itemsPerPage.value + 1;
|
||||
});
|
||||
|
||||
const currentPageEnd = computed(() => {
|
||||
const end = currentPage.value * itemsPerPage.value;
|
||||
return Math.min(end, filteredTotal.value);
|
||||
});
|
||||
|
||||
// Method untuk mendapatkan warna status
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'Tunggu Daftar':
|
||||
return 'orange';
|
||||
case 'Barcode':
|
||||
return 'blue';
|
||||
case 'Selesai':
|
||||
return 'green';
|
||||
case 'Batal':
|
||||
return 'red';
|
||||
case 'Aktif':
|
||||
return 'success';
|
||||
case 'Menunggu':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'grey';
|
||||
}
|
||||
};
|
||||
|
||||
// Method untuk mendapatkan warna klinik
|
||||
const getKlinikColor = (klinik) => {
|
||||
switch (klinik) {
|
||||
case 'KANDUNGAN':
|
||||
return 'pink';
|
||||
case 'IPD':
|
||||
return 'blue';
|
||||
case 'THT':
|
||||
return 'orange';
|
||||
case 'SARAF':
|
||||
return 'purple';
|
||||
default:
|
||||
return 'grey';
|
||||
}
|
||||
};
|
||||
|
||||
// Method untuk mendapatkan class title
|
||||
const getTitleClass = (title) => {
|
||||
if (title.includes('TERLAMBAT')) {
|
||||
return 'text-warning';
|
||||
} else if (title.includes('PENDING')) {
|
||||
return 'text-info';
|
||||
} else if (title.includes('DI LOKET')) {
|
||||
return 'text-success';
|
||||
}
|
||||
return 'text-primary';
|
||||
};
|
||||
|
||||
// Watch untuk reset halaman ketika items per page berubah
|
||||
watch(itemsPerPage, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
// Watch untuk reset halaman ketika items berubah
|
||||
watch(() => props.items, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
// Watch untuk reset halaman ketika search berubah
|
||||
watch(search, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-red {
|
||||
color: #f44336 !important;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: #ff9800 !important;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
color: #2196f3 !important;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #4caf50 !important;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #1976d2 !important;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Table enhancements */
|
||||
:deep(.v-data-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.v-data-table tbody tr) {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.v-data-table tbody tr:hover) {
|
||||
background: rgba(25, 118, 210, 0.04) !important;
|
||||
}
|
||||
|
||||
:deep(.v-data-table th) {
|
||||
font-weight: 600 !important;
|
||||
background: #fafafa !important;
|
||||
color: #424242 !important;
|
||||
}
|
||||
|
||||
:deep(.v-data-table td) {
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
:deep(.v-data-table) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.v-pagination {
|
||||
:deep(.v-pagination__item) {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
hide-default-footer
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.pilih="{ item }">
|
||||
<v-checkbox
|
||||
:model-value="isSelected(item.id)"
|
||||
@change="toggleService(item.id)"
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.no="{ item }">
|
||||
{{ item.no }}
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
headers: Array,
|
||||
items: Array,
|
||||
selectedItems: Array,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:selectedItems']);
|
||||
|
||||
const isSelected = (id) => props.selectedItems.includes(id);
|
||||
|
||||
const toggleService = (id) => {
|
||||
const newSelection = isSelected(id)
|
||||
? props.selectedItems.filter(serviceId => serviceId !== id)
|
||||
: [...props.selectedItems, id];
|
||||
|
||||
emit('update:selectedItems', newSelection);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-data-table {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.v-checkbox {
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,320 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Filter Section -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" class="d-flex align-center flex-wrap ml-4">
|
||||
<div style="width: 200px;" class="mr-4">
|
||||
<v-text-field
|
||||
v-model="filterDate"
|
||||
type="date"
|
||||
label="Tanggal"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<div style="width: 150px;" class="mr-4">
|
||||
<v-select
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<v-btn color="primary" @click="searchData" class="mr-3">
|
||||
SEARCH
|
||||
</v-btn>
|
||||
<v-btn color="success" variant="outlined" @click="exportLaporan" class="mr-3">
|
||||
Laporan Pasien
|
||||
</v-btn>
|
||||
<v-btn color="info" variant="outlined" @click="exportLaporanPerKlinik">
|
||||
Laporan Pasien Per Klinik
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Table Controls -->
|
||||
<v-row class="mb-3">
|
||||
<v-col cols="12" md="6" class="d-flex align-center">
|
||||
<span class="mr-2 pa-4">Show</span>
|
||||
<div style="width: 100px;">
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<span class="ml-2">entries</span>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="d-flex justify-end">
|
||||
<div class="d-flex align-center pa-4">
|
||||
<span class="mr-2">Search:</span>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="min-width: 200px"
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Data Table -->
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="paginatedItems"
|
||||
:search="search"
|
||||
hide-default-footer
|
||||
class="elevation-1"
|
||||
>
|
||||
<!-- Custom Status Column -->
|
||||
<template v-slot:item.status="{ item }">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Custom Keterangan Column -->
|
||||
<template v-slot:item.keterangan="{ item }">
|
||||
<span :class="getKeteranganClass(item.keterangan)">
|
||||
{{ item.keterangan }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- No Data -->
|
||||
<template #no-data>
|
||||
<div class="text-center pa-4">
|
||||
No data available in table
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="d-flex justify-space-between align-center pa-4">
|
||||
<div class="text-body-2 text-grey-darken-1">
|
||||
Showing {{ currentPageStart }} to {{ currentPageEnd }} of {{ totalFilteredItems }} entries
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn
|
||||
:disabled="currentPage === 1"
|
||||
@click="previousPage"
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
Previous
|
||||
</v-btn>
|
||||
<template v-for="page in visiblePages" :key="page">
|
||||
<v-btn
|
||||
v-if="page !== '...'"
|
||||
:color="page === currentPage ? 'primary' : ''"
|
||||
:variant="page === currentPage ? 'flat' : 'text'"
|
||||
@click="goToPage(page)"
|
||||
size="small"
|
||||
class="mx-1"
|
||||
min-width="40"
|
||||
>
|
||||
{{ page }}
|
||||
</v-btn>
|
||||
<span v-else class="mx-1">...</span>
|
||||
</template>
|
||||
<v-btn
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="nextPage"
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
Next
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['search', 'export-laporan', 'export-laporan-per-klinik'])
|
||||
|
||||
// Filter states
|
||||
const filterDate = ref('')
|
||||
const filterStatus = ref('Semua')
|
||||
const search = ref('')
|
||||
|
||||
// Pagination states
|
||||
const itemsPerPage = ref(10)
|
||||
const currentPage = ref(1)
|
||||
|
||||
// Status options
|
||||
const statusOptions = ['Semua', 'Online', 'Offline', 'Tunggu Daftar', 'Barcode']
|
||||
|
||||
// Table headers
|
||||
const headers = [
|
||||
{ title: 'No', key: 'no', sortable: false, width: '60px' },
|
||||
{ title: 'Tgl Periksa', key: 'tglPeriksa', sortable: true },
|
||||
{ title: 'NIK', key: 'nik', sortable: true },
|
||||
{ title: 'RM', key: 'rm', sortable: true },
|
||||
{ title: 'Barcode', key: 'barcode', sortable: true },
|
||||
{ title: 'No Antrian', key: 'noAntrian', sortable: true },
|
||||
{ title: 'Klinik', key: 'klinik', sortable: true },
|
||||
{ title: 'First Name Last Name', key: 'fullName', sortable: true },
|
||||
{ title: 'Shift', key: 'shift', sortable: true },
|
||||
{ title: 'Pembayaran', key: 'pembayaran', sortable: true },
|
||||
{ title: 'Keterangan', key: 'keterangan', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: true }
|
||||
]
|
||||
|
||||
// Computed properties
|
||||
const filteredItems = computed(() => {
|
||||
let filtered = props.items
|
||||
|
||||
// Filter by date
|
||||
if (filterDate.value) {
|
||||
filtered = filtered.filter(item =>
|
||||
item.tglPeriksa === filterDate.value
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (filterStatus.value && filterStatus.value !== 'Semua') {
|
||||
filtered = filtered.filter(item =>
|
||||
item.status === filterStatus.value
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalFilteredItems = computed(() => filteredItems.value.length)
|
||||
const totalPages = computed(() => Math.ceil(totalFilteredItems.value / itemsPerPage.value))
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||
const end = start + itemsPerPage.value
|
||||
return filteredItems.value.slice(start, end).map((item, index) => ({
|
||||
...item,
|
||||
no: start + index + 1
|
||||
}))
|
||||
})
|
||||
|
||||
const currentPageStart = computed(() =>
|
||||
totalFilteredItems.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1
|
||||
)
|
||||
|
||||
const currentPageEnd = computed(() =>
|
||||
Math.min(currentPage.value * itemsPerPage.value, totalFilteredItems.value)
|
||||
)
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
if (current <= 4) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
} else if (current >= total - 3) {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = total - 4; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
}
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
// Methods
|
||||
const searchData = () => {
|
||||
currentPage.value = 1
|
||||
emit('search', {
|
||||
date: filterDate.value,
|
||||
status: filterStatus.value
|
||||
})
|
||||
}
|
||||
|
||||
const exportLaporan = () => {
|
||||
emit('export-laporan')
|
||||
}
|
||||
|
||||
const exportLaporanPerKlinik = () => {
|
||||
// Navigate to laporan pasien per klinik page
|
||||
navigateTo('/laporan-pasien-per-klinik')
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
'Online': 'success',
|
||||
'Offline': 'error',
|
||||
'Tunggu Daftar': 'warning',
|
||||
'Barcode': 'info'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
const getKeteranganClass = (keterangan) => {
|
||||
return keterangan === 'Online' ? 'text-success' : ''
|
||||
}
|
||||
|
||||
const previousPage = () => {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
}
|
||||
}
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++
|
||||
}
|
||||
}
|
||||
|
||||
const goToPage = (page) => {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(itemsPerPage, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
watch([filterDate, filterStatus], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-success {
|
||||
color: rgb(76, 175, 80) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
// composables/useAuth.ts - Enhanced version with better error handling
|
||||
import type { User, SessionResponse, LoginResponse, LogoutResponse } from '~/types/auth'
|
||||
|
||||
export const useAuth = () => {
|
||||
const user = ref<User | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
const checkAuth = async (): Promise<User | null> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
clearError()
|
||||
|
||||
const response = await $fetch<SessionResponse>('/api/auth/session')
|
||||
|
||||
if (response.success === false && response.error) {
|
||||
error.value = response.error
|
||||
user.value = null
|
||||
return null
|
||||
}
|
||||
|
||||
user.value = response.user
|
||||
return response.user
|
||||
} catch (fetchError: any) {
|
||||
console.error('Session check failed:', fetchError)
|
||||
error.value = 'Failed to check authentication status'
|
||||
user.value = null
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (): Promise<void> => {
|
||||
try {
|
||||
clearError()
|
||||
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response?.success && response?.data?.authUrl) {
|
||||
console.log('🔗 Redirecting to Keycloak login...')
|
||||
window.location.href = response.data.authUrl
|
||||
} else {
|
||||
const errorMsg = response?.error || 'Failed to get authorization URL'
|
||||
error.value = errorMsg
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
} catch (loginError: any) {
|
||||
console.error('❌ Login error:', loginError)
|
||||
error.value = loginError.message || 'Login failed'
|
||||
throw loginError
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
clearError()
|
||||
console.log('🚪 Starting logout process...')
|
||||
|
||||
const response = await $fetch<LogoutResponse>('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
// Clear user immediately regardless of response
|
||||
user.value = null
|
||||
|
||||
if (response?.success && response?.logoutUrl) {
|
||||
console.log('🔗 Redirecting to Keycloak logout...')
|
||||
window.location.href = response.logoutUrl
|
||||
} else {
|
||||
const warningMsg = response?.error || response?.message || 'No logout URL received'
|
||||
console.warn('⚠️', warningMsg)
|
||||
error.value = warningMsg
|
||||
await navigateTo('/LoginPage')
|
||||
}
|
||||
} catch (logoutError: any) {
|
||||
console.error('❌ Logout error:', logoutError)
|
||||
error.value = logoutError.message || 'Logout failed'
|
||||
user.value = null
|
||||
await navigateTo('/LoginPage')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to refresh user data
|
||||
const refreshUser = async (): Promise<boolean> => {
|
||||
const userData = await checkAuth()
|
||||
return !!userData
|
||||
}
|
||||
|
||||
// Helper function to check if user has specific role
|
||||
const hasRole = (role: string): boolean => {
|
||||
if (!user.value) return false
|
||||
|
||||
// Check in roles array
|
||||
if (user.value.roles?.includes(role)) return true
|
||||
|
||||
// Check in realm_access.roles
|
||||
if (user.value.realm_access?.roles?.includes(role)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Helper function to check if user has any of the specified roles
|
||||
const hasAnyRole = (roles: string[]): boolean => {
|
||||
return roles.some(role => hasRole(role))
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
user: readonly(user),
|
||||
isAuthenticated,
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
|
||||
// Actions
|
||||
checkAuth,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
clearError,
|
||||
|
||||
// Utilities
|
||||
hasRole,
|
||||
hasAnyRole
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<!-- Navigation Drawer -->
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
:rail="rail"
|
||||
permanent
|
||||
app
|
||||
color="white"
|
||||
width="280"
|
||||
rail-width="72"
|
||||
>
|
||||
<!-- Header Sidebar -->
|
||||
<div class="pa-4 d-flex align-center" style="height: 64px;">
|
||||
<v-icon color="#ff9248" size="32">mdi-hospital-building</v-icon>
|
||||
<v-toolbar-title v-show="!rail" class="ml-3 text-h6">
|
||||
Antrean RSSA
|
||||
</v-toolbar-title>
|
||||
</div>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- Menu Items -->
|
||||
<v-list density="default" nav class="py-2">
|
||||
<v-list-subheader v-show="!rail" class="text-caption text-grey">
|
||||
OVERVIEW
|
||||
</v-list-subheader>
|
||||
|
||||
<template v-for="item in items" :key="item.title">
|
||||
<!-- Item dengan Children -->
|
||||
<v-menu
|
||||
v-if="item.children"
|
||||
open-on-hover
|
||||
location="end"
|
||||
:disabled="!rail"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-list-group v-if="!rail" :value="item.title">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
:active="item.title === currentActiveMenu"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item
|
||||
v-for="child in item.children"
|
||||
:key="child.title"
|
||||
:to="child.to"
|
||||
:title="child.title"
|
||||
:active="child.to === currentRoute.path"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1 pl-12"
|
||||
>
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
|
||||
<!-- Rail mode menu -->
|
||||
<v-list-item
|
||||
v-else
|
||||
v-bind="props"
|
||||
:prepend-icon="item.icon"
|
||||
:active="item.title === currentActiveMenu"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<!-- Submenu untuk rail mode -->
|
||||
<v-list class="py-2" style="min-width: 200px;">
|
||||
<v-list-item>
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
{{ item.title }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider class="my-2"></v-divider>
|
||||
<v-list-item
|
||||
v-for="child in item.children"
|
||||
:key="child.title"
|
||||
:to="child.to"
|
||||
:title="child.title"
|
||||
:active="child.to === currentRoute.path"
|
||||
rounded="lg"
|
||||
class="mx-2"
|
||||
>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<!-- Item tanpa Children -->
|
||||
<v-list-item
|
||||
v-else
|
||||
:prepend-icon="item.icon"
|
||||
:title="!rail ? item.title : ''"
|
||||
:to="item.to"
|
||||
:active="item.to === currentRoute.path"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
<template v-slot:append v-if="item.badge && !rail">
|
||||
<v-chip size="x-small" color="primary">{{ item.badge }}</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<v-list-subheader v-show="!rail" class="text-caption text-grey">
|
||||
ACCOUNT
|
||||
</v-list-subheader>
|
||||
|
||||
<v-list-item
|
||||
prepend-icon="mdi-cog-outline"
|
||||
:title="!rail ? 'Settings' : ''"
|
||||
to="/settings"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
prepend-icon="mdi-logout"
|
||||
:title="!rail ? 'Log out' : ''"
|
||||
rounded="lg"
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<template v-slot:append>
|
||||
<!-- Theme Toggle -->
|
||||
<div class="pa-4 d-flex justify-center align-center">
|
||||
<v-icon color="primary" size="20">mdi-white-balance-sunny</v-icon>
|
||||
<v-switch
|
||||
v-show="!rail"
|
||||
v-model="darkMode"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="mx-2"
|
||||
></v-switch>
|
||||
<v-icon v-show="!rail" size="20">mdi-moon-waning-crescent</v-icon>
|
||||
</div>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="pa-4">
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar color="#ff9248" size="40">
|
||||
<span class="text-white">AS</span>
|
||||
</v-avatar>
|
||||
<div v-show="!rail" class="ml-3 flex-grow-1">
|
||||
<div class="text-subtitle-2 font-weight-bold">Adam Sulfat</div>
|
||||
<div class="text-caption text-grey">adam@rssa.com</div>
|
||||
</div>
|
||||
<v-btn
|
||||
v-show="!rail"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
>
|
||||
<v-icon size="20">mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- Main Content -->
|
||||
<v-main>
|
||||
<!-- Top Bar untuk Toggle -->
|
||||
<v-app-bar flat color="transparent" height="64">
|
||||
<v-btn
|
||||
icon
|
||||
@click="rail = !rail"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon>
|
||||
<v-icon>mdi-bell-outline</v-icon>
|
||||
</v-btn>
|
||||
</v-app-bar>
|
||||
|
||||
<v-container fluid class="pa-6">
|
||||
<slot></slot>
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<!-- Footer -->
|
||||
<v-footer app class="bg-grey-lighten-4">
|
||||
<v-container fluid class="py-2">
|
||||
<v-row no-gutters align="center">
|
||||
<v-col cols="12" md="6">
|
||||
<span class="text-caption text-grey-darken-2">
|
||||
RSUD Dr. Saiful Anwar Malang | Jl. Jaksa Agung Suprapto No. 2 Malang | Telp: 0341-362101
|
||||
</span>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="text-right">
|
||||
<span class="text-caption text-grey-darken-2">
|
||||
ITIKom Antrian RSSA Ver. 0.2
|
||||
</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-footer>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
// State
|
||||
const drawer = ref(true);
|
||||
const rail = ref(false);
|
||||
const darkMode = ref(false);
|
||||
|
||||
// Menu Items
|
||||
const items = ref([
|
||||
{ title: "Dashboard", icon: "mdi-view-dashboard-outline", to: "/dashboard" },
|
||||
// { title: "Marketplace", icon: "mdi-storefront-outline", to: "/marketplace" },
|
||||
// { title: "My Properties", icon: "mdi-home-outline", to: "/properties" },
|
||||
// { title: "Auctions", icon: "mdi-gavel", to: "/auctions" },
|
||||
// { title: "Wallet", icon: "mdi-wallet-outline", to: "/wallet" },
|
||||
// { title: "Favorites", icon: "mdi-heart-outline", to: "/favorites" },
|
||||
{
|
||||
title: "Setting",
|
||||
icon: "mdi-cog-outline",
|
||||
children: [
|
||||
{ title: "Hak Akses", to: "/setting/hak-akses" },
|
||||
{ title: "User Login", to: "/setting/user-login" },
|
||||
{ title: "Master Loket", to: "/setting/master-loket" },
|
||||
{ title: "Master Klinik", to: "/setting/master-klinik" },
|
||||
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
|
||||
{ title: "Screen", to: "/setting/screen" },
|
||||
],
|
||||
},
|
||||
{ title: "Loket Admin", icon: "mdi-account-supervisor-outline", to: "/loket-admin" },
|
||||
{ title: "Ranap Admin", icon: "mdi-bed-outline", to: "/ranap-admin" },
|
||||
{
|
||||
title: "Anjungan",
|
||||
icon: "mdi-account-box-multiple-outline",
|
||||
children: [
|
||||
{ title: "Anjungan", to: "/anjungan/anjungan" },
|
||||
{ title: "Klinik Ruang", to: "/anjungan/AntrianKlinik"},
|
||||
],
|
||||
},
|
||||
{ title: "Data Pasien", icon: "mdi-account-multiple-outline", to: "/data-pasien" },
|
||||
]);
|
||||
|
||||
const currentRoute = useRoute();
|
||||
const currentActiveMenu = computed(() => {
|
||||
const currentItem = items.value.find(item => item.to === currentRoute.path);
|
||||
if (currentItem) {
|
||||
return currentItem.title;
|
||||
}
|
||||
|
||||
for (const item of items.value) {
|
||||
if (item.children) {
|
||||
const childItem = item.children.find(child => child.to === currentRoute.path);
|
||||
if (childItem) {
|
||||
return item.title;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-list-item--active {
|
||||
background-color: #e8f5e9 !important;
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.v-list-item--active :deep(.v-list-item__prepend) {
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.v-list-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.v-navigation-drawer {
|
||||
border-right: 1px solid #e0e0e0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-main>
|
||||
<slot />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Pastikan v-main mengisi seluruh tinggi halaman untuk mengaktifkan centering */
|
||||
.v-main {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!-- layouts/default.vue -->
|
||||
<template>
|
||||
<v-app id="inspire">
|
||||
<AppBar @toggle-rail="rail = !rail" />
|
||||
<SideBar :items="navItemsStore.navItems" v-model:drawer="drawer" :rail="rail" />
|
||||
|
||||
<v-main app>
|
||||
<slot />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect } from "vue";
|
||||
import AppBar from "../components/AppBar.vue";
|
||||
import SideBar from "../components/SideBar.vue";
|
||||
import { useNavItemsStore } from '@/stores/navItems'; // Import the new store
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const drawer = ref(true);
|
||||
const rail = ref(true);
|
||||
|
||||
const navItemsStore = useNavItemsStore();
|
||||
|
||||
// Your logic to check user access and filter the menu can go here
|
||||
// For example:
|
||||
// const filteredItems = computed(() => {
|
||||
// return navItemsStore.navItems.filter(item => userHasAccess(item.path));
|
||||
// });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Global styles for layout */
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
// export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// console.log('🛡️ Auth middleware triggered for:', to.path)
|
||||
|
||||
// // Skip middleware on server-side during build/generation
|
||||
// if (process.server && process.env.NODE_ENV === 'development') {
|
||||
// console.log('⏭️ Skipping auth check on server-side during development')
|
||||
// return
|
||||
// }
|
||||
|
||||
// // Allow the login page to handle its own checks
|
||||
// if (to.path === '/LoginPage') {
|
||||
// console.log('⏭️ Allowing access to LoginPage')
|
||||
// return
|
||||
// }
|
||||
|
||||
// // This is the crucial change: check for the authentication signal
|
||||
// const isAuthRedirect = to.query.authenticated === 'true';
|
||||
|
||||
// // If this is a redirect from a successful login, we need to let the route load
|
||||
// if (isAuthRedirect) {
|
||||
// console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
|
||||
// // We navigate to a clean URL to remove the query parameter
|
||||
// return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||
// }
|
||||
|
||||
// try {
|
||||
// console.log('🔍 Checking authentication status...')
|
||||
|
||||
// const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null)
|
||||
|
||||
// if (session && session.user) {
|
||||
// console.log('✅ User is authenticated:', session.user.name || session.user.email)
|
||||
// return
|
||||
// } else {
|
||||
// console.log('❌ No valid session found, redirecting to login')
|
||||
// return navigateTo('/LoginPage')
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('❌ Auth middleware error:', error)
|
||||
// console.log('🔄 Redirecting to login due to error')
|
||||
// return navigateTo('/LoginPage')
|
||||
// }
|
||||
// })
|
||||
|
||||
|
||||
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
|
||||
// Define the shape of the user object returned by your authentication API.
|
||||
// This provides type safety for session.user.
|
||||
interface User {
|
||||
name?: string | null;
|
||||
email: string;
|
||||
// Add other properties from your user object as needed.
|
||||
}
|
||||
|
||||
// Define the shape of the full session object returned by the API.
|
||||
interface Session {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
|
||||
console.log('🛡️ Auth middleware triggered for:', to.path);
|
||||
|
||||
// Skip middleware on server-side during development build/generation
|
||||
if (process.server && process.env.NODE_ENV === 'development') {
|
||||
console.log('⏭️ Skipping auth check on server-side during development');
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow the login page to handle its own checks without redirection loops
|
||||
if (to.path === '/LoginPage') {
|
||||
console.log('⏭️ Allowing access to LoginPage');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for the authentication signal from a successful login redirect
|
||||
const isAuthRedirect: boolean = to.query.authenticated === 'true';
|
||||
|
||||
// If this is a redirect from a successful login, allow the route to load.
|
||||
// We navigate to a clean URL to remove the query parameter.
|
||||
if (isAuthRedirect) {
|
||||
console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
|
||||
return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔍 Checking authentication status...');
|
||||
|
||||
// Use the defined Session interface to type the fetch response
|
||||
const session: Session | null = await $fetch<Session>('/api/auth/session').catch(() => null);
|
||||
|
||||
// Check if a valid session and user exist using optional chaining
|
||||
if (session?.user) {
|
||||
console.log('✅ User is authenticated:', session.user.name || session.user.email);
|
||||
return;
|
||||
} else {
|
||||
console.log('❌ No valid session found, redirecting to login');
|
||||
return navigateTo('/LoginPage');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Auth middleware error:', error);
|
||||
console.log('🔄 Redirecting to login due to error');
|
||||
return navigateTo('/LoginPage');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// middleware/guest.ts
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
console.log('👋 Guest middleware triggered for:', to.path);
|
||||
|
||||
// Skip middleware on server-side during build/generation
|
||||
if (process.server && process.env.NODE_ENV === 'development') {
|
||||
console.log('⏭️ Skipping guest check on server-side during development');
|
||||
return;
|
||||
}
|
||||
|
||||
const isAuthRedirect = to.query.authenticated === 'true';
|
||||
const isLogoutSuccess = to.query.logout === 'success';
|
||||
|
||||
// If this is a logout success, allow access to login page
|
||||
if (isLogoutSuccess) {
|
||||
console.log('✅ Logout success detected, allowing access to login page');
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is a redirect from a successful login, we need to wait
|
||||
if (isAuthRedirect) {
|
||||
console.log('⏳ Client-side is processing a new login session, waiting for session to be established...');
|
||||
// We navigate to a clean URL to remove the query parameter from the user's view
|
||||
return navigateTo({ path: to.path, query: {} }, { replace: true });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔍 Checking if user is already authenticated...');
|
||||
|
||||
// The $fetch will automatically send the new user_session cookie
|
||||
const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null);
|
||||
|
||||
if (session && session.user) {
|
||||
console.log('✅ User already authenticated, redirecting to dashboard');
|
||||
return navigateTo('/dashboard');
|
||||
} else {
|
||||
console.log('ℹ️ No session found, staying on login page');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('ℹ️ Auth check failed (expected for login), staying on login page');
|
||||
return;
|
||||
}
|
||||
});
|
||||
+21
-3
@@ -1,4 +1,6 @@
|
||||
// nuxt.config.ts
|
||||
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-05-15',
|
||||
devtools: { enabled: true },
|
||||
@@ -12,6 +14,8 @@ export default defineNuxtConfig({
|
||||
'@nuxt/scripts',
|
||||
'@nuxt/test-utils',
|
||||
'@nuxt/ui',
|
||||
'@pinia/nuxt',
|
||||
// Remove '@sidebase/nuxt-auth' completely
|
||||
(_options, nuxt) => {
|
||||
nuxt.hooks.hook('vite:extendConfig', (config) => {
|
||||
// @ts-expect-error
|
||||
@@ -19,11 +23,25 @@ export default defineNuxtConfig({
|
||||
})
|
||||
},
|
||||
],
|
||||
|
||||
// Remove the auth configuration completely
|
||||
// auth: { ... } <- Remove this entire block
|
||||
|
||||
runtimeConfig: {
|
||||
authSecret: process.env.NUXT_AUTH_SECRET,
|
||||
keycloakClientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
|
||||
public: {
|
||||
authUrl: process.env.AUTH_ORIGIN || 'http://localhost:3001' || 'http://localhost:3000'
|
||||
}
|
||||
},
|
||||
|
||||
build: {
|
||||
transpile: ['vuetify'] // Important for Nuxt 3 with Vuetify
|
||||
transpile: ['vuetify']
|
||||
},
|
||||
css: [
|
||||
'vuetify/lib/styles/main.sass', // Or 'vuetify/styles' depending on version
|
||||
'vuetify/lib/styles/main.sass',
|
||||
'@mdi/font/css/materialdesignicons.min.css',
|
||||
],
|
||||
vite: {
|
||||
@@ -31,7 +49,7 @@ export default defineNuxtConfig({
|
||||
noExternal: ['vuetify']
|
||||
},
|
||||
plugins: [
|
||||
vuetify({ autoImport: true }) // If using vite-plugin-vuetify
|
||||
vuetify({ autoImport: true })
|
||||
]
|
||||
}
|
||||
})
|
||||
Generated
+2309
-1906
File diff suppressed because it is too large
Load diff
+7
-1
@@ -11,18 +11,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@nuxt/content": "^3.6.3",
|
||||
"@nuxt/content": "^2.7.2",
|
||||
"@nuxt/eslint": "^1.7.1",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@nuxt/scripts": "^0.11.10",
|
||||
"@nuxt/test-utils": "^3.19.2",
|
||||
"@nuxt/ui": "^3.3.0",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@unhead/vue": "^2.0.13",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"eslint": "^9.32.0",
|
||||
"nuxt": "^3.17.7",
|
||||
"pinia": "^3.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-draggable-next": "^2.3.0",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
<template>
|
||||
<v-container fluid class="bg-grey-lighten-4 pa-4">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Admin Anjungan</h1>
|
||||
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-card flat>
|
||||
<v-card-text>
|
||||
<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" text-color="white">
|
||||
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="#ff9248" style="color: white"
|
||||
>Pendaftaran Online</v-btn
|
||||
>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider :thickness="5" color="deep-orange-darken-4"></v-divider>
|
||||
|
||||
<TabelData
|
||||
:headers="lateHeaders"
|
||||
:items="lateVisitors"
|
||||
title="DATA PENGUNJUNG TERLAMBAT"
|
||||
/>
|
||||
|
||||
<v-divider :thickness="5" color="deep-orange-darken-4"></v-divider>
|
||||
|
||||
<TabelData
|
||||
:headers="mainHeaders"
|
||||
:items="mainPatients"
|
||||
title="DATA PENGUNJUNG"
|
||||
>
|
||||
<template v-slot:actions="{ item }">
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn
|
||||
small
|
||||
color="#ff9248"
|
||||
class="d-flex flex-row"
|
||||
variant="flat"
|
||||
style="color: white"
|
||||
>PASIEN</v-btn
|
||||
>
|
||||
<v-btn
|
||||
small
|
||||
color="grey-lighten-3"
|
||||
class="d-flex flex-row"
|
||||
variant="flat"
|
||||
>PENGANTAR</v-btn
|
||||
>
|
||||
<v-btn small color="info" class="d-flex flex-row" variant="flat"
|
||||
>ByPass</v-btn
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</TabelData>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import TabelData from "../components/TabelData.vue"; // Pastikan path-nya benar
|
||||
|
||||
// Ini adalah data yang akan menjadi "single source of truth"
|
||||
// untuk tabel Anda. Data ini dikirim sebagai props ke komponen anak.
|
||||
|
||||
const mainHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
{ title: "Tgl Daftar", value: "tglDaftar", sortable: true },
|
||||
{ title: "RM", value: "rm", sortable: true },
|
||||
{ title: "Barcode", value: "barcode", sortable: true },
|
||||
{ title: "No Antrian", value: "noAntrian", sortable: true },
|
||||
{ title: "No Klinik", value: "noKlinik", sortable: true },
|
||||
{ title: "Shift", value: "shift", sortable: true },
|
||||
{ title: "Klinik", value: "klinik", sortable: true },
|
||||
{ title: "Pembayaran", value: "pembayaran", sortable: true },
|
||||
{ title: "Masuk", value: "masuk", sortable: true },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const mainPatients = ref([
|
||||
{
|
||||
no: 1,
|
||||
tglDaftar: "12:49",
|
||||
rm: "250811100163",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
noKlinik: "THT",
|
||||
barcode: "2321232",
|
||||
shift: "Shift 1",
|
||||
klinik: "KANDUNGAN",
|
||||
pembayaran: "UMUM",
|
||||
masuk: "TIDAK",
|
||||
status: "current",
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
tglDaftar: "18:23",
|
||||
rm: "42081123200199",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
noKlinik: "THT",
|
||||
barcode: "2321985",
|
||||
shift: "Shift 1",
|
||||
klinik: "DALAM",
|
||||
pembayaran: "UMUM",
|
||||
masuk: "TIDAK",
|
||||
status: "current",
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
tglDaftar: "02:19",
|
||||
rm: "15092710084",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
noKlinik: "THT",
|
||||
barcode: "2321777",
|
||||
shift: "Shift 1",
|
||||
klinik: "ANAK",
|
||||
pembayaran: "UMUM",
|
||||
masuk: "TIDAK",
|
||||
status: "current",
|
||||
},
|
||||
{
|
||||
no: 4,
|
||||
tglDaftar: "10:09",
|
||||
rm: "250254310011",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
noKlinik: "THT",
|
||||
barcode: "2321298",
|
||||
shift: "Shift 1",
|
||||
klinik: "JANTUNG",
|
||||
pembayaran: "UMUM",
|
||||
masuk: "TIDAK",
|
||||
status: "current",
|
||||
},
|
||||
]);
|
||||
|
||||
const lateHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
// Tambahkan headers spesifik untuk tabel ini jika berbeda
|
||||
]);
|
||||
|
||||
const lateVisitors = ref([
|
||||
// Tambahkan data spesifik untuk tabel ini jika ada
|
||||
]);
|
||||
|
||||
// ... Sisa kode lainnya yang tidak terkait dengan tabel ...
|
||||
const items = ref([
|
||||
{ title: "Dashboard", icon: "mdi-view-dashboard", to: "/dashboard" },
|
||||
{
|
||||
title: "Setting",
|
||||
icon: "mdi-cog",
|
||||
children: [
|
||||
{ title: "Hak Akses", to: "/setting/hak-akses" },
|
||||
{ title: "User Login", to: "/setting/user-login" },
|
||||
{ title: "Master Loket", to: "/setting/master-loket" },
|
||||
{ title: "Master Klinik", to: "/setting/master-klinik" },
|
||||
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
|
||||
{ title: "Screen", to: "/setting/screen" },
|
||||
],
|
||||
},
|
||||
{ title: "Loket Admin", icon: "mdi-account-supervisor" },
|
||||
{ title: "Ranap Admin", icon: "mdi-bed" },
|
||||
{ title: "Klinik Admin", icon: "mdi-hospital-box" },
|
||||
{ title: "Klinik Ruang Admin", icon: "mdi-hospital-marker" },
|
||||
{ title: "Anjungan", icon: "mdi-account-box-multiple", to: "/anjungan" },
|
||||
{ title: "Fast Track", icon: "mdi-clock-fast" },
|
||||
{ title: "Data Pasien", icon: "mdi-account-multiple" },
|
||||
{ title: "Screen", icon: "mdi-monitor" },
|
||||
{ title: "List Pasien", icon: "mdi-format-list-bulleted" },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<v-divider class="my-8"></v-divider>
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<!-- Konten utama dibungkus dalam div yang menyesuaikan padding kiri -->
|
||||
<div :style="contentStyle">
|
||||
<v-container fluid>
|
||||
<h1 class="text-h4">Admin Anjungan</h1>
|
||||
<v-card class="pa-5 mb-5" color="white" flat>
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
label="Barcode"
|
||||
placeholder="Masukkan Barcode"
|
||||
outlined
|
||||
dense
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-chip color="#B71C1C" class="text-caption">
|
||||
Tekan Enter. (Barcode depan nomor selalu ada huruf lain, Ex:
|
||||
J20073010005 "Hiraukan huruf 'J' nya")
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<v-btn block color="info">Pendaftaran Online</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<v-divider class="my-5"></v-divider>
|
||||
|
||||
<v-card class="mb-5">
|
||||
<v-toolbar flat color="transparent" dense>
|
||||
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
|
||||
DATA PENGUNJUNG TERLAMBAT
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="searchLate"
|
||||
append-icon="mdi-magnify"
|
||||
label="Search"
|
||||
single-line
|
||||
hide-details
|
||||
dense
|
||||
class="mr-2"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
<v-select
|
||||
:items="[10, 25, 50, 100]"
|
||||
label="Show"
|
||||
dense
|
||||
single-line
|
||||
hide-details
|
||||
class="shrink"
|
||||
variant="outlined"
|
||||
></v-select>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-data-table
|
||||
:headers="lateHeaders"
|
||||
:items="lateVisitors"
|
||||
:search="searchLate"
|
||||
no-data-text="No data available in table"
|
||||
hide-default-footer
|
||||
class="elevation-1"
|
||||
></v-data-table>
|
||||
<div class="d-flex justify-end pt-2">
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="10"
|
||||
:total-visible="5"
|
||||
></v-pagination>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-divider class="my-5"></v-divider>
|
||||
|
||||
<v-card>
|
||||
<v-toolbar flat color="transparent" dense>
|
||||
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
|
||||
DATA PENGUNJUNG
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
append-icon="mdi-magnify"
|
||||
label="Search"
|
||||
single-line
|
||||
hide-details
|
||||
dense
|
||||
class="mr-2"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
<v-select
|
||||
:items="[10, 25, 50, 100]"
|
||||
label="Show"
|
||||
dense
|
||||
single-line
|
||||
hide-details
|
||||
class="shrink"
|
||||
variant="outlined"
|
||||
></v-select>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="visitors"
|
||||
:search="search"
|
||||
no-data-text="No data available in table"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<div class="d-flex flex-column">
|
||||
<v-btn small color="success" class="my-1">Tiket</v-btn>
|
||||
<v-btn small color="primary" class="my-1">Tiket Pengantar</v-btn>
|
||||
<v-btn small color="warning" class="my-1">ByPass</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</div>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
// Definisikan props untuk menerima status 'rail' dari layout induk
|
||||
const props = defineProps({
|
||||
rail: Boolean,
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const search = ref("");
|
||||
const searchLate = ref("");
|
||||
const page = ref(1);
|
||||
|
||||
// Gaya komputasi untuk menyesuaikan padding
|
||||
const contentStyle = computed(() => {
|
||||
return {
|
||||
paddingLeft: props.rail ? '56px' : '64px',
|
||||
transition: 'padding-left 0.3s ease-in-out',
|
||||
};
|
||||
});
|
||||
|
||||
// Table headers for late visitors
|
||||
const lateHeaders = [
|
||||
{ text: 'No', value: 'no' },
|
||||
{ text: 'Barcode', value: 'barcode' },
|
||||
{ text: 'No Rekamedik', value: 'noRekamedik' },
|
||||
{ text: 'No Antrian', value: 'noAntrian' },
|
||||
{ text: 'No Antrian Klinik', value: 'noAntrianKlinik' },
|
||||
{ text: 'Shift', value: 'shift' },
|
||||
{ text: 'Pembayaran', value: 'pembayaran' },
|
||||
{ text: 'Status', value: 'status' },
|
||||
];
|
||||
|
||||
// Table headers for all visitors
|
||||
const headers = [
|
||||
{ text: 'No', value: 'no' },
|
||||
{ text: 'Barcode', value: 'barcode' },
|
||||
{ text: 'No Rekamedik', value: 'noRekamedik' },
|
||||
{ text: 'No Antrian', value: 'noAntrian' },
|
||||
{ text: 'Shift', value: 'shift' },
|
||||
{ text: 'Ket', value: 'ket' },
|
||||
{ text: 'Fast Track', value: 'fastTrack' },
|
||||
{ text: 'Pembayaran', value: 'pembayaran' },
|
||||
{ text: 'Panggil', value: 'panggil' },
|
||||
{ text: 'Aksi', value: 'aksi' },
|
||||
];
|
||||
|
||||
// Mock data for late visitors
|
||||
const lateVisitors = ref([
|
||||
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', noAntrianKlinik: 'K1', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
|
||||
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', noAntrianKlinik: 'K2', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
|
||||
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', noAntrianKlinik: 'K3', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
|
||||
]);
|
||||
|
||||
// Mock data for all visitors
|
||||
const visitors = ref([
|
||||
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', shift: 'Shift 1', ket: '', fastTrack: 'Ya', pembayaran: 'JKN', panggil: 'Ya' },
|
||||
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
|
||||
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
|
||||
]);
|
||||
</script>
|
||||
@@ -1,686 +0,0 @@
|
||||
<!-- pages/Anjungan.vue -->
|
||||
<template>
|
||||
<div class="anjungan-container">
|
||||
<!-- Header Section -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-hospital-building</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Anjungan RSSA</h1>
|
||||
<p class="page-subtitle">Pilih Klinik untuk Pendaftaran</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-chip color="white" variant="flat" class="instruction-chip">
|
||||
<v-icon start size="16">mdi-information</v-icon>
|
||||
Hijau: Tersedia | Merah: Tutup/Penuh
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Section -->
|
||||
<!-- <v-card class="controls-card mb-4" elevation="2">
|
||||
<v-card-text class="py-3">
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="6">
|
||||
<div class="d-flex align-center flex-wrap gap-3">
|
||||
<v-select
|
||||
v-model="selectedStatus"
|
||||
:items="statusOptions"
|
||||
label="Filter Status"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
style="min-width: 160px;"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="Cari Klinik"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
clearable
|
||||
style="min-width: 200px;"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<div class="d-flex justify-end align-center flex-wrap gap-2">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="refreshData"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
>
|
||||
Refresh
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card> -->
|
||||
|
||||
<!-- Clinic Cards -->
|
||||
<v-card elevation="2" class="main-content-card">
|
||||
<!-- <v-card-title class="d-flex align-center pa-4 bg-grey-lighten-4">
|
||||
<v-icon class="mr-2">mdi-hospital-marker</v-icon>
|
||||
<span>Daftar Klinik - {{ filteredClinics.length }} dari {{ totalClinics }} klinik</span>
|
||||
</v-card-title> -->
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="clinic in filteredClinics"
|
||||
:key="clinic.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
class="pa-2"
|
||||
>
|
||||
<v-card
|
||||
:class="{
|
||||
'clinic-card': true,
|
||||
'clinic-available': clinic.available,
|
||||
'clinic-closed': !clinic.available
|
||||
}"
|
||||
:disabled="!clinic.available"
|
||||
@click="selectClinic(clinic)"
|
||||
elevation="2"
|
||||
>
|
||||
<v-card-text class="text-center pa-4">
|
||||
<!-- Status Chip -->
|
||||
<div class="mb-3">
|
||||
<v-chip
|
||||
:color="clinic.available ? 'success' : 'error'"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
{{ clinic.available ? 'TERSEDIA' : 'TUTUP' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="clinic-icon-wrapper mb-3">
|
||||
<v-icon
|
||||
:icon="clinic.icon"
|
||||
size="40"
|
||||
:color="clinic.available ? 'success' : 'error'"
|
||||
></v-icon>
|
||||
</div>
|
||||
|
||||
<!-- Clinic Name -->
|
||||
<h3 class="text-h6 font-weight-bold mb-2">
|
||||
{{ clinic.name }}
|
||||
</h3>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<p v-if="clinic.subtitle" class="text-caption text-grey-darken-1 mb-2">
|
||||
{{ clinic.subtitle }}
|
||||
</p>
|
||||
|
||||
<!-- Shift Info -->
|
||||
<div class="shift-info">
|
||||
<v-chip
|
||||
size="small"
|
||||
:color="clinic.available ? 'info' : 'error'"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
>
|
||||
{{ clinic.shift }}
|
||||
</v-chip>
|
||||
<br>
|
||||
<span v-if="clinic.schedule" class="text-caption text-grey-darken-1">
|
||||
{{ clinic.schedule }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<div class="mt-3">
|
||||
<v-btn
|
||||
v-if="clinic.available"
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="small"
|
||||
block
|
||||
>
|
||||
Pilih Klinik
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
color="error"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled
|
||||
block
|
||||
>
|
||||
Tidak Tersedia
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredClinics.length === 0" class="text-center py-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-hospital-marker-outline</v-icon>
|
||||
<h3 class="text-h6 mt-4 text-grey-darken-1">Tidak ada klinik yang sesuai filter</h3>
|
||||
<p class="text-body-2 text-grey-darken-1">Coba ubah filter pencarian Anda</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Selection Dialog -->
|
||||
<v-dialog v-model="showDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center bg-primary text-white">
|
||||
<v-icon class="mr-2">mdi-check-circle</v-icon>
|
||||
Konfirmasi Pilihan
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6" v-if="selectedClinic">
|
||||
<div class="text-center">
|
||||
<div class="mb-3">
|
||||
<v-icon :icon="selectedClinic.icon" size="48" color="primary"></v-icon>
|
||||
</div>
|
||||
<h3 class="text-h5 font-weight-bold mb-2">{{ selectedClinic.name }}</h3>
|
||||
<p v-if="selectedClinic.subtitle" class="text-body-1 text-grey-darken-1 mb-3">
|
||||
{{ selectedClinic.subtitle }}
|
||||
</p>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<div class="text-left">
|
||||
<p><strong>Shift:</strong> {{ selectedClinic.shift }}</p>
|
||||
<p v-if="selectedClinic.schedule"><strong>Jadwal:</strong> {{ selectedClinic.schedule }}</p>
|
||||
<p><strong>Status:</strong> <span class="text-success">Tersedia</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn @click="showDialog = false" variant="text">
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="proceedToRegistration"
|
||||
variant="flat"
|
||||
>
|
||||
Lanjutkan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
:color="snackbarColor"
|
||||
:timeout="3000"
|
||||
location="top right"
|
||||
>
|
||||
{{ snackbarText }}
|
||||
|
||||
<template v-slot:actions>
|
||||
<v-btn icon @click="snackbar = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
definePageMeta({
|
||||
layout: false, // Disables the layout for this specific page
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const loading = ref(false)
|
||||
const selectedStatus = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const showDialog = ref(false)
|
||||
const selectedClinic = ref(null)
|
||||
const snackbar = ref(false)
|
||||
const snackbarText = ref('')
|
||||
const snackbarColor = ref('success')
|
||||
|
||||
// Options
|
||||
const statusOptions = ['Tersedia', 'Tutup']
|
||||
|
||||
// Clinic data
|
||||
const clinics = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'ANAK',
|
||||
subtitle: '',
|
||||
icon: 'mdi-baby-face',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'ANESTESI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-sleep',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'BEDAH',
|
||||
subtitle: '',
|
||||
icon: 'mdi-medical-bag',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'GERIATRI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-account-supervisor',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'GIGI DAN MULUT',
|
||||
subtitle: 'GIGI DAN MULUT',
|
||||
icon: 'mdi-tooth',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'GIZI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-food-apple',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'HOM',
|
||||
subtitle: 'HEMATO ONKOLOGI MEDIS',
|
||||
icon: 'mdi-water',
|
||||
shift: 'TUTUP',
|
||||
schedule: '',
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'IPD',
|
||||
subtitle: 'PENYAKIT DALAM',
|
||||
icon: 'mdi-hospital',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'JANTUNG',
|
||||
subtitle: 'CARDIOLOGI',
|
||||
icon: 'mdi-heart-pulse',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'JIWA',
|
||||
subtitle: '',
|
||||
icon: 'mdi-brain',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'KANDUNGAN',
|
||||
subtitle: '',
|
||||
icon: 'mdi-human-pregnant',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'KEMOTERAPI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-needle',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: 'KOMPLEMENTER',
|
||||
subtitle: 'NYERI',
|
||||
icon: 'mdi-leaf',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: 'KUL KEL',
|
||||
subtitle: 'KULIT KELAMIN',
|
||||
icon: 'mdi-hand-back-right',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
name: 'MATA',
|
||||
subtitle: '',
|
||||
icon: 'mdi-eye',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
name: 'MCU',
|
||||
subtitle: '',
|
||||
icon: 'mdi-clipboard-check',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
name: 'ONKOLOGI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-ribbon',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
name: 'PARU',
|
||||
subtitle: '',
|
||||
icon: 'mdi-lungs',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
name: 'R. TINDAKAN',
|
||||
subtitle: 'EMG, ECG, DLL',
|
||||
icon: 'mdi-monitor-heart-rate',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
name: 'RADIOTERAPI',
|
||||
subtitle: '',
|
||||
icon: 'mdi-radioactive',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
name: 'REHAB MEDIK',
|
||||
subtitle: '',
|
||||
icon: 'mdi-human-cane',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
name: 'SARAF',
|
||||
subtitle: 'NEUROLOGI',
|
||||
icon: 'mdi-head-cog',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
name: 'THT',
|
||||
subtitle: '',
|
||||
icon: 'mdi-ear-hearing',
|
||||
shift: 'SHIFT 1',
|
||||
schedule: 'Mulai Pukul 07:00',
|
||||
available: true,
|
||||
}
|
||||
])
|
||||
|
||||
// Computed properties
|
||||
const filteredClinics = computed(() => {
|
||||
let filtered = clinics.value
|
||||
|
||||
if (selectedStatus.value) {
|
||||
const isAvailable = selectedStatus.value === 'Tersedia'
|
||||
filtered = filtered.filter(clinic => clinic.available === isAvailable)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(clinic =>
|
||||
clinic.name.toLowerCase().includes(query) ||
|
||||
clinic.subtitle.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalClinics = computed(() => clinics.value.length)
|
||||
|
||||
// Methods
|
||||
const showSnackbar = (text, color = 'success') => {
|
||||
snackbarText.value = text
|
||||
snackbarColor.value = color
|
||||
snackbar.value = true
|
||||
}
|
||||
|
||||
const selectClinic = (clinic) => {
|
||||
if (clinic.available) {
|
||||
selectedClinic.value = clinic
|
||||
showDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const proceedToRegistration = () => {
|
||||
showSnackbar(`Mengarahkan ke pendaftaran ${selectedClinic.value.name}...`, 'success')
|
||||
console.log('Proceeding to registration for:', selectedClinic.value.name)
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
showSnackbar('Status klinik berhasil diperbarui', 'success')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.anjungan-container {
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.instruction-chip {
|
||||
font-weight: 500;
|
||||
color: #1976d2 !important;
|
||||
}
|
||||
|
||||
.controls-card,
|
||||
.main-content-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.clinic-card {
|
||||
cursor: pointer;
|
||||
border-radius: 16px !important;
|
||||
height: 280px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.clinic-available {
|
||||
border-left: 6px solid #4CAF50;
|
||||
}
|
||||
|
||||
.clinic-closed {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
border-left: 6px solid #F44336;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.clinic-icon-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.clinic-closed .clinic-icon-wrapper {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
.shift-info {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.anjungan-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.clinic-card {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.clinic-icon-wrapper {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.clinic-card {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,543 +0,0 @@
|
||||
<!-- pages/AntrianKlinik.vue -->
|
||||
<template>
|
||||
<div class="tv-display-container">
|
||||
<!-- Header -->
|
||||
<div class="tv-header">
|
||||
<div class="tv-header-left">
|
||||
<div class="hospital-logo">
|
||||
<v-icon size="64" color="white">mdi-hospital-box</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="hospital-name">ANTRIAN KLINIK</h1>
|
||||
<p class="display-title">RSUD Dr. Saiful Anwar Provinsi Jawa Timur</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tv-header-right">
|
||||
<div class="current-time">{{ currentTime }}</div>
|
||||
<div class="current-date">{{ currentDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Called Queue - Super Prominent -->
|
||||
<div v-if="currentCalledQueue" class="hero-queue">
|
||||
<div class="hero-label">SEDANG DIPANGGIL</div>
|
||||
<div class="hero-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
|
||||
<div class="hero-clinic">{{ currentCalledQueue.klinik }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Clinic Grid - 3x3 Layout -->
|
||||
<div class="clinic-grid">
|
||||
<div
|
||||
v-for="clinic in displayedClinics"
|
||||
:key="clinic.name"
|
||||
class="clinic-card"
|
||||
>
|
||||
<div class="clinic-name">{{ clinic.name }}</div>
|
||||
|
||||
<div class="queue-display">
|
||||
<!-- Show current queue number -->
|
||||
<div v-if="clinic.currentQueue" class="current-queue-large">
|
||||
<div class="queue-label">SEKARANG</div>
|
||||
<div class="queue-number-huge">
|
||||
{{ clinic.currentQueue.noAntrian.split(' |')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show next queues -->
|
||||
<div v-if="clinic.nextQueues.length > 0" class="next-queues">
|
||||
<div class="queue-label-small">SELANJUTNYA</div>
|
||||
<div class="next-queue-numbers">
|
||||
<span
|
||||
v-for="(queue, index) in clinic.nextQueues.slice(0, 3)"
|
||||
:key="queue.no"
|
||||
class="next-number"
|
||||
>
|
||||
{{ queue.noAntrian.split(' |')[0] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!clinic.currentQueue && clinic.nextQueues.length === 0" class="empty-queue">
|
||||
<v-icon size="48" color="grey-lighten-2">mdi-checkbox-blank-circle-outline</v-icon>
|
||||
<div class="empty-text">Tidak Ada Antrian</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue count badge -->
|
||||
<div class="queue-count">
|
||||
<v-icon size="20" class="mr-1">mdi-account-multiple</v-icon>
|
||||
{{ clinic.totalQueues }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div class="tv-footer">
|
||||
<div class="footer-stats">
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">{{ statistics.total }}</div>
|
||||
<div class="stat-label">Total Antrian</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">{{ statistics.waiting }}</div>
|
||||
<div class="stat-label">Menunggu</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">{{ statistics.active }}</div>
|
||||
<div class="stat-label">Sedang Dilayani</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-message">
|
||||
<v-icon size="24" class="mr-2">mdi-information</v-icon>
|
||||
Harap perhatikan nomor antrian Anda di layar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useQueueStore } from '../stores/queueStore'
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
|
||||
// Store
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
// Reactive data
|
||||
const currentTime = ref('')
|
||||
const currentDate = ref('')
|
||||
let timeInterval = null
|
||||
let refreshInterval = null
|
||||
|
||||
// Computed - Get all klinik patients (stage: klinik)
|
||||
const klinikPatients = computed(() => {
|
||||
return queueStore.getPatientsByStage('klinik').value.all
|
||||
})
|
||||
|
||||
// Get clinics with their queues organized
|
||||
const displayedClinics = computed(() => {
|
||||
const allKliniks = queueStore.kliniks.slice(0, 9) // Max 9 clinics for 3x3 grid
|
||||
|
||||
return allKliniks.map(klinik => {
|
||||
const queues = klinikPatients.value
|
||||
.filter(p => p.klinik === klinik.name)
|
||||
.sort((a, b) => {
|
||||
// Sort by status priority then time
|
||||
const statusPriority = {
|
||||
'di-loket': 1,
|
||||
'waiting': 2,
|
||||
'terlambat': 3,
|
||||
'pending': 4
|
||||
}
|
||||
|
||||
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
|
||||
// Sort by time if same status
|
||||
const timeA = a.jamPanggil.split(':').map(Number)
|
||||
const timeB = b.jamPanggil.split(':').map(Number)
|
||||
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
|
||||
})
|
||||
|
||||
// Get current (first di-loket or first waiting)
|
||||
const currentQueue = queues.find(q => q.status === 'di-loket') ||
|
||||
(queues.find(q => q.status === 'waiting') || null)
|
||||
|
||||
// Get next queues (excluding current)
|
||||
const nextQueues = queues.filter(q =>
|
||||
q.no !== currentQueue?.no &&
|
||||
(q.status === 'waiting' || q.status === 'di-loket')
|
||||
)
|
||||
|
||||
return {
|
||||
name: klinik.name,
|
||||
currentQueue: currentQueue,
|
||||
nextQueues: nextQueues,
|
||||
totalQueues: queues.length
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Current queue being called (most recent di-loket)
|
||||
const currentCalledQueue = computed(() => {
|
||||
const calledQueues = klinikPatients.value
|
||||
.filter(p => p.status === 'di-loket')
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
|
||||
return calledQueues[0] || null
|
||||
})
|
||||
|
||||
// Statistics
|
||||
const statistics = computed(() => {
|
||||
const all = klinikPatients.value
|
||||
return {
|
||||
total: all.length,
|
||||
waiting: all.filter(p => p.status === 'waiting').length,
|
||||
active: all.filter(p => p.status === 'di-loket').length
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
currentDate.value = now.toLocaleDateString('id-ID', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
timeInterval = setInterval(updateTime, 1000)
|
||||
|
||||
// Auto refresh every 5 seconds
|
||||
refreshInterval = setInterval(() => {
|
||||
// Data will auto-update due to reactive store
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
if (refreshInterval) clearInterval(refreshInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tv-display-container {
|
||||
background: linear-gradient(135deg, #e7e7e7 0%, #FFDCAF 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.tv-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #FAFAFA;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 20px 40px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tv-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.hospital-logo {
|
||||
background: #1e3c72;
|
||||
border-radius: 50%;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hospital-name {
|
||||
font-size: 36px;
|
||||
margin: 0;
|
||||
letter-spacing: 2px;
|
||||
color: #1e3c72;
|
||||
line-height: 1;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.display-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #1e3c72;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tv-header-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #1e3c72;
|
||||
text-shadow: 2px 2px 4px rgba(124, 124, 124, 0.3);
|
||||
}
|
||||
|
||||
.current-date {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Hero Queue - Sedang Dipanggil */
|
||||
.hero-queue {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
|
||||
border-radius: 24px;
|
||||
padding: 30px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
box-shadow: 0 12px 48px rgba(255, 107, 107, 0.4);
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-number {
|
||||
font-size: 120px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
margin: 10px 0;
|
||||
text-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.hero-clinic {
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 12px 48px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 16px 64px rgba(255, 107, 107, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Clinic Grid - 3x3 */
|
||||
.clinic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.clinic-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.clinic-name {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #1e3c72;
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 3px solid #2a5298;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.queue-display {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Current Queue Display */
|
||||
.current-queue-large {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.queue-label {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #4caf50;
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.queue-number-huge {
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e3c72;
|
||||
line-height: 1;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Next Queues */
|
||||
.next-queues {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.queue-label-small {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.next-queue-numbers {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.next-number {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-queue {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
margin-top: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Queue Count Badge */
|
||||
.queue-count {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.tv-footer {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 20px 40px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.footer-stats {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
color:#1e3c72;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
margin-top: 5px;
|
||||
color:#1e3c72;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer-message {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.95;
|
||||
color:#1e3c72;
|
||||
}
|
||||
|
||||
/* Responsive for different TV sizes */
|
||||
@media (max-width: 1920px) {
|
||||
.hero-number {
|
||||
font-size: 100px;
|
||||
}
|
||||
|
||||
.queue-number-huge {
|
||||
font-size: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.clinic-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.clinic-card {
|
||||
padding: 15px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.clinic-name {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.queue-number-huge {
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.hero-number {
|
||||
font-size: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for smooth transitions */
|
||||
.clinic-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.queue-number-huge,
|
||||
.hero-number {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load diff
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<v-container fluid class="pa-6">
|
||||
|
||||
<!-- Colored Header with Quota Chip -->
|
||||
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center justify-space-between pa-4">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="48" color="white" class="mr-4">mdi-account-group-outline</v-icon>
|
||||
<h1 class="text-h4 font-weight-bold text-white">Klinik Admin</h1>
|
||||
</div>
|
||||
<v-tooltip text="Jumlah Maksimal Bangku Tersedia" location="bottom">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-chip
|
||||
v-bind="props"
|
||||
class="text-white px-4 py-2"
|
||||
color="green-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-icon start>mdi-chair-rolling</v-icon>
|
||||
Max Quota Bangku 0
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-card>
|
||||
|
||||
<!-- Loket Admin Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Loket Admin
|
||||
<div>
|
||||
<v-btn
|
||||
color="green-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 mr-2 btn-call-group"
|
||||
@click="handleCallClick(1)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-1-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 1 Antrian</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="blue-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 mr-2 btn-call-group"
|
||||
@click="handleCallClick(5)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-5-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 5 Antrian</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="orange-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 mr-2 btn-call-group"
|
||||
@click="handleCallClick(10)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-10-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 10 Antrian</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="red-lighten-1"
|
||||
variant="flat"
|
||||
rounded="xl"
|
||||
class="text-white elevation-4 btn-call-group"
|
||||
@click="handleCallClick(20)"
|
||||
>
|
||||
<v-icon start>mdi-numeric-20-box</v-icon>
|
||||
<span class="d-none d-md-inline">Panggil 20 Antrian</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<!-- Pilihan Show Entries untuk Loket Admin -->
|
||||
<div class="d-flex justify-end mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-subtitle-1">Show Entries:</span>
|
||||
<v-select
|
||||
:items="[10, 25, 50]"
|
||||
v-model="itemsPerPageLoket"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="show-entries-select"
|
||||
></v-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-table class="mt-3 custom-table rounded-lg elevation-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="header in loketHeaders" :key="header.text">
|
||||
{{ header.text }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in paginatedLoketData" :key="index">
|
||||
<td>{{ item.no }}</td>
|
||||
<td>{{ item.barcode }}</td>
|
||||
<td>{{ item.noRekamedik }}</td>
|
||||
<td>{{ item.noAntrian }}</td>
|
||||
<td>{{ item.shift }}</td>
|
||||
<td>{{ item.ket }}</td>
|
||||
<td>{{ item.fastTrack }}</td>
|
||||
<td>{{ item.pembayaran }}</td>
|
||||
<td>
|
||||
<v-btn size="small" color="primary" class="text-white rounded-lg" @click="handlePanggil(item)">
|
||||
<v-icon start>mdi-phone-incoming</v-icon>
|
||||
Panggil
|
||||
</v-btn>
|
||||
</td>
|
||||
<td>
|
||||
<v-btn size="small" color="red-darken-1" class="text-white rounded-lg" @click="handleBatalkan(item)">
|
||||
<v-icon start>mdi-close</v-icon>
|
||||
Batalkan
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||
<span>
|
||||
Menampilkan {{ (currentPageLoket - 1) * itemsPerPageLoket + 1 }} hingga
|
||||
{{ Math.min(currentPageLoket * itemsPerPageLoket, loketData.length) }} dari
|
||||
{{ loketData.length }} entri
|
||||
</span>
|
||||
<div>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPageLoket === 1" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket - 1)">Previous</v-btn>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPageLoket >= totalPagesLoket" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket + 1)">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Data Pengunjung Table -->
|
||||
<v-card class="pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
Data Pengunjung: Loket
|
||||
</v-card-title>
|
||||
|
||||
<!-- Pilihan Show Entries untuk Data Pengunjung -->
|
||||
<div class="d-flex justify-end mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-subtitle-1">Show Entries:</span>
|
||||
<v-select
|
||||
:items="[10, 25, 50]"
|
||||
v-model="itemsPerPagePengunjung"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="show-entries-select"
|
||||
></v-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-table class="mt-3 custom-table rounded-lg elevation-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="header in pengunjungHeaders" :key="header.text">
|
||||
{{ header.text }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in paginatedPengunjungData" :key="index">
|
||||
<td>{{ item.no }}</td>
|
||||
<td>{{ item.barcode }}</td>
|
||||
<td>{{ item.noRekamedik }}</td>
|
||||
<td>{{ item.noAntrian }}</td>
|
||||
<td>{{ item.noAntrianKlinik }}</td>
|
||||
<td>{{ item.shift }}</td>
|
||||
<td>{{ item.pembayaran }}</td>
|
||||
<td>
|
||||
<v-chip :color="item.status === 'Selesai' ? 'green' : 'orange'" size="small">
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||
<span>
|
||||
Menampilkan {{ (currentPagePengunjung - 1) * itemsPerPagePengunjung + 1 }} hingga
|
||||
{{ Math.min(currentPagePengunjung * itemsPerPagePengunjung, pengunjungData.length) }} dari
|
||||
{{ pengunjungData.length }} entri
|
||||
</span>
|
||||
<div>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPagePengunjung === 1" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung - 1)">Previous</v-btn>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPagePengunjung >= totalPagesPengunjung" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung + 1)">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// Generate dummy data
|
||||
const generateDummyData = (count) => {
|
||||
const data = [];
|
||||
const shiftOptions = ['Pagi', 'Siang', 'Sore'];
|
||||
const pembayaranOptions = ['BPJS', 'Umum', 'Asuransi'];
|
||||
const statusOptions = ['Menunggu', 'Selesai', 'Di-cancel'];
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
data.push({
|
||||
no: i,
|
||||
barcode: `B${1000 + i}`,
|
||||
noRekamedik: `RM${100 + i}`,
|
||||
noAntrian: `A${100 + i}`,
|
||||
noAntrianKlinik: `K${100 + i}`,
|
||||
shift: shiftOptions[Math.floor(Math.random() * shiftOptions.length)],
|
||||
ket: "Dummy Data",
|
||||
fastTrack: Math.random() > 0.5 ? "Ya" : "Tidak",
|
||||
pembayaran: pembayaranOptions[Math.floor(Math.random() * pembayaranOptions.length)],
|
||||
status: statusOptions[Math.floor(Math.random() * statusOptions.length)]
|
||||
});
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// State for Loket Admin table
|
||||
const loketData = ref(generateDummyData(50));
|
||||
const currentPageLoket = ref(1);
|
||||
const itemsPerPageLoket = ref(10);
|
||||
|
||||
// State for Data Pengunjung table
|
||||
const pengunjungData = ref(generateDummyData(50));
|
||||
const currentPagePengunjung = ref(1);
|
||||
const itemsPerPagePengunjung = ref(10);
|
||||
|
||||
// Computed properties for Loket Admin table
|
||||
const paginatedLoketData = computed(() => {
|
||||
const start = (currentPageLoket.value - 1) * itemsPerPageLoket.value;
|
||||
const end = start + itemsPerPageLoket.value;
|
||||
return loketData.value.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPagesLoket = computed(() => {
|
||||
return Math.ceil(loketData.value.length / itemsPerPageLoket.value);
|
||||
});
|
||||
|
||||
// Computed properties for Data Pengunjung table
|
||||
const paginatedPengunjungData = computed(() => {
|
||||
const start = (currentPagePengunjung.value - 1) * itemsPerPagePengunjung.value;
|
||||
const end = start + itemsPerPagePengunjung.value;
|
||||
return pengunjungData.value.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPagesPengunjung = computed(() => {
|
||||
return Math.ceil(pengunjungData.value.length / itemsPerPagePengunjung.value);
|
||||
});
|
||||
|
||||
// Method to handle page change for Loket Admin table
|
||||
const handlePageChangeLoket = (page) => {
|
||||
if (page >= 1 && page <= totalPagesLoket.value) {
|
||||
currentPageLoket.value = page;
|
||||
}
|
||||
};
|
||||
|
||||
// Method to handle page change for Data Pengunjung table
|
||||
const handlePageChangePengunjung = (page) => {
|
||||
if (page >= 1 && page <= totalPagesPengunjung.value) {
|
||||
currentPagePengunjung.value = page;
|
||||
}
|
||||
};
|
||||
|
||||
// Methods to handle button clicks (unchanged)
|
||||
const loketHeaders = [
|
||||
{ text: 'No' },
|
||||
{ text: 'Barcode' },
|
||||
{ text: 'No Rekamedik' },
|
||||
{ text: 'No Antrian' },
|
||||
{ text: 'Shift' },
|
||||
{ text: 'Ket' },
|
||||
{ text: 'Fast Track' },
|
||||
{ text: 'Pembayaran' },
|
||||
{ text: 'Panggil' },
|
||||
{ text: 'Aksi' },
|
||||
];
|
||||
|
||||
const pengunjungHeaders = [
|
||||
{ text: 'No' },
|
||||
{ text: 'Barcode' },
|
||||
{ text: 'No Rekamedik' },
|
||||
{ text: 'No Antrian' },
|
||||
{ text: 'No Antrian Klinik' },
|
||||
{ text: 'Shift' },
|
||||
{ text: 'Pembayaran' },
|
||||
{ text: 'Status' },
|
||||
];
|
||||
|
||||
const handleCallClick = (value) => {
|
||||
console.log(`Panggil ${value} antrian diklik!`);
|
||||
};
|
||||
|
||||
const handlePanggil = (item) => {
|
||||
console.log('Panggil pasien:', item);
|
||||
};
|
||||
|
||||
const handleBatalkan = (item) => {
|
||||
console.log('Batalkan pasien:', item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Main container padding */
|
||||
.v-container {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.header-banner {
|
||||
background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* General card styling */
|
||||
.v-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Call buttons group */
|
||||
.btn-call-group {
|
||||
min-width: 50px;
|
||||
height: 40px !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.custom-table {
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.custom-table :deep(th) {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.custom-table :deep(tr:hover) {
|
||||
background-color: #e8eaf6 !important; /* Light blue on hover */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-table :deep(tbody tr:nth-of-type(odd)) {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
margin: 0 4px;
|
||||
background-color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
/* Custom styling for status chip */
|
||||
.v-chip.v-chip--size-small {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.show-entries-select {
|
||||
max-width: 100px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<v-container fluid class="pa-6">
|
||||
|
||||
<!-- Colored Header Banner -->
|
||||
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
|
||||
<v-icon size="48" color="white" class="mr-4">mdi-clipboard-list-outline</v-icon>
|
||||
<h1 class="text-h4 font-weight-bold text-white">Klinik Ruang Admin</h1>
|
||||
</v-card>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card class="pa-6 rounded-xl elevation-2 mb-4">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
GENERATE TIKET
|
||||
</v-card-title>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
label="Masukkan Barcode"
|
||||
variant="solo"
|
||||
density="compact"
|
||||
hide-details
|
||||
flat
|
||||
class="mr-4 barcode-input"
|
||||
v-model="barcodeInput"
|
||||
@keyup.enter="generateTicket"
|
||||
></v-text-field>
|
||||
<v-chip color="#B71C1C" class="text-caption font-weight-bold text-white chip-warning">
|
||||
Tekan Enter: (Apabila barcode depan nomor ada huruf lain, ex: J008730180085 "hiraukan huruf 'J' nya")
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card class="pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
Pasien Klinik Ruang Admin
|
||||
</v-card-title>
|
||||
|
||||
<div class="d-flex justify-space-between align-center my-3">
|
||||
<div class="d-flex align-center">
|
||||
<span>Show</span>
|
||||
<v-select
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
:items="[10, 25, 50, 100]"
|
||||
class="mx-2 select-items"
|
||||
hide-details
|
||||
v-model="itemsPerPage"
|
||||
></v-select>
|
||||
<span>entries</span>
|
||||
</div>
|
||||
<v-text-field
|
||||
label="Search"
|
||||
variant="solo"
|
||||
density="compact"
|
||||
flat
|
||||
hide-details
|
||||
append-inner-icon="mdi-magnify"
|
||||
style="max-width: 200px"
|
||||
v-model="searchQuery"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<v-table class="mt-3 custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="header in klinikRuangAdminHeaders" :key="header.text">
|
||||
{{ header.text }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="paginatedItems.length === 0">
|
||||
<td :colspan="klinikRuangAdminHeaders.length" class="text-center text-grey">
|
||||
Tidak ada data yang tersedia
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="(item, index) in paginatedItems" :key="item.no">
|
||||
<td>{{ (currentPage - 1) * itemsPerPage + index + 1 }}</td>
|
||||
<td>{{ item.barcode }}</td>
|
||||
<td>{{ item.noRekamedik }}</td>
|
||||
<td>{{ item.noAntrian }}</td>
|
||||
<td>{{ item.noAntrianKlinik }}</td>
|
||||
<td>{{ item.noAntrianRuang }}</td>
|
||||
<td>{{ item.shift }}</td>
|
||||
<td>{{ item.pembayaran }}</td>
|
||||
<td>
|
||||
<v-btn color="success" size="small" class="text-white">Action</v-btn>
|
||||
</td>
|
||||
<td>{{ item.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<div class="d-flex justify-space-between align-center mt-3 pa-2">
|
||||
<span>Showing {{ showingFrom }} to {{ showingTo }} of {{ filteredItems.length }} entries</span>
|
||||
<div class="d-flex">
|
||||
<v-btn size="small" variant="flat" :disabled="currentPage === 1" @click="prevPage" class="pagination-btn">Previous</v-btn>
|
||||
<v-btn size="small" variant="flat" :disabled="currentPage === totalPages" @click="nextPage" class="pagination-btn ml-2">Next</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// === Data Dummy untuk Tabel ===
|
||||
const klinikRuangAdminHeaders = [
|
||||
{ text: 'No' },
|
||||
{ text: 'Barcode' },
|
||||
{ text: 'No Rekamedik' },
|
||||
{ text: 'No Antrian' },
|
||||
{ text: 'No Antrian Klinik' },
|
||||
{ text: 'No Antrian Ruang' },
|
||||
{ text: 'Shift' },
|
||||
{ text: 'Pembayaran' },
|
||||
{ text: 'Action' },
|
||||
{ text: 'Status' },
|
||||
];
|
||||
|
||||
const allItems = ref([
|
||||
{ no: 1, barcode: '008730180085', noRekamedik: 'RM001', noAntrian: 'A001', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Selesai' },
|
||||
{ no: 2, barcode: '008730180086', noRekamedik: 'RM002', noAntrian: 'A002', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'BPJS', status: 'Proses' },
|
||||
{ no: 3, barcode: '008730180087', noRekamedik: 'RM003', noAntrian: 'A003', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'Tunai', status: 'Menunggu' },
|
||||
{ no: 4, barcode: '008730180088', noRekamedik: 'RM004', noAntrian: 'A004', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Selesai' },
|
||||
{ no: 5, barcode: '008730180089', noRekamedik: 'RM005', noAntrian: 'A005', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'BPJS', status: 'Proses' },
|
||||
{ no: 6, barcode: '008730180090', noRekamedik: 'RM006', noAntrian: 'A006', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Menunggu' },
|
||||
{ no: 7, barcode: '008730180091', noRekamedik: 'RM007', noAntrian: 'A007', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'BPJS', status: 'Selesai' },
|
||||
{ no: 8, barcode: '008730180092', noRekamedik: 'RM008', noAntrian: 'A008', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'Tunai', status: 'Proses' },
|
||||
{ no: 9, barcode: '008730180093', noRekamedik: 'RM009', noAntrian: 'A009', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Menunggu' },
|
||||
{ no: 10, barcode: '008730180094', noRekamedik: 'RM010', noAntrian: 'A010', noAntrianKlinik: 'K2', noAntrianRuang: 'R2', shift: 'Siang', pembayaran: 'BPJS', status: 'Selesai' },
|
||||
{ no: 11, barcode: '008730180095', noRekamedik: 'RM011', noAntrian: 'A011', noAntrianKlinik: 'K1', noAntrianRuang: 'R1', shift: 'Pagi', pembayaran: 'Tunai', status: 'Proses' },
|
||||
]);
|
||||
|
||||
// === State untuk Paginasi dan Pencarian ===
|
||||
const itemsPerPage = ref(10);
|
||||
const currentPage = ref(1);
|
||||
const searchQuery = ref('');
|
||||
const barcodeInput = ref('');
|
||||
|
||||
// === Computed Properties untuk Filter dan Paginasi ===
|
||||
const filteredItems = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return allItems.value;
|
||||
}
|
||||
const searchLower = searchQuery.value.toLowerCase();
|
||||
return allItems.value.filter(item => {
|
||||
return Object.values(item).some(value =>
|
||||
String(value).toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value;
|
||||
const end = start + itemsPerPage.value;
|
||||
return filteredItems.value.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
|
||||
});
|
||||
|
||||
const showingFrom = computed(() => {
|
||||
if (filteredItems.value.length === 0) return 0;
|
||||
return (currentPage.value - 1) * itemsPerPage.value + 1;
|
||||
});
|
||||
|
||||
const showingTo = computed(() => {
|
||||
const end = currentPage.value * itemsPerPage.value;
|
||||
return Math.min(end, filteredItems.value.length);
|
||||
});
|
||||
|
||||
// === Fungsi untuk Paginasi dan Pencarian ===
|
||||
const prevPage = () => {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const generateTicket = () => {
|
||||
// Logika untuk menambahkan data ke tabel
|
||||
if (barcodeInput.value) {
|
||||
// Hapus karakter non-digit jika ada
|
||||
const cleanedBarcode = barcodeInput.value.replace(/\D/g, '');
|
||||
const newNo = allItems.value.length + 1;
|
||||
const newItem = {
|
||||
no: newNo,
|
||||
barcode: cleanedBarcode,
|
||||
noRekamedik: `RM${String(newNo).padStart(3, '0')}`,
|
||||
noAntrian: `A${String(newNo).padStart(3, '0')}`,
|
||||
noAntrianKlinik: 'K1',
|
||||
noAntrianRuang: 'R1',
|
||||
shift: 'Pagi',
|
||||
pembayaran: 'Tunai',
|
||||
status: 'Menunggu'
|
||||
};
|
||||
allItems.value.unshift(newItem); // Tambahkan item baru di paling depan
|
||||
barcodeInput.value = ''; // Reset input
|
||||
currentPage.value = 1; // Kembali ke halaman pertama setelah menambahkan data
|
||||
}
|
||||
};
|
||||
|
||||
// === Watcher untuk mereset halaman saat filter atau items per page berubah ===
|
||||
watch([searchQuery, itemsPerPage], () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles to make the page more lively */
|
||||
.v-container {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* .header-banner {
|
||||
background: linear-gradient(45deg, #42a5f5, #1565c0); /* Blue gradient */
|
||||
/* color: white;
|
||||
padding: 24px;
|
||||
} */
|
||||
|
||||
.header-banner {
|
||||
background: linear-gradient(45deg, #1A237E, #283593); /* Dark Blue gradient */
|
||||
color: white;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
|
||||
.barcode-input .v-field--variant-solo {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.chip-warning {
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.select-items {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.select-items .v-field--variant-solo {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.custom-table :deep(th) {
|
||||
background-color: #e0e0e0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.custom-table :deep(tr) {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.custom-table :deep(tbody tr:nth-of-type(odd)) {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
margin: 0 4px;
|
||||
background-color: #ffb38a !important;
|
||||
}
|
||||
|
||||
.next-queue-card {
|
||||
background: linear-gradient(135deg, #00A896, #00796B); /* Teal gradient */
|
||||
}
|
||||
|
||||
.current-queue-number {
|
||||
background-color: white;
|
||||
border: 4px solid #00A896;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #00A896 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,635 +0,0 @@
|
||||
<template>
|
||||
<div class="pasien-container">
|
||||
<!-- Header Section -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-account-group</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">List Pasien</h1>
|
||||
<p class="page-subtitle">Senin, 15 September 2025 - Data Master Pasien</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-chip
|
||||
color="success"
|
||||
variant="flat"
|
||||
class="mr-2"
|
||||
>
|
||||
Total {{ pasienData.length }} Pasien
|
||||
</v-chip>
|
||||
<v-chip
|
||||
color="white"
|
||||
variant="flat"
|
||||
class="text-primary"
|
||||
>
|
||||
Status: Aktif
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls
|
||||
<v-card class="filter-controls-card mb-4" elevation="2">
|
||||
<v-card-text class="py-4">
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="8">
|
||||
<div class="d-flex align-center flex-wrap gap-10">
|
||||
<span class="text-subtitle-1 font-weight-medium">Filter Cepat:</span>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="default"
|
||||
class="px-4"
|
||||
@click="filterByStatus('Tunggu Daftar')"
|
||||
>
|
||||
<v-icon start size="16">mdi-clock-outline</v-icon>
|
||||
Tunggu Daftar
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="default"
|
||||
class="px-4"
|
||||
@click="filterByStatus('Selesai')"
|
||||
>
|
||||
<v-icon start size="16">mdi-check-circle</v-icon>
|
||||
Selesai
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="flat"
|
||||
size="default"
|
||||
class="px-4"
|
||||
@click="filterByKlinik('JKN')"
|
||||
>
|
||||
<v-icon start size="16">mdi-card-account-details</v-icon>
|
||||
JKN
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="info"
|
||||
variant="flat"
|
||||
size="default"
|
||||
class="px-4"
|
||||
@click="resetFilter()"
|
||||
>
|
||||
<v-icon start size="16">mdi-refresh</v-icon>
|
||||
Reset
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<div class="d-flex justify-end gap-2">
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
@click="handleExportLaporan"
|
||||
:loading="loading"
|
||||
>
|
||||
<v-icon start>mdi-file-excel</v-icon>
|
||||
Export Laporan
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="handleExportLaporanPerKlinik"
|
||||
:loading="loading"
|
||||
>
|
||||
<v-icon start>mdi-hospital-building</v-icon>
|
||||
Export Per Klinik
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card> -->
|
||||
|
||||
<!-- Main Patients Table -->
|
||||
<v-card class="main-table-card mb-4" elevation="2">
|
||||
<v-card-title class="d-flex align-center justify-space-between pa-6">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="primary" class="mr-2">mdi-table</v-icon>
|
||||
<span class="text-h6 font-weight-bold">DATA PASIEN</span>
|
||||
</div>
|
||||
<v-chip color="info" variant="flat">
|
||||
{{ filteredData.length }} dari {{ pasienData.length }} pasien
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<TabelListPasien
|
||||
:items="filteredData"
|
||||
@search="handleSearch"
|
||||
@export-laporan="handleExportLaporan"
|
||||
@export-laporan-per-klinik="handleExportLaporanPerKlinik"
|
||||
/>
|
||||
</v-card>
|
||||
|
||||
Statistics Cards
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="stats-card" elevation="2">
|
||||
<v-card-text class="text-center pa-4">
|
||||
<v-icon size="40" color="success" class="mb-2">mdi-account-check</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-success">{{ getStatsByStatus('Tunggu Daftar') }}</div>
|
||||
<div class="text-body-2 text-grey-darken-1">Tunggu Daftar</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="stats-card" elevation="2">
|
||||
<v-card-text class="text-center pa-4">
|
||||
<v-icon size="40" color="info" class="mb-2">mdi-barcode</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-info">{{ getStatsByStatus('Barcode') }}</div>
|
||||
<div class="text-body-2 text-grey-darken-1">Barcode</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="stats-card" elevation="2">
|
||||
<v-card-text class="text-center pa-4">
|
||||
<v-icon size="40" color="warning" class="mb-2">mdi-wifi</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-warning">{{ getStatsByKeterangan('Online') }}</div>
|
||||
<div class="text-body-2 text-grey-darken-1">Online</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card class="stats-card" elevation="2">
|
||||
<v-card-text class="text-center pa-4">
|
||||
<v-icon size="40" color="error" class="mb-2">mdi-wifi-off</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-error">{{ getStatsByKeterangan('Offline') }}</div>
|
||||
<div class="text-body-2 text-grey-darken-1">Offline</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<v-overlay v-model="loading" class="align-center justify-center">
|
||||
<v-progress-circular
|
||||
color="primary"
|
||||
indeterminate
|
||||
size="64"
|
||||
/>
|
||||
</v-overlay>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="snackbar.show"
|
||||
:color="snackbar.color"
|
||||
:timeout="3000"
|
||||
location="top right"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
<template v-slot:actions>
|
||||
<v-btn
|
||||
icon
|
||||
@click="snackbar.show = false"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import TabelListPasien from '~/components/TabelListPasien.vue'
|
||||
|
||||
// Meta untuk SEO
|
||||
definePageMeta({
|
||||
title: 'List Pasien',
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
// Reactive states
|
||||
const loading = ref(false)
|
||||
const currentFilter = ref('')
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
// Sample data
|
||||
const pasienData = ref([
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3507264104730004',
|
||||
rm: '11412584',
|
||||
barcode: '250627100001',
|
||||
noAntrian: 'HQ1001',
|
||||
klinik: 'HOM',
|
||||
fullName: 'Binti Almatul',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3504096063630001',
|
||||
rm: '',
|
||||
barcode: '250627100002',
|
||||
noAntrian: 'QB1001',
|
||||
klinik: 'KANDUNGAN',
|
||||
fullName: 'maret kumalal',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3507114102250002',
|
||||
rm: '11555560',
|
||||
barcode: '250627100003',
|
||||
noAntrian: 'QB1002',
|
||||
klinik: 'KANDUNGAN',
|
||||
fullName: 'ayu rafti lelu amanda',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3508185040150002',
|
||||
rm: '11333655',
|
||||
barcode: '250627100004',
|
||||
noAntrian: 'AN1001',
|
||||
klinik: 'ANAK',
|
||||
fullName: 'Erin Wahyuni',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3515085040110004',
|
||||
rm: '11585554',
|
||||
barcode: '250627100005',
|
||||
noAntrian: 'IP1001',
|
||||
klinik: 'IPD',
|
||||
fullName: 'Yohana Karina Pusplta Sari',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '3506246105750002',
|
||||
rm: '11527608',
|
||||
barcode: '250627100006',
|
||||
noAntrian: 'IP1001',
|
||||
klinik: 'IPD',
|
||||
fullName: 'Elok Suharsti',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Online',
|
||||
status: 'Tunggu Daftar'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '',
|
||||
rm: '',
|
||||
barcode: '250627100007',
|
||||
noAntrian: 'HQ1002',
|
||||
klinik: 'HOM',
|
||||
fullName: '',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Offline',
|
||||
status: 'Barcode'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '',
|
||||
rm: '',
|
||||
barcode: '250627100008',
|
||||
noAntrian: 'IP1002',
|
||||
klinik: 'IPD',
|
||||
fullName: '',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Offline',
|
||||
status: 'Barcode'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '',
|
||||
rm: '',
|
||||
barcode: '250627100009',
|
||||
noAntrian: 'IP1001',
|
||||
klinik: 'IPD',
|
||||
fullName: '',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Offline',
|
||||
status: 'Barcode'
|
||||
},
|
||||
{
|
||||
tglPeriksa: '27/08/2025',
|
||||
nik: '',
|
||||
rm: '',
|
||||
barcode: '250627100010',
|
||||
noAntrian: 'IP1001',
|
||||
klinik: 'IPD',
|
||||
fullName: '',
|
||||
shift: 'Shift 1',
|
||||
pembayaran: 'JKN',
|
||||
keterangan: 'Offline',
|
||||
status: 'Barcode'
|
||||
}
|
||||
])
|
||||
|
||||
// Computed properties
|
||||
const filteredData = computed(() => {
|
||||
if (!currentFilter.value) {
|
||||
return pasienData.value
|
||||
}
|
||||
|
||||
return pasienData.value.filter(item => {
|
||||
return item.status === currentFilter.value ||
|
||||
item.pembayaran === currentFilter.value ||
|
||||
item.keterangan === currentFilter.value
|
||||
})
|
||||
})
|
||||
|
||||
// Methods
|
||||
const showSnackbar = (message, color = 'success') => {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message,
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
const filterByStatus = (status) => {
|
||||
currentFilter.value = status
|
||||
showSnackbar(`Filter diterapkan: ${status}`, 'info')
|
||||
}
|
||||
|
||||
const filterByKlinik = (pembayaran) => {
|
||||
currentFilter.value = pembayaran
|
||||
showSnackbar(`Filter diterapkan: ${pembayaran}`, 'info')
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
currentFilter.value = ''
|
||||
showSnackbar('Filter direset', 'success')
|
||||
}
|
||||
|
||||
const getStatsByStatus = (status) => {
|
||||
return pasienData.value.filter(item => item.status === status).length
|
||||
}
|
||||
|
||||
const getStatsByKeterangan = (keterangan) => {
|
||||
return pasienData.value.filter(item => item.keterangan === keterangan).length
|
||||
}
|
||||
|
||||
const handleSearch = async (filters) => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
console.log('Search filters:', filters)
|
||||
showSnackbar('Data berhasil difilter', 'success')
|
||||
} catch (error) {
|
||||
console.error('Error searching data:', error)
|
||||
showSnackbar('Gagal memfilter data', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportLaporan = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
console.log('Exporting laporan pasien...')
|
||||
showSnackbar('Laporan pasien berhasil diexport', 'success')
|
||||
} catch (error) {
|
||||
console.error('Error exporting laporan:', error)
|
||||
showSnackbar('Gagal export laporan pasien', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportLaporanPerKlinik = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
console.log('Exporting laporan pasien per klinik...')
|
||||
showSnackbar('Laporan pasien per klinik berhasil diexport', 'success')
|
||||
} catch (error) {
|
||||
console.error('Error exporting laporan per klinik:', error)
|
||||
showSnackbar('Gagal export laporan pasien per klinik', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPasienData = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
console.log('Patient data loaded successfully')
|
||||
} catch (error) {
|
||||
console.error('Error fetching patient data:', error)
|
||||
showSnackbar('Gagal memuat data pasien', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchPasienData()
|
||||
})
|
||||
|
||||
// Head untuk SEO
|
||||
useHead({
|
||||
title: 'List Pasien - Antrean RSSA',
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Daftar master data seluruh pasien rumah sakit'
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pasien-container {
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* .filter-controls-card, */
|
||||
.main-table-card,
|
||||
.stats-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Enhanced table styling */
|
||||
.main-table-card :deep(.v-data-table th) {
|
||||
background: #fafbfc;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.main-table-card :deep(.v-data-table tbody tr:hover) {
|
||||
background: #f8fafc !important;
|
||||
}
|
||||
|
||||
.main-table-card :deep(.v-data-table tbody td) {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.v-btn {
|
||||
text-transform: none !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v-btn--size-default {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pasien-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* .filter-controls-card .d-flex {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
} */
|
||||
|
||||
.filter-controls-card .v-col:last-child .d-flex {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.filter-buttons-container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
margin-right: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-right .v-chip {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,868 @@
|
||||
<!-- // Pages/LoginPage.vue -->
|
||||
|
||||
<template>
|
||||
<v-container fluid fill-height class="login-background">
|
||||
|
||||
<!-- Navigation Bar -->
|
||||
<v-app-bar class="navbar" flat>
|
||||
<v-toolbar-title class="brand-logo">
|
||||
<v-icon class="mr-2" color="white">mdi-hospital-building</v-icon>
|
||||
<span class="font-weight-bold text-blue-darken-3">ANTREAN</span> <span class="font-weight-bold" >RSSA</span>
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<!-- Navigation with Dropdown Menus -->
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="white"
|
||||
class="nav-link"
|
||||
v-bind="props"
|
||||
>
|
||||
Tentang
|
||||
<v-icon right small>mdi-chevron-down</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list class="nav-dropdown">
|
||||
<v-list-item class="dropdown-item">
|
||||
<v-icon class="mr-3">mdi-hospital-building</v-icon>
|
||||
<v-list-item-title>Profil Rumah Sakit</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="white"
|
||||
class="nav-link"
|
||||
v-bind="props"
|
||||
>
|
||||
Kontak
|
||||
<v-icon right small>mdi-chevron-down</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list class="nav-dropdown">
|
||||
<v-list-item class="dropdown-item">
|
||||
<v-icon class="mr-3">mdi-phone</v-icon>
|
||||
<v-list-item-title>Hubungi Kami</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item class="dropdown-item">
|
||||
<v-icon class="mr-3">mdi-map-marker</v-icon>
|
||||
<v-list-item-title>Alamat & Lokasi</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item class="dropdown-item">
|
||||
<v-icon class="mr-3">mdi-email</v-icon>
|
||||
<v-list-item-title>Email</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item class="dropdown-item">
|
||||
<v-icon class="mr-3">mdi-help-circle</v-icon>
|
||||
<v-list-item-title>Bantuan</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-btn icon color="white" class="ml-4">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
</v-app-bar>
|
||||
|
||||
<!-- Floating Medical Icons Background -->
|
||||
<div class="floating-medical-icon icon-1">
|
||||
<v-icon size="144">mdi-heart-pulse</v-icon>
|
||||
</div>
|
||||
<div class="floating-medical-icon icon-2">
|
||||
<v-icon size="108">mdi-medical-bag</v-icon>
|
||||
</div>
|
||||
<div class="floating-medical-icon icon-3">
|
||||
<v-icon size="126">mdi-stethoscope</v-icon>
|
||||
</div>
|
||||
<div class="floating-medical-icon icon-4">
|
||||
<v-icon size="120">mdi-hospital-box</v-icon>
|
||||
</div>
|
||||
<div class="floating-medical-icon icon-5">
|
||||
<v-icon size="96">mdi-pill</v-icon>
|
||||
</div>
|
||||
<div class="floating-medical-icon icon-6">
|
||||
<v-icon size="114">mdi-bandage</v-icon>
|
||||
</div>
|
||||
|
||||
<v-row class="fill-height align-center justify-center">
|
||||
<!-- Left Content -->
|
||||
<v-col cols="12" md="6" class="text-section">
|
||||
<div class="hero-content">
|
||||
<div class="logo-section mb-6">
|
||||
<img
|
||||
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
|
||||
alt="Logo Rumah Sakit"
|
||||
class="mt-3 hospital-logo"
|
||||
/>
|
||||
<h1 class="hero-title">Sistem Terbaik</h1>
|
||||
<h1 class="hero-title">Untuk Pelayanan</h1>
|
||||
<h1 class="hero-title">Kesehatan</h1>
|
||||
</div>
|
||||
|
||||
<p class="hero-description">
|
||||
Tingkatkan efisiensi layanan rumah sakit dengan sistem antrean RSSA yang canggih dan intuitif.
|
||||
Dirancang dengan kesederhanaan, keamanan, dan kecepatan, memastikan perjalanan
|
||||
Anda ke platform kami semudah mungkin. Mari kita buat inovasi menjadi sederhana!
|
||||
</p>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Right Login Card -->
|
||||
<v-col cols="12" md="6" class="d-flex justify-center">
|
||||
<v-card class="login-card white-card rounded-xl pa-8" max-width="450" width="100%">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
|
||||
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
|
||||
</div>
|
||||
|
||||
<!-- Logo Section -->
|
||||
<div class="d-flex flex-column align-center text-center mb-6">
|
||||
<span class="text-h5 font-weight-bold app-title-dark text-blue-darken-3">Antrean RSSA</span>
|
||||
<img
|
||||
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
|
||||
alt="Logo Rumah Sakit"
|
||||
class="mt-3 hospital-logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-alert v-if="successMessage" type="success" class="mb-4">
|
||||
{{ successMessage }}
|
||||
</v-alert>
|
||||
|
||||
<!-- SSO Info -->
|
||||
<div class="text-center mb-6">
|
||||
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
|
||||
</div>
|
||||
|
||||
<!-- Keycloak Login Button -->
|
||||
<v-btn
|
||||
@click="handleLogin"
|
||||
class="login-btn"
|
||||
block
|
||||
rounded="lg"
|
||||
size="large"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<v-icon left>mdi-shield-key</v-icon>
|
||||
<span class="font-weight-bold">
|
||||
{{ isLoading ? 'Connecting to Keycloak...' : 'Login dengan Keycloak' }}
|
||||
</span>
|
||||
<v-icon right>mdi-arrow-right</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- Registration Section -->
|
||||
<v-divider class="my-6 custom-divider-dark"></v-divider>
|
||||
|
||||
<div class="text-center">
|
||||
<v-btn
|
||||
@click="showRegistrationDialog = true"
|
||||
class="register-btn-dark"
|
||||
variant="outlined"
|
||||
block
|
||||
rounded="lg"
|
||||
size="large"
|
||||
>
|
||||
<v-icon left>mdi-account-plus</v-icon>
|
||||
<span class="font-weight-bold">Daftar Akun Baru</span>
|
||||
</v-btn>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<span class="help-text-dark">
|
||||
Belum memiliki akun?
|
||||
</span>
|
||||
<br>
|
||||
<v-btn
|
||||
@click="showAdminContact = true"
|
||||
variant="text"
|
||||
color="#0053AD"
|
||||
size="small"
|
||||
class="contact-link-dark mt-1"
|
||||
>
|
||||
Hubungi Administrator
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Help -->
|
||||
<div class="text-center mt-4">
|
||||
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
|
||||
</div>
|
||||
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Registration Information Dialog -->
|
||||
<v-dialog v-model="showRegistrationDialog" max-width="500">
|
||||
<v-card class="white-dialog rounded-xl">
|
||||
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
|
||||
<v-icon left color="#0053AD">mdi-account-plus</v-icon>
|
||||
Pendaftaran Akun Baru
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="text-grey-darken-2 pa-6">
|
||||
<div class="text-center mb-4">
|
||||
<v-icon size="64" color="#0053AD" class="mb-4">mdi-information</v-icon>
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-4">
|
||||
Untuk mendaftar akun baru pada sistem Antrean RSSA, silakan ikuti langkah berikut:
|
||||
</p>
|
||||
|
||||
<v-list class="transparent">
|
||||
<v-list-item class="text-grey-darken-2 px-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#0053AD">mdi-numeric-1-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
Hubungi Administrator IT Rumah Sakit
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-grey-darken-2 px-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#0053AD">mdi-numeric-2-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
Siapkan dokumen identitas dan surat penugasan
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-grey-darken-2 px-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#0053AD">mdi-numeric-3-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
Tunggu proses verifikasi dan aktivasi akun
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 bg-grey-lighten-5">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
@click="showRegistrationDialog = false"
|
||||
color="grey"
|
||||
variant="outlined"
|
||||
rounded
|
||||
>
|
||||
Tutup
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="showRegistrationDialog = false; showAdminContact = true"
|
||||
color="#0053AD"
|
||||
rounded
|
||||
>
|
||||
<v-icon left>mdi-phone</v-icon>
|
||||
Hubungi Admin
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Admin Contact Dialog -->
|
||||
<v-dialog v-model="showAdminContact" max-width="500">
|
||||
<v-card class="white-dialog rounded-xl">
|
||||
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
|
||||
<v-icon left color="#0053AD">mdi-account-tie</v-icon>
|
||||
Kontak Administrator
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="text-grey-darken-2 pa-6">
|
||||
<div class="text-center mb-4">
|
||||
<v-icon size="64" color="#0053AD" class="mb-4">mdi-phone-settings</v-icon>
|
||||
</div>
|
||||
|
||||
<v-list class="transparent">
|
||||
<v-list-item class="text-grey-darken-2 px-0 mb-2">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#0053AD">mdi-office-building</v-icon>
|
||||
</template>
|
||||
<div>
|
||||
<v-list-item-title class="text-grey-darken-2 font-weight-bold">
|
||||
IT Support RSSA
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-grey-darken-1">
|
||||
Bagian Teknologi Informasi
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-grey-darken-2 px-0 mb-2">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#0053AD">mdi-phone</v-icon>
|
||||
</template>
|
||||
<div>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
(0341) 343343 ext. 1234
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-grey-darken-1">
|
||||
Telepon Internal
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-grey-darken-2 px-0 mb-2">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#0053AD">mdi-email</v-icon>
|
||||
</template>
|
||||
<div>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
it-support@rssa.malang.go.id
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-grey-darken-1">
|
||||
Email Resmi
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-grey-darken-2 px-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="#0053AD">mdi-clock</v-icon>
|
||||
</template>
|
||||
<div>
|
||||
<v-list-item-title class="text-grey-darken-2">
|
||||
Senin - Jumat: 07:00 - 15:00
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-grey-darken-1">
|
||||
Jam Operasional
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
color="blue"
|
||||
>
|
||||
<div class="text-grey-darken-2">
|
||||
<strong>Catatan:</strong> Pendaftaran akun memerlukan verifikasi dokumen dan dapat memakan waktu 1-2 hari kerja.
|
||||
</div>
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 bg-grey-lighten-5">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
@click="showAdminContact = false"
|
||||
color="grey"
|
||||
variant="outlined"
|
||||
rounded
|
||||
>
|
||||
Tutup
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="copyContactInfo"
|
||||
color="#0053AD"
|
||||
rounded
|
||||
>
|
||||
<v-icon left>mdi-content-copy</v-icon>
|
||||
Salin Info
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LoginResponse } from '~/types/auth'
|
||||
|
||||
// Use guest middleware to redirect if already authenticated
|
||||
definePageMeta({
|
||||
layout: 'empty',
|
||||
middleware:['auth','guest']
|
||||
})
|
||||
|
||||
// Reactive state
|
||||
const isLoading = ref<boolean>(false)
|
||||
const errorMessage = ref<string>('')
|
||||
const successMessage = ref<string>('')
|
||||
const showRegistrationDialog = ref<boolean>(false)
|
||||
const showAdminContact = ref<boolean>(false)
|
||||
|
||||
// Check URL parameters for errors
|
||||
const route = useRoute()
|
||||
onMounted(() => {
|
||||
if (route.query.error) {
|
||||
errorMessage.value = decodeURIComponent(route.query.error as string)
|
||||
}
|
||||
})
|
||||
|
||||
// Custom login handler
|
||||
const handleLogin = async (): Promise<void> => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
console.log('Starting login process...')
|
||||
|
||||
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response?.success && response?.data?.authUrl) {
|
||||
console.log('Redirecting to Keycloak...')
|
||||
successMessage.value = 'Redirecting to Keycloak...'
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = response.data!.authUrl
|
||||
}, 500)
|
||||
} else {
|
||||
throw new Error('Failed to get authorization URL')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error)
|
||||
errorMessage.value = `Login failed: ${error.message || 'Please try again.'}`
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Copy contact information to clipboard
|
||||
const copyContactInfo = async (): Promise<void> => {
|
||||
const contactInfo = `
|
||||
IT Support RSSA
|
||||
Telepon: (0341) 343343 ext. 1234
|
||||
Email: [email protected]
|
||||
Jam Operasional: Senin - Jumat, 08:00 - 16:00
|
||||
`.trim()
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(contactInfo)
|
||||
successMessage.value = 'Informasi kontak berhasil disalin!'
|
||||
showAdminContact.value = false
|
||||
|
||||
setTimeout(() => {
|
||||
successMessage.value = ''
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy contact info:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Main Background */
|
||||
.login-background {
|
||||
background: linear-gradient(135deg, #f1b464 0%, #faa22e 25%, #e49458 50%, #e46f30 75%, #e26450 100%);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Navigation Bar */
|
||||
.navbar {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: white !important;
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Floating Medical Icons */
|
||||
.floating-medical-icon {
|
||||
position: absolute;
|
||||
animation: dvdBounce 20s linear infinite;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.floating-medical-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.floating-medical-icon .v-icon {
|
||||
color: rgba(255, 255, 255, 0.15) !important;
|
||||
}
|
||||
|
||||
.icon-1 {
|
||||
top: 0%;
|
||||
left: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 25s;
|
||||
animation-name: dvdBounce1;
|
||||
}
|
||||
|
||||
.icon-1 .v-icon {
|
||||
color: rgba(6, 37, 83, 0.15) !important;
|
||||
}
|
||||
|
||||
.icon-2 {
|
||||
top: 0%;
|
||||
right: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 30s;
|
||||
animation-name: dvdBounce2;
|
||||
}
|
||||
|
||||
.icon-2 .v-icon {
|
||||
color: rgba(15, 180, 70, 0.12) !important;
|
||||
}
|
||||
|
||||
.icon-3 {
|
||||
bottom: 0%;
|
||||
left: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 22s;
|
||||
animation-name: dvdBounce3;
|
||||
}
|
||||
|
||||
.icon-3 .v-icon {
|
||||
color: rgba(23, 178, 206, 0.18) !important;
|
||||
}
|
||||
|
||||
.icon-4 {
|
||||
top: 50%;
|
||||
left: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 28s;
|
||||
animation-name: dvdBounce4;
|
||||
}
|
||||
|
||||
.icon-4 .v-icon {
|
||||
color: rgba(223, 8, 8, 0.14) !important;
|
||||
}
|
||||
|
||||
.icon-5 {
|
||||
bottom: 0%;
|
||||
right: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 26s;
|
||||
animation-name: dvdBounce5;
|
||||
}
|
||||
|
||||
.icon-5 .v-icon {
|
||||
color: rgba(148, 15, 236, 0.16) !important;
|
||||
}
|
||||
|
||||
.icon-6 {
|
||||
top: 25%;
|
||||
left: 0%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 24s;
|
||||
animation-name: dvdBounce6;
|
||||
}
|
||||
|
||||
.icon-6 .v-icon {
|
||||
color: rgba(87, 48, 5, 0.13) !important;
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 1 - Top Left to Bottom Right */
|
||||
@keyframes dvdBounce1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(80vw, 70vh); }
|
||||
50% { transform: translate(20vw, 10vh); }
|
||||
75% { transform: translate(70vw, 80vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 2 - Top Right to Bottom Left */
|
||||
@keyframes dvdBounce2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(-75vw, 60vh); }
|
||||
50% { transform: translate(-30vw, 20vh); }
|
||||
75% { transform: translate(-85vw, 75vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 3 - Bottom Left to Top Right */
|
||||
@keyframes dvdBounce3 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(70vw, -60vh); }
|
||||
50% { transform: translate(40vw, -80vh); }
|
||||
75% { transform: translate(90vw, -30vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 4 - Middle Left across screen */
|
||||
@keyframes dvdBounce4 {
|
||||
0% { transform: translate(0, 0); }
|
||||
16.6% { transform: translate(60vw, -30vh); }
|
||||
33.3% { transform: translate(90vw, 20vh); }
|
||||
50% { transform: translate(50vw, 40vh); }
|
||||
66.6% { transform: translate(10vw, -20vh); }
|
||||
83.3% { transform: translate(80vw, -40vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 5 - Bottom Right to Top Left */
|
||||
@keyframes dvdBounce5 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(-60vw, -70vh); }
|
||||
50% { transform: translate(-90vw, -20vh); }
|
||||
75% { transform: translate(-40vw, -80vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* DVD Bounce Animation 6 - Complex zigzag pattern */
|
||||
@keyframes dvdBounce6 {
|
||||
0% { transform: translate(0, 0); }
|
||||
14.3% { transform: translate(50vw, 30vh); }
|
||||
28.6% { transform: translate(85vw, -20vh); }
|
||||
42.9% { transform: translate(30vw, 60vh); }
|
||||
57.1% { transform: translate(70vw, 10vh); }
|
||||
71.4% { transform: translate(15vw, 70vh); }
|
||||
85.7% { transform: translate(80vw, 40vh); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.text-section {
|
||||
padding-left: 4rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
color: white;
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.5rem;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-top: 2rem;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Login Card - White Background */
|
||||
.login-card {
|
||||
z-index: 3;
|
||||
max-width: 450px;
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
.white-card {
|
||||
background: white !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 20px 50px rgba(0, 0, 0, 0.15),
|
||||
0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* White Dialog Styles */
|
||||
.white-dialog {
|
||||
background: white !important;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Card Header - Dark Text */
|
||||
.welcome-title-dark {
|
||||
color: #37474F;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-instruction-dark {
|
||||
color: #546E7A;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* App Title - Dark */
|
||||
.app-title-dark {
|
||||
color: #0053AD;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.hospital-logo {
|
||||
height: 64px;
|
||||
width: auto;
|
||||
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.sso-text-dark {
|
||||
color: #546E7A;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.login-btn {
|
||||
background: linear-gradient(135deg, #0053AD 0%, #0663C7 50%, #0671E0 100%) !important;
|
||||
color: white !important;
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 8px 25px rgba(0, 83, 173, 0.3),
|
||||
0 4px 12px rgba(0, 83, 173, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
text-transform: none;
|
||||
font-size: 1rem;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
background: linear-gradient(135deg, #004A9B 0%, #0558B0 50%, #0661CA 100%) !important;
|
||||
box-shadow:
|
||||
0 12px 30px rgba(0, 83, 173, 0.4),
|
||||
0 6px 15px rgba(0, 83, 173, 0.3);
|
||||
}
|
||||
|
||||
.register-btn-dark {
|
||||
color: #0053AD !important;
|
||||
border: 2px solid #0053AD !important;
|
||||
background: transparent !important;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: none;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.register-btn-dark:hover {
|
||||
background: rgba(0, 83, 173, 0.05) !important;
|
||||
border-color: #0663C7 !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Custom Divider - Dark */
|
||||
.custom-divider-dark {
|
||||
border-color: rgba(0, 0, 0, 0.12) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Help Text and Links - Dark */
|
||||
.help-text-dark {
|
||||
color: #546E7A;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.contact-link-dark {
|
||||
color: #0053AD !important;
|
||||
text-decoration: underline;
|
||||
text-transform: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.help-link-dark {
|
||||
color: #78909C;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-link-dark:hover {
|
||||
color: #0053AD;
|
||||
}
|
||||
|
||||
/* Transparent Background */
|
||||
.transparent {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Navigation Dropdown Styles */
|
||||
.nav-dropdown {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
min-width: 220px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
color: #FF9B1B;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 8px;
|
||||
margin: 4px 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: linear-gradient(135deg, #FF9B1B 0%, #FF8F00 100%);
|
||||
color: white;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.dropdown-item .v-icon {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover .v-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 960px) {
|
||||
.text-section {
|
||||
padding-left: 2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.text-section {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,754 +0,0 @@
|
||||
<!-- pages/LoketAdmin.vue -->
|
||||
<template>
|
||||
<div class="loket-container">
|
||||
<!-- Header Section -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Loket Admin</h1>
|
||||
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-chip color="success" variant="flat" class="mr-2">
|
||||
Total {{ totalPasien }} Pasien
|
||||
</v-chip>
|
||||
<v-chip color="white" variant="flat" class="text-primary">
|
||||
Max 150 Pasien
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<v-card class="next-patient-card mb-4" elevation="2">
|
||||
<v-card-text class="text-center pa-6">
|
||||
<v-chip color="success" variant="flat" class="mb-3" size="large">
|
||||
<v-icon start>mdi-account-arrow-right</v-icon>
|
||||
PASIEN SELANJUTNYA
|
||||
</v-chip>
|
||||
<div class="text-h4 font-weight-bold mb-2 text-success">
|
||||
{{ nextPatient ? nextPatient.noAntrian.split(" |")[0] : "UM1004" }}
|
||||
</div>
|
||||
<div class="text-body-1 mb-4 text-grey-darken-1">
|
||||
Klik tombol hijau untuk memanggil
|
||||
</div>
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="px-8"
|
||||
@click="callNext"
|
||||
:disabled="!nextPatient"
|
||||
>
|
||||
<v-icon start>mdi-microphone</v-icon>
|
||||
PANGGIL NEXT
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
<!-- Combined Control Section -->
|
||||
<v-row class="mb-4">
|
||||
<!-- Left Side: Call Controls and Current Processing -->
|
||||
<v-col cols="12" lg="6">
|
||||
<!-- Current Patient Processing Card -->
|
||||
<v-card
|
||||
v-if="currentProcessingPatient"
|
||||
class="current-processing-card"
|
||||
elevation="2"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div class="patient-info">
|
||||
<v-chip color="primary" variant="flat" class="mb-2">
|
||||
<v-icon start>mdi-account-clock</v-icon>
|
||||
SEDANG DIPROSES
|
||||
</v-chip>
|
||||
<div class="text-h5 font-weight-bold mb-1">
|
||||
{{ currentProcessingPatient.noAntrian.split(" |")[0] }}
|
||||
</div>
|
||||
<div class="text-subtitle-1 text-grey-darken-1">
|
||||
{{ currentProcessingPatient.barcode }} |
|
||||
{{ currentProcessingPatient.klinik }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
class="mr-2"
|
||||
@click="processPatient(currentProcessingPatient, 'check-in')"
|
||||
>
|
||||
<v-icon start>mdi-check-circle</v-icon>
|
||||
Check In
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="flat"
|
||||
class="mr-2"
|
||||
@click="processPatient(currentProcessingPatient, 'terlambat')"
|
||||
>
|
||||
<v-icon start>mdi-clock-alert</v-icon>
|
||||
Terlambat
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
@click="processPatient(currentProcessingPatient, 'pending')"
|
||||
>
|
||||
<v-icon start>mdi-pause-circle</v-icon>
|
||||
Pending
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Right Side: Patient Queue Info -->
|
||||
<v-col cols="12" lg="6">
|
||||
<!-- Next Patient Card -->
|
||||
<!-- Quota Info Card -->
|
||||
<v-card class="quota-info-card" elevation="2">
|
||||
<v-card-text class="pa-4">
|
||||
<div class="text-center">
|
||||
<div class="text-h6 font-weight-medium mb-3">
|
||||
Panggil Antrean Anjungan
|
||||
</div>
|
||||
<v-row class="mb-3">
|
||||
<v-col cols="6" class="text-center">
|
||||
<div class="text-caption text-grey-darken-1">Kuota</div>
|
||||
<div class="text-h4 font-weight-bold">150</div>
|
||||
</v-col>
|
||||
<v-col cols="6" class="text-center">
|
||||
<div class="text-caption text-grey-darken-1">Tersedia</div>
|
||||
<div class="text-h4 font-weight-bold text-success">
|
||||
{{ 150 - quotaUsed }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div class="text-body-2 text-grey-darken-1 mb-2">
|
||||
Total Quota Terpakai: {{ quotaUsed }}
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="(quotaUsed / 150) * 100"
|
||||
color="success"
|
||||
height="8"
|
||||
rounded
|
||||
></v-progress-linear>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card-text class="call-controls-card align-center py-4">
|
||||
<div class="text-subtitle-1 font-weight-medium"
|
||||
>Panggil Pasien:</div>
|
||||
<div class="d-flex align-center flex-wrap mb-3">
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="px-6 ma-4"
|
||||
@click="callMultiplePatients(1)"
|
||||
>
|
||||
<span class="text-h6 font-weight-bold">1</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="info"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="px-6 ma-4"
|
||||
@click="callMultiplePatients(5)"
|
||||
>
|
||||
<span class="text-h6 font-weight-bold">5</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="px-6 ma-4"
|
||||
@click="callMultiplePatients(10)"
|
||||
>
|
||||
<span class="text-h6 font-weight-bold">10</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="px-6 ma-4"
|
||||
@click="callMultiplePatients(20)"
|
||||
>
|
||||
<span class="text-h6 font-weight-bold">20</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Di Loket Patients Table -->
|
||||
<v-card class="main-table-card mb-4" elevation="2">
|
||||
<TabelData
|
||||
:headers="diLoketHeaders"
|
||||
:items="diLoketPatients"
|
||||
title="DATA PASIEN - DI LOKET"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<div class="d-flex gap-1">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="info"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'aktifkan')"
|
||||
>
|
||||
Aktifkan
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="success"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'proses')"
|
||||
>
|
||||
Proses
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.jamPanggil="{ item }">
|
||||
<span :class="getRowClass(item)">{{ item.jamPanggil }}</span>
|
||||
</template>
|
||||
</TabelData>
|
||||
</v-card>
|
||||
|
||||
<!-- Terlambat Patients Table -->
|
||||
<v-card
|
||||
class="late-table-card mb-4"
|
||||
elevation="2"
|
||||
v-if="terlambatPatients.length > 0"
|
||||
>
|
||||
<TabelData
|
||||
:headers="terlambatHeaders"
|
||||
:items="terlambatPatients"
|
||||
title="INFO PASIEN LAPOR TERLAMBAT"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<div class="d-flex gap-1">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="success"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'aktifkan')"
|
||||
>
|
||||
Aktifkan
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</TabelData>
|
||||
</v-card>
|
||||
|
||||
<!-- Pending Patients Table -->
|
||||
<v-card
|
||||
class="pending-table-card mb-4"
|
||||
elevation="2"
|
||||
v-if="pendingPatients.length > 0"
|
||||
>
|
||||
<TabelData
|
||||
:headers="pendingHeaders"
|
||||
:items="pendingPatients"
|
||||
title="INFO PASIEN PENDING"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<div class="d-flex gap-1">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="success"
|
||||
variant="flat"
|
||||
@click="processPatient(item, 'proses')"
|
||||
>
|
||||
Proses
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</TabelData>
|
||||
</v-card>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
:color="snackbarColor"
|
||||
:timeout="3000"
|
||||
location="top right"
|
||||
>
|
||||
{{ snackbarText }}
|
||||
|
||||
<template v-slot:actions>
|
||||
<v-btn icon @click="snackbar = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import TabelData from "../components/TabelData.vue";
|
||||
|
||||
// Reactive data
|
||||
const snackbar = ref(false);
|
||||
const snackbarText = ref("");
|
||||
const snackbarColor = ref("success");
|
||||
const quotaUsed = ref(5);
|
||||
const currentProcessingPatient = ref(null);
|
||||
|
||||
// Base patient data - semua pasien yang belum dipanggil
|
||||
const allPatients = ref([
|
||||
{
|
||||
no: 1,
|
||||
jamPanggil: "12:49",
|
||||
barcode: "250811100163",
|
||||
noAntrian: "UM1001 | Online - 250811100163",
|
||||
shift: "Shift 1",
|
||||
klinik: "KANDUNGAN",
|
||||
fastTrack: "UMUM",
|
||||
pembayaran: "UMUM",
|
||||
status: "waiting", // waiting, di-loket, terlambat, pending, processed
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
jamPanggil: "10:52",
|
||||
barcode: "250811100155",
|
||||
noAntrian: "UM1002 | Online - 250811100155",
|
||||
shift: "Shift 1",
|
||||
klinik: "IPD",
|
||||
fastTrack: "UMUM",
|
||||
pembayaran: "UMUM",
|
||||
status: "waiting",
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
jamPanggil: "09:30",
|
||||
barcode: "250811100200",
|
||||
noAntrian: "UM1003 | Online - 250811100200",
|
||||
shift: "Shift 1",
|
||||
klinik: "SARAF",
|
||||
fastTrack: "UMUM",
|
||||
pembayaran: "UMUM",
|
||||
status: "waiting",
|
||||
},
|
||||
{
|
||||
no: 4,
|
||||
jamPanggil: "14:15",
|
||||
barcode: "250811100210",
|
||||
noAntrian: "UM1004 | Online - 250811100210",
|
||||
shift: "Shift 1",
|
||||
klinik: "THT",
|
||||
fastTrack: "UMUM",
|
||||
pembayaran: "UMUM",
|
||||
status: "waiting",
|
||||
},
|
||||
...Array.from({ length: 16 }, (_, i) => ({
|
||||
no: i + 5,
|
||||
jamPanggil: `${String(Math.floor(Math.random() * 12) + 1).padStart(2, "0")}:${String(Math.floor(Math.random() * 60)).padStart(2, "0")}`,
|
||||
barcode: `25081110${String(i + 300).padStart(4, "0")}`,
|
||||
noAntrian: `UM100${i + 5} | Online - 25081110${String(i + 300).padStart(4, "0")}`,
|
||||
shift: "Shift 1",
|
||||
klinik: ["KANDUNGAN", "IPD", "THT", "SARAF"][Math.floor(Math.random() * 4)],
|
||||
fastTrack: "UMUM",
|
||||
pembayaran: "UMUM",
|
||||
status: "waiting",
|
||||
})),
|
||||
]);
|
||||
|
||||
// Computed properties for different status tables
|
||||
const diLoketPatients = computed(() =>
|
||||
allPatients.value.filter((patient) => patient.status === "di-loket")
|
||||
);
|
||||
|
||||
const terlambatPatients = computed(() =>
|
||||
allPatients.value.filter((patient) => patient.status === "terlambat")
|
||||
);
|
||||
|
||||
const pendingPatients = computed(() =>
|
||||
allPatients.value.filter((patient) => patient.status === "pending")
|
||||
);
|
||||
|
||||
const nextPatient = computed(() => {
|
||||
return allPatients.value.find((patient) => patient.status === "waiting");
|
||||
});
|
||||
|
||||
const totalPasien = computed(() => allPatients.value.length);
|
||||
|
||||
// Headers for different tables
|
||||
const diLoketHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false, width: "60px" },
|
||||
{ title: "Jam Panggil", value: "jamPanggil", sortable: true, width: "100px" },
|
||||
{ title: "Barcode", value: "barcode", sortable: true, width: "140px" },
|
||||
{ title: "No Antrian", value: "noAntrian", sortable: true, width: "200px" },
|
||||
{ title: "Shift", value: "shift", sortable: true, width: "80px" },
|
||||
{ title: "Klinik", value: "klinik", sortable: true, width: "120px" },
|
||||
{ title: "Fast Track", value: "fastTrack", sortable: true, width: "100px" },
|
||||
{ title: "Pembayaran", value: "pembayaran", sortable: true, width: "100px" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false, width: "200px" },
|
||||
]);
|
||||
|
||||
const terlambatHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false, width: "60px" },
|
||||
{ title: "Barcode", value: "barcode", sortable: true, width: "140px" },
|
||||
{ title: "No Antrian", value: "noAntrian", sortable: true, width: "200px" },
|
||||
{ title: "Shift", value: "shift", sortable: true, width: "80px" },
|
||||
{ title: "Klinik", value: "klinik", sortable: true, width: "120px" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false, width: "100px" },
|
||||
]);
|
||||
|
||||
const pendingHeaders = ref([
|
||||
{ title: "#", value: "no", sortable: false, width: "60px" },
|
||||
{ title: "Barcode", value: "barcode", sortable: true, width: "140px" },
|
||||
{ title: "No Antrian", value: "noAntrian", sortable: true, width: "200px" },
|
||||
{ title: "Shift", value: "shift", sortable: true, width: "80px" },
|
||||
{ title: "Klinik", value: "klinik", sortable: true, width: "120px" },
|
||||
{ title: "Fast Track", value: "fastTrack", sortable: true, width: "100px" },
|
||||
{ title: "Pembayaran", value: "pembayaran", sortable: true, width: "100px" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false, width: "100px" },
|
||||
]);
|
||||
|
||||
// Methods
|
||||
const showSnackbar = (text, color = "success") => {
|
||||
snackbarText.value = text;
|
||||
snackbarColor.value = color;
|
||||
snackbar.value = true;
|
||||
};
|
||||
|
||||
const callMultiplePatients = (count) => {
|
||||
const waitingPatients = allPatients.value.filter(
|
||||
(patient) => patient.status === "waiting"
|
||||
);
|
||||
const patientsToCall = waitingPatients.slice(0, count);
|
||||
|
||||
if (patientsToCall.length === 0) {
|
||||
showSnackbar("Tidak ada pasien yang menunggu", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check quota
|
||||
if (quotaUsed.value + patientsToCall.length > 150) {
|
||||
showSnackbar("Quota tidak mencukupi", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Move patients to "di-loket" status
|
||||
patientsToCall.forEach((patient) => {
|
||||
patient.status = "di-loket";
|
||||
});
|
||||
|
||||
quotaUsed.value += patientsToCall.length;
|
||||
showSnackbar(`Memanggil ${patientsToCall.length} pasien ke loket`, "success");
|
||||
};
|
||||
|
||||
const callNext = () => {
|
||||
if (!nextPatient.value) {
|
||||
showSnackbar("Tidak ada pasien selanjutnya", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (quotaUsed.value >= 150) {
|
||||
showSnackbar("Quota sudah penuh", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Move next patient to processing
|
||||
nextPatient.value.status = "di-loket";
|
||||
currentProcessingPatient.value = nextPatient.value;
|
||||
quotaUsed.value++;
|
||||
|
||||
showSnackbar(
|
||||
`Memanggil pasien ${nextPatient.value.noAntrian.split(" |")[0]}`,
|
||||
"success"
|
||||
);
|
||||
};
|
||||
|
||||
const processPatient = (patient, action) => {
|
||||
const patientCode = patient.noAntrian.split(" |")[0];
|
||||
|
||||
switch (action) {
|
||||
case "check-in":
|
||||
patient.status = "processed";
|
||||
if (currentProcessingPatient.value?.no === patient.no) {
|
||||
currentProcessingPatient.value = null;
|
||||
}
|
||||
showSnackbar(`Pasien ${patientCode} berhasil check in`, "success");
|
||||
break;
|
||||
|
||||
case "terlambat":
|
||||
patient.status = "terlambat";
|
||||
if (currentProcessingPatient.value?.no === patient.no) {
|
||||
currentProcessingPatient.value = null;
|
||||
}
|
||||
showSnackbar(`Pasien ${patientCode} ditandai terlambat`, "warning");
|
||||
break;
|
||||
|
||||
case "pending":
|
||||
patient.status = "pending";
|
||||
if (currentProcessingPatient.value?.no === patient.no) {
|
||||
currentProcessingPatient.value = null;
|
||||
}
|
||||
showSnackbar(`Pasien ${patientCode} di-pending`, "info");
|
||||
break;
|
||||
|
||||
case "aktifkan":
|
||||
if (patient.status === "terlambat") {
|
||||
patient.status = "di-loket";
|
||||
showSnackbar(`Pasien ${patientCode} diaktifkan kembali`, "success");
|
||||
}
|
||||
break;
|
||||
|
||||
case "proses":
|
||||
currentProcessingPatient.value = patient;
|
||||
showSnackbar(`Memproses pasien ${patientCode}`, "info");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getRowClass = (item) => {
|
||||
if (item.status === "current") {
|
||||
return "text-success font-weight-bold";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loket-container {
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.call-controls-card,
|
||||
.next-patient-card,
|
||||
.current-processing-card,
|
||||
.quota-info-card,
|
||||
.main-table-card,
|
||||
.late-table-card,
|
||||
.pending-table-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.next-patient-card {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 2px solid rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.current-processing-card {
|
||||
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
|
||||
border: 2px solid rgba(255, 152, 0, 0.2);
|
||||
}
|
||||
|
||||
.quota-info-card {
|
||||
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
|
||||
border: 2px solid rgba(33, 150, 243, 0.2);
|
||||
}
|
||||
|
||||
.patient-info .text-h5 {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Enhanced table styling */
|
||||
.main-table-card :deep(.v-data-table th),
|
||||
.late-table-card :deep(.v-data-table th),
|
||||
.pending-table-card :deep(.v-data-table th) {
|
||||
background: #fafbfc;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.main-table-card :deep(.v-data-table tbody tr:hover),
|
||||
.late-table-card :deep(.v-data-table tbody tr:hover),
|
||||
.pending-table-card :deep(.v-data-table tbody tr:hover) {
|
||||
background: #f8fafc !important;
|
||||
}
|
||||
|
||||
.main-table-card :deep(.v-data-table tbody td),
|
||||
.late-table-card :deep(.v-data-table tbody td),
|
||||
.pending-table-card :deep(.v-data-table tbody td) {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.v-btn {
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.v-btn--size-small {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Success text color */
|
||||
.text-success {
|
||||
color: #4caf50 !important;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.current-processing-card .d-flex {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-buttons .v-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Stack layout vertically on medium screens */
|
||||
.call-controls-card .d-flex {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.loket-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.call-controls-card .d-flex {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.call-controls-card .v-btn {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Stack all control buttons vertically on mobile */
|
||||
.call-controls-card .d-flex.flex-wrap {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.call-controls-card .v-btn {
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-buttons .v-btn {
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.next-patient-card .text-h4 {
|
||||
font-size: 1.75rem !important;
|
||||
}
|
||||
|
||||
.current-processing-card .patient-info .text-h5 {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
.quota-info-card .text-h4 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,526 @@
|
||||
<template>
|
||||
<!-- Main Content -->
|
||||
<v-main class="bg-grey-lighten-3">
|
||||
<v-container fluid class="pa-6 main-content-padding">
|
||||
<!-- Header Banner & Stats -->
|
||||
<v-card class="d-flex justify-space-between align-center pa-5 rounded-xl elevation-4 mb-6 header-banner">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="40" class="mr-3 text-white">mdi-hospital-box-outline</v-icon>
|
||||
<span class="text-h4 font-weight-bold text-white">Loket Admin </span>
|
||||
</div>
|
||||
<div class="d-flex align-center text-white text-end flex-wrap justify-end">
|
||||
<span class="mr-4">Loket 24</span>
|
||||
<span class="mr-4">{{ currentDateLongFormatted }}</span>
|
||||
<span>{{ currentDateShortFormatted }} - Pelayanan</span>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Status Cards Section -->
|
||||
<v-row class="mb-6">
|
||||
<!-- Panggil 1 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
class="pa-4 rounded-xl elevation-2 text-center"
|
||||
color="#4CAF50"
|
||||
@click="handleStatusCardClick(1)"
|
||||
>
|
||||
<v-card-text class="text-white">
|
||||
<div class="text-h4 font-weight-bold">1</div>
|
||||
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<!-- Panggil 5 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
class="pa-4 rounded-xl elevation-2 text-center"
|
||||
color="#4CAF50"
|
||||
@click="handleStatusCardClick(5)"
|
||||
>
|
||||
<v-card-text class="text-white">
|
||||
<div class="text-h4 font-weight-bold">5</div>
|
||||
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<!-- Panggil 10 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
class="pa-4 rounded-xl elevation-2 text-center"
|
||||
color="#4CAF50"
|
||||
@click="handleStatusCardClick(10)"
|
||||
>
|
||||
<v-card-text class="text-white">
|
||||
<div class="text-h4 font-weight-bold">10</div>
|
||||
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<!-- Panggil 20 Antrian Card -->
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
class="pa-4 rounded-xl elevation-2 text-center"
|
||||
color="#4CAF50"
|
||||
@click="handleStatusCardClick(20)"
|
||||
>
|
||||
<v-card-text class="text-white">
|
||||
<div class="text-h4 font-weight-bold">20</div>
|
||||
<div class="text-subtitle-1 mt-1">Panggil</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Next Patient Card -->
|
||||
<v-card class="next-patient-card d-flex align-center justify-center pa-8 text-center rounded-xl elevation-6 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-h4 text-white">NEXT PATIENT</div>
|
||||
<div class="text-h2 font-weight-bold text-white mt-2">UM1001</div>
|
||||
<v-btn
|
||||
size="large"
|
||||
color="#00A896"
|
||||
class="mt-4 text-white"
|
||||
@click="handleNextPatientClick"
|
||||
>
|
||||
<v-icon start>mdi-arrow-right-circle</v-icon>
|
||||
Panggil Pasien Selanjutnya
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Main Data Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Data Pasien
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-caption">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
class="mx-2 select-items"
|
||||
></v-select>
|
||||
<span class="mr-2 text-caption">entries</span>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
append-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
style="max-width: 200px"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="mainHeaders"
|
||||
:items="mainPatients"
|
||||
:search="search"
|
||||
:items-per-page="itemsPerPage"
|
||||
:row-class="getRowClass"
|
||||
class="custom-table"
|
||||
>
|
||||
<!-- Custom template for the 'aksi' column -->
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<div class="d-flex ga-1">
|
||||
<!-- Show different buttons based on the item's status -->
|
||||
<template v-if="item.status === 'dipanggil'">
|
||||
<v-btn size="small" color="primary" variant="flat" class="rounded-lg" @click="handleProsesClick(item)">
|
||||
<v-icon start>mdi-cogs</v-icon>Proses
|
||||
</v-btn>
|
||||
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'dalam_proses'">
|
||||
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
|
||||
</v-btn>
|
||||
<v-btn size="small" color="warning" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-pause-circle-outline</v-icon>Tunda
|
||||
</v-btn>
|
||||
<v-btn size="small" color="error" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-close-circle-outline</v-icon>Batal
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn size="small" color="primary" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-cogs</v-icon>Proses
|
||||
</v-btn>
|
||||
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
|
||||
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
|
||||
</v-btn>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Custom template for the 'panggil' column -->
|
||||
<template v-slot:item.panggil="{ item }">
|
||||
<v-btn
|
||||
size="small"
|
||||
:color="item.status === 'dalam_proses' ? 'grey' : 'info'"
|
||||
variant="flat"
|
||||
class="rounded-lg"
|
||||
@click="handlePanggilClick(item)"
|
||||
:disabled="item.status === 'dalam_proses'"
|
||||
>
|
||||
<v-icon start>mdi-phone</v-icon>Panggil
|
||||
</v-btn>
|
||||
</template>
|
||||
<!-- Custom template for the 'noAntrian' column -->
|
||||
<template v-slot:item.noAntrian="{ item }">
|
||||
<span :class="{'online-antrian': item.status === 'dipanggil'}">{{ item.noAntrian }}</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Late Patients Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Info Pasien Lapor Terlambat
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-caption text-orange">KETERANGAN: PASIEN MASUK PADA TANGGAL</span>
|
||||
<span class="mr-2 text-caption">Show</span>
|
||||
<v-select
|
||||
v-model="lateItemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
class="mx-2 select-items"
|
||||
></v-select>
|
||||
<span class="mr-2 text-caption">entries</span>
|
||||
<v-text-field
|
||||
v-model="lateSearch"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
hide-details
|
||||
append-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
style="max-width: 200px"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="lateHeaders"
|
||||
:items="latePatients"
|
||||
:search="lateSearch"
|
||||
:items-per-page="lateItemsPerPage"
|
||||
class="custom-table"
|
||||
>
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-4">Tidak ada data yang tersedia</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Clinic Entry Patients Table -->
|
||||
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
|
||||
Info Pasien Masuk Klinik
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="clinicHeaders"
|
||||
:items="clinicPatients"
|
||||
:search="clinicSearch"
|
||||
:items-per-page="clinicItemsPerPage"
|
||||
class="custom-table"
|
||||
>
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-4">Tidak ada data yang tersedia</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Info Klinik Table -->
|
||||
<v-card class="pa-6 rounded-xl elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
|
||||
Info Klinik
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="infoKlinikHeaders"
|
||||
:items="infoKlinikData"
|
||||
class="custom-table"
|
||||
hide-default-footer
|
||||
disable-pagination
|
||||
>
|
||||
<template v-slot:bottom>
|
||||
<v-card-text class="d-flex justify-end text-right">
|
||||
<span class="mr-4 font-weight-bold text-h6">Total:</span>
|
||||
<span class="mr-12 font-weight-bold text-h6 text-primary">{{ totalDapatDipanggil }}</span>
|
||||
<span class="font-weight-bold text-h6 text-primary">{{ totalShiftBelumBuka }}</span>
|
||||
</v-card-text>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// Reactive data
|
||||
const search = ref("");
|
||||
const lateSearch = ref("");
|
||||
const clinicSearch = ref("");
|
||||
const itemsPerPage = ref(10);
|
||||
const lateItemsPerPage = ref(10);
|
||||
const clinicItemsPerPage = ref(10);
|
||||
const currentDateLongFormatted = ref("");
|
||||
const currentDateShortFormatted = ref("");
|
||||
|
||||
// Table headers
|
||||
const mainHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
{ title: "Jam Panggil", value: "jamPanggil" },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Fast Track", value: "fastTrack" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Panggil", align: 'center', value: "panggil", sortable: false },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const lateHeaders = ref([
|
||||
{ title: "No", value: "no", sortable: false },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const clinicHeaders = ref([
|
||||
{ title: "#", value: "no", sortable: false },
|
||||
{ title: "Barcode", value: "barcode" },
|
||||
{ title: "No Antrian", value: "noAntrian" },
|
||||
{ title: "No RM", value: "noRM" },
|
||||
{ title: "Shift", value: "shift" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Fast Track", value: "fastTrack" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false },
|
||||
]);
|
||||
|
||||
const infoKlinikHeaders = ref([
|
||||
{ title: "#", value: "no" },
|
||||
{ title: "Klinik", value: "klinik" },
|
||||
{ title: "Jumlah Shift", value: "jumlahShift" },
|
||||
{ title: "Quota Per Shift", value: "quotaPerShift" },
|
||||
{ title: "Status", value: "status" },
|
||||
{ title: "Dapat Di Panggil", value: "dapatDiPanggil" },
|
||||
{ title: "Shift Belum Buka", value: "shiftBelumBuka" },
|
||||
]);
|
||||
|
||||
// Sample data with new 'originalAntrian' and 'status' properties
|
||||
const mainPatients = ref([
|
||||
{ no: 1, jamPanggil: "11:46", barcode: "250826100362", noAntrian: "UM1002 | Online - 250826100362", originalAntrian: "UM1002", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "dipanggil" },
|
||||
{ no: 2, jamPanggil: "06:47", barcode: "250826100140", noAntrian: "UM1003", originalAntrian: "UM1003", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||
{ no: 3, jamPanggil: "06:47", barcode: "250826100143", noAntrian: "UM1004", originalAntrian: "UM1004", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||
{ no: 4, jamPanggil: "06:47", barcode: "250826100500", noAntrian: "UM1005", originalAntrian: "UM1005", shift: "Shift 1", klinik: "MATA", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||
{ no: 5, jamPanggil: "06:47", barcode: "250826100525", noAntrian: "UM1006", originalAntrian: "UM1006", shift: "Shift 1", klinik: "ONKOLOGI", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||
{ no: 6, jamPanggil: "06:47", barcode: "250826100536", noAntrian: "UM1007", originalAntrian: "UM1007", shift: "Shift 1", klinik: "THT", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
|
||||
]);
|
||||
|
||||
// Tambahkan lebih banyak data pasien untuk demonstrasi "Panggil 20"
|
||||
for (let i = 7; i <= 25; i++) {
|
||||
mainPatients.value.push({
|
||||
no: i,
|
||||
jamPanggil: "07:00",
|
||||
barcode: `250826100${100 + i}`,
|
||||
noAntrian: `UM100${i}`,
|
||||
originalAntrian: `UM100${i}`,
|
||||
shift: "Shift 1",
|
||||
klinik: "UMUM",
|
||||
fastTrack: "",
|
||||
pembayaran: "UMUM",
|
||||
panggil: "Panggil",
|
||||
status: "",
|
||||
});
|
||||
}
|
||||
|
||||
const latePatients = ref([]);
|
||||
const clinicPatients = ref([]);
|
||||
const infoKlinikData = ref([
|
||||
{ no: 1, klinik: "ANESTESI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 2, klinik: "GERIATRI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 3, klinik: "GIGI DAN MULUT", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 4, klinik: "HOM", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 5, klinik: "IPD", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 6, klinik: "JANTUNG", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 7, klinik: "KANDUNGAN", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 8, klinik: "KOMPLEMENTER", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBukan: "-" },
|
||||
{ no: 9, klinik: "KUL.KEL", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 10, klinik: "MATA", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 11, klinik: "ONKOLOGI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 12, klinik: "PARU", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 13, klinik: "SARAF", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
{ no: 14, klinik: "THT", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
|
||||
]);
|
||||
|
||||
// Computed properties for totals
|
||||
const totalDapatDipanggil = computed(() => {
|
||||
return infoKlinikData.value.reduce((total, item) => {
|
||||
return total + (item.dapatDiPanggil === "-" ? 0 : parseInt(item.dapatDiPanggil));
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const totalShiftBelumBuka = computed(() => {
|
||||
return infoKlinikData.value.reduce((total, item) => {
|
||||
return total + (item.shiftBelumBuka === "-" ? 0 : parseInt(item.shiftBelumBuka));
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const getRowClass = (item) => {
|
||||
if (item.status === 'dipanggil') {
|
||||
return 'called-row';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleStatusCardClick = (count) => {
|
||||
console.log(`Memanggil ${count} antrean pasien.`);
|
||||
const updatedPatients = mainPatients.value.map((patient, index) => {
|
||||
const isCalled = index < count;
|
||||
return {
|
||||
...patient,
|
||||
status: isCalled ? 'dipanggil' : '',
|
||||
noAntrian: isCalled ? `${patient.originalAntrian} | Online - ${patient.barcode}` : patient.originalAntrian
|
||||
};
|
||||
});
|
||||
mainPatients.value = updatedPatients;
|
||||
};
|
||||
|
||||
const handleNextPatientClick = () => {
|
||||
console.log("Tombol Panggil Pasien Selanjutnya diklik! (Mengambil pasien pertama dari antrean)");
|
||||
const updatedPatients = mainPatients.value.map(patient => {
|
||||
return { ...patient, status: '', noAntrian: patient.originalAntrian };
|
||||
});
|
||||
mainPatients.value = updatedPatients;
|
||||
};
|
||||
|
||||
const handlePanggilClick = (item) => {
|
||||
console.log(`Tombol Panggil untuk pasien: ${item.noAntrian} diklik!`);
|
||||
|
||||
// Membuat salinan baru dari seluruh array untuk memicu reaktivitas
|
||||
const updatedPatients = mainPatients.value.map(p => {
|
||||
// Jika pasien cocok, buat salinan baru dengan status 'dipanggil' dan tambahkan "Online"
|
||||
if (p.no === item.no) {
|
||||
return {
|
||||
...p,
|
||||
status: 'dipanggil',
|
||||
noAntrian: `${p.originalAntrian} | Online - ${p.barcode}`
|
||||
};
|
||||
}
|
||||
// Jika tidak, kembalikan objek pasien aslinya
|
||||
return p;
|
||||
});
|
||||
|
||||
// Ganti seluruh array data dengan salinan yang baru.
|
||||
mainPatients.value = updatedPatients;
|
||||
};
|
||||
|
||||
const handleProsesClick = (item) => {
|
||||
console.log(`Tombol Proses untuk pasien: ${item.noAntrian} diklik!`);
|
||||
|
||||
const updatedPatients = mainPatients.value.map(p => {
|
||||
if (p.no === item.no) {
|
||||
return {
|
||||
...p,
|
||||
status: 'dalam_proses'
|
||||
};
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
mainPatients.value = updatedPatients;
|
||||
};
|
||||
|
||||
// Mengatur tanggal saat komponen dimuat
|
||||
onMounted(() => {
|
||||
const today = new Date();
|
||||
const optionsLong = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||
currentDateLongFormatted.value = today.toLocaleDateString('id-ID', optionsLong);
|
||||
|
||||
const optionsShort = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
currentDateShortFormatted.value = today.toLocaleDateString('id-ID', optionsShort);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles for a cleaner look */
|
||||
.main-content-padding {
|
||||
padding-left: 24px !important;
|
||||
padding-right: 24px !important;
|
||||
}
|
||||
|
||||
/* Header Banner */
|
||||
.header-banner {
|
||||
background: linear-gradient(90deg, #1565C0, #1976D2);
|
||||
color: white;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Next Patient Card */
|
||||
.next-patient-card {
|
||||
background: linear-gradient(45deg, #00A896, #00796B);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Status Cards */
|
||||
.v-card.text-center {
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.v-card.text-center:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.custom-table :deep(thead th) {
|
||||
background-color: #E8EAF6; /* Light gray-blue header */
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.custom-table :deep(tbody tr:nth-of-type(odd)) {
|
||||
background-color: #F5F5F5; /* Light gray for odd rows */
|
||||
}
|
||||
|
||||
/* Highlighted row for "dipanggil" status */
|
||||
.custom-table :deep(tbody tr.called-row) {
|
||||
background-color: #998479 !important; /* Light green background */
|
||||
}
|
||||
|
||||
/* Field and Select styling */
|
||||
.select-items .v-field--variant-solo,
|
||||
.v-text-field .v-field--variant-solo {
|
||||
background-color: #ECEFF1;
|
||||
}
|
||||
|
||||
.text-blue {
|
||||
color: #1976D2 !important;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #1976D2 !important;
|
||||
}
|
||||
|
||||
.online-antrian {
|
||||
font-weight: bold;
|
||||
color: #1976D2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<v-main class="ranap-admin-page">
|
||||
<v-container fluid class="pa-6">
|
||||
<!-- Modern Header -->
|
||||
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
|
||||
<v-icon size="48" color="white" class="mr-4">mdi-hospital-box-outline</v-icon>
|
||||
<h1 class="text-h4 font-weight-bold text-white">Ranap Admin</h1>
|
||||
</v-card>
|
||||
|
||||
<v-card class="elevation-2 rounded-xl pa-4">
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-caption mr-2">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="solo"
|
||||
flat
|
||||
class="pagination-select"
|
||||
:menu-props="{ attach: true }"
|
||||
></v-select>
|
||||
<span class="text-caption ml-2">entries</span>
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
label="Search"
|
||||
append-inner-icon="mdi-magnify"
|
||||
single-line
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
class="search-field"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredItems"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="elevation-0"
|
||||
item-key="noAntrean"
|
||||
>
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<v-btn small color="success" class="text-white" @click="selectItem(item)">
|
||||
Selesai
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:bottom>
|
||||
<div class="d-flex justify-space-between align-center pa-2">
|
||||
<span class="text-caption">Showing {{ (page - 1) * itemsPerPage + 1 }} to {{ Math.min(page * itemsPerPage, filteredItems.length) }} of {{ filteredItems.length }} entries</span>
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="pageCount"
|
||||
:total-visible="5"
|
||||
rounded="circle"
|
||||
></v-pagination>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<v-row class="mt-6">
|
||||
<v-col cols="12" md="4" class="pr-md-2">
|
||||
<v-card class="elevation-2 rounded-xl next-card pa-4 d-flex flex-column align-center text-center">
|
||||
<h2 class="text-h3 font-weight-bold next-title">NEXT</h2>
|
||||
<span class="text-h4 font-weight-medium my-4 next-content">{{ nextAntrean || 'Kosong' }}</span>
|
||||
<p class="text-caption">Klik untuk memanggil pasien selanjutnya</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
const headers = [
|
||||
{ title: 'No', align: 'start', sortable: false, key: 'no' },
|
||||
{ title: 'No. Antrean', key: 'noAntrean' },
|
||||
{ title: 'Daftar', key: 'daftar' },
|
||||
{ title: 'Pelayanan', key: 'pelayanan' },
|
||||
{ title: 'Aksi', key: 'aksi' },
|
||||
];
|
||||
|
||||
const items = ref([
|
||||
{ no: 1, noAntrean: '001', daftar: '26 Aug 2025 07:10:31', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 2, noAntrean: '002', daftar: '26 Aug 2025 07:10:35', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 3, noAntrean: '003', daftar: '26 Aug 2025 07:10:44', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 4, noAntrean: '004', daftar: '26 Aug 2025 07:10:46', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 5, noAntrean: '005', daftar: '26 Aug 2025 07:10:47', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 6, noAntrean: '006', daftar: '26 Aug 2025 07:10:49', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 7, noAntrean: '007', daftar: '26 Aug 2025 07:10:51', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 8, noAntrean: '008', daftar: '26 Aug 2025 07:10:53', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 9, noAntrean: '009', daftar: '26 Aug 2025 07:10:54', pelayanan: 'Belum Dilayani' },
|
||||
{ no: 10, noAntrean: '010', daftar: '26 Aug 2025 07:10:55', pelayanan: 'Belum Dilayani' },
|
||||
]);
|
||||
|
||||
const search = ref('');
|
||||
const itemsPerPage = ref(10);
|
||||
const page = ref(1);
|
||||
|
||||
const nextAntrean = ref('001');
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!search.value) {
|
||||
return items.value;
|
||||
}
|
||||
return items.value.filter(item =>
|
||||
Object.values(item).some(val =>
|
||||
String(val).toLowerCase().includes(search.value.toLowerCase())
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const pageCount = computed(() => {
|
||||
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
|
||||
});
|
||||
|
||||
const selectItem = (item) => {
|
||||
console.log('Item selected:', item);
|
||||
// Di sini Anda bisa menambahkan logika untuk mengubah status pasien menjadi "Dilayani" atau memindahkannya ke antrean berikutnya.
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ranap-admin-page {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header-banner {
|
||||
background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
|
||||
color: white;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.search-field, .pagination-select {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.v-data-table :deep(table) {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 10px;
|
||||
}
|
||||
|
||||
.v-data-table :deep(tbody tr) {
|
||||
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.next-card {
|
||||
background-color: #00A896;
|
||||
color: white;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.next-title {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
|
||||
.next-content {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-card>
|
||||
<v-card-title>Edit Loket</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form @submit.prevent="simpanLoket">
|
||||
<v-text-field label="Nama Loket" v-model="loket.namaLoket"></v-text-field>
|
||||
<v-text-field label="Kuota Bangku" v-model="loket.kuota" type="number"></v-text-field>
|
||||
|
||||
<v-select
|
||||
label="Status Pelayanan"
|
||||
:items="['RAWAT JALAN', 'RAWAT INAP']"
|
||||
v-model="loket.statusPelayanan"
|
||||
></v-select>
|
||||
|
||||
<v-select
|
||||
label="Pembayaran"
|
||||
:items="['JKN', 'UMUM']"
|
||||
v-model="loket.pembayaran"
|
||||
></v-select>
|
||||
|
||||
<v-select
|
||||
label="Keterangan"
|
||||
:items="['ONLINE', 'MANUAL']"
|
||||
v-model="loket.keterangan"
|
||||
></v-select>
|
||||
|
||||
<div class="my-4">
|
||||
<h3 class="text-h6">Pelayanan</h3>
|
||||
<TabelLayanan
|
||||
:headers="serviceHeaders"
|
||||
:items="availableServices"
|
||||
v-model:selected-items="loket.pelayanan"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-btn color="success" type="submit" class="mr-2">Simpan</v-btn>
|
||||
<v-btn color="secondary" @click="cancelEdit">Batal</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import TabelLayanan from '../../components/TabelLayanan.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Data dummy loket yang sama seperti di Master-Loket.vue
|
||||
const loketData = ref([
|
||||
{ id: 1, no: 1, namaLoket: 'Loket 1', kuota: 500, pelayanan: ['RADIOTERAPI', 'REHAB MEDIK', 'TINDAKAN'], pembayaran: 'JKN', keterangan: 'ONLINE' },
|
||||
{ id: 2, no: 2, namaLoket: 'Loket 2', kuota: 666, pelayanan: ['JIWA', 'SARAF'], pembayaran: 'JKN', keterangan: 'ONLINE' },
|
||||
{ id: 3, no: 3, namaLoket: 'Loket 3', kuota: 666, pelayanan: ['ANESTESI', 'JANTUNG'], pembayaran: 'JKN', keterangan: 'ONLINE' },
|
||||
{ id: 4, no: 4, namaLoket: 'Loket 4', kuota: 3676, pelayanan: ['KULIT KELAMIN', 'PARU'], pembayaran: 'JKN', keterangan: 'ONLINE' },
|
||||
]);
|
||||
|
||||
const loket = ref({
|
||||
id: null,
|
||||
namaLoket: '',
|
||||
kuota: 0,
|
||||
statusPelayanan: '',
|
||||
pembayaran: '',
|
||||
keterangan: '',
|
||||
pelayanan: [],
|
||||
});
|
||||
|
||||
const serviceHeaders = ref([
|
||||
{ title: '#', value: 'no', sortable: false },
|
||||
{ title: 'Kode', value: 'id' },
|
||||
{ title: 'Klinik', value: 'nama' },
|
||||
{ title: 'Pilih', value: 'pilih', sortable: false },
|
||||
]);
|
||||
|
||||
const availableServices = ref([
|
||||
{ no: 1, id: 'AN', nama: 'ANAK' },
|
||||
{ no: 2, id: 'AS', nama: 'ANESTESI' },
|
||||
{ no: 3, id: 'BD', nama: 'BEDAH' },
|
||||
{ no: 4, id: 'GR', nama: 'GERIATRI' },
|
||||
{ no: 5, id: 'GI', nama: 'GIGI DAN MULUT' },
|
||||
{ no: 6, id: 'GZ', nama: 'GIZI' },
|
||||
{ no: 7, id: 'HO', nama: 'HOM' },
|
||||
{ no: 8, id: 'IP', nama: 'IPD' },
|
||||
]);
|
||||
|
||||
onMounted(() => {
|
||||
// Cari loket yang sesuai dengan ID di URL
|
||||
const selectedLoket = loketData.value.find(loket => loket.id === parseInt(route.params.id));
|
||||
|
||||
if (selectedLoket) {
|
||||
// Jika data ditemukan, salin ke objek loket
|
||||
loket.value = { ...selectedLoket };
|
||||
// Konversi string pelayanan menjadi array untuk checkbox
|
||||
if (typeof loket.value.pelayanan === 'string') {
|
||||
loket.value.pelayanan = loket.value.pelayanan.split(', ').map(s => s.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const simpanLoket = () => {
|
||||
// Dalam aplikasi nyata, ini adalah tempat untuk memanggil API update data
|
||||
|
||||
// Untuk simulasi, kita akan kembali ke halaman master
|
||||
router.back();
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
router.back();
|
||||
};
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -1,156 +0,0 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-card>
|
||||
<!-- <v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>Master Loket</span>
|
||||
<v-btn color=#ff9248 @click="tambahLoket" style="color:white;">Tambah Baru</v-btn>
|
||||
</v-card-title> -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Master Loket</h1>
|
||||
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TabelData
|
||||
:headers="loketHeaders"
|
||||
:items="loketData"
|
||||
title="Master Loket"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<v-btn
|
||||
small
|
||||
color="#ff9248"
|
||||
@click="editLoket(item)"
|
||||
class="mr-2"
|
||||
style="color: white"
|
||||
>Edit</v-btn
|
||||
>
|
||||
<v-btn small color="grey-lighten-4" @click="deleteLoket(item)"
|
||||
>Delete</v-btn
|
||||
>
|
||||
</template>
|
||||
</TabelData>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import TabelData from "../../components/TabelData.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const loketHeaders = ref([
|
||||
{ title: "No", value: "no" },
|
||||
{ title: "Nama Loket", value: "namaLoket" },
|
||||
{ title: "Kuota", value: "kuota" },
|
||||
{ title: "Pelayanan", value: "pelayanan" },
|
||||
{ title: "Pembayaran", value: "pembayaran" },
|
||||
{ title: "Keterangan", value: "keterangan" },
|
||||
{ title: "Aksi", value: "aksi", sortable: false }, // 'value' harus 'actions'
|
||||
]);
|
||||
|
||||
// Master-Loket.vue
|
||||
const loketData = ref([
|
||||
{
|
||||
id: 1,
|
||||
no: 1,
|
||||
namaLoket: "Loket 1",
|
||||
kuota: 500,
|
||||
pelayanan: ["RADIOTERAPI", "REHAB MEDIK", "TINDAKAN"],
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
no: 2,
|
||||
namaLoket: "Loket 2",
|
||||
kuota: 666,
|
||||
pelayanan: ["JIWA", "SARAF"],
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
no: 3,
|
||||
namaLoket: "Loket 3",
|
||||
kuota: 666,
|
||||
pelayanan: ["ANESTESI", "JANTUNG"],
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
no: 4,
|
||||
namaLoket: "Loket 4",
|
||||
kuota: 3676,
|
||||
pelayanan: ["KULIT KELAMIN", "PARU"],
|
||||
pembayaran: "JKN",
|
||||
keterangan: "ONLINE",
|
||||
},
|
||||
]);
|
||||
|
||||
const editLoket = (item) => {
|
||||
router.push({ path: `/Setting/Edit-Loket/${item.id}` });
|
||||
};
|
||||
|
||||
const deleteLoket = (item) => {
|
||||
const index = loketData.value.findIndex((loket) => loket.id === item.id);
|
||||
if (index !== -1) {
|
||||
loketData.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const tambahLoket = () => {
|
||||
router.push({ path: "/Setting/Tambah-Loket" });
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,491 @@
|
||||
<template>
|
||||
<div class="master-klinik-page pa-4">
|
||||
<!-- Breadcrumbs -->
|
||||
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
|
||||
<template v-slot:divider>
|
||||
<v-icon icon="mdi-chevron-right"></v-icon>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
|
||||
<!-- Tampilan Formulir Tambah/Edit/View -->
|
||||
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold mb-4">
|
||||
{{ isEditMode ? 'Edit Klinik' : readOnly ? 'Detail Klinik' : 'Tambah Klinik' }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Kode</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.kode"
|
||||
placeholder="Masukkan Kode Klinik"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Nama</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.namaKlinik"
|
||||
placeholder="Masukkan Nama Klinik"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Shift</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.shift"
|
||||
placeholder="Jumlah Shift"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Kuota Shift</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.kuotaShift"
|
||||
placeholder="Kuota Per Shift"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="d-flex align-center">
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Jam Buka Shift</v-label>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-model="editedItem.jamBuka"
|
||||
placeholder="Jam Buka Shift 1"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mr-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
<span class="text-h6 font-weight-bold mr-2">:</span>
|
||||
<v-text-field
|
||||
v-model="editedItem.menitBuka"
|
||||
placeholder="Menit Buka Shift 1"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-switch
|
||||
v-model="editedItem.autoShift"
|
||||
inset
|
||||
color="primary"
|
||||
label="Auto Shift"
|
||||
hide-details
|
||||
:readonly="readOnly"
|
||||
></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-label class="font-weight-bold">Kuota Bangku</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.kuotaBangku"
|
||||
placeholder="Masukkan Kuota Bangku Klinik"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-label class="font-weight-bold">Jadwal Klinik</v-label>
|
||||
<v-table class="rounded-lg elevation-1 mt-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left text-uppercase font-weight-bold">#</th>
|
||||
<th class="text-left text-uppercase font-weight-bold">Hari</th>
|
||||
<th class="text-left text-uppercase font-weight-bold">Pilih</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(day, index) in days" :key="index">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td>{{ day.name }}</td>
|
||||
<td>
|
||||
<v-checkbox
|
||||
v-model="day.selected"
|
||||
color="primary"
|
||||
hide-details
|
||||
:readonly="readOnly"
|
||||
></v-checkbox>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-start pa-4">
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
variant="flat"
|
||||
class="text-capitalize rounded-lg mr-2"
|
||||
@click="cancelForm"
|
||||
>
|
||||
{{ readOnly ? 'Tutup' : 'Batal' }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!readOnly"
|
||||
color="orange-darken-2"
|
||||
variant="flat"
|
||||
class="text-capitalize rounded-lg"
|
||||
@click="saveItem"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- Tampilan Tabel Data -->
|
||||
<div v-else>
|
||||
<!-- Banner biru sebagai pengganti header h1 -->
|
||||
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
|
||||
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
|
||||
<v-icon icon="mdi-hospital" class="mr-2" size="40"></v-icon>
|
||||
<span>Master Klinik</span>
|
||||
</v-card-title>
|
||||
<v-btn
|
||||
color="success"
|
||||
prepend-icon="mdi-plus"
|
||||
rounded
|
||||
class="text-capitalize"
|
||||
@click="showForm = true"
|
||||
>
|
||||
Tambah Baru
|
||||
</v-btn>
|
||||
</v-card>
|
||||
|
||||
<v-card class="pa-4 rounded-lg elevation-2">
|
||||
<v-card-text>
|
||||
<!-- Table controls -->
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-subtitle-1">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50]"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 80px;"
|
||||
rounded
|
||||
class="mr-2"
|
||||
></v-select>
|
||||
<span class="text-subtitle-1">entries</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
rounded
|
||||
clearable
|
||||
></v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table dengan paginasi kustom -->
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="allKlinikData"
|
||||
:search="search"
|
||||
:items-per-page="itemsPerPage"
|
||||
v-model:page="page"
|
||||
class="rounded-lg elevation-0 custom-table"
|
||||
>
|
||||
<!-- Slot untuk aksi di setiap baris -->
|
||||
<template v-slot:[`item.actions`]="{ item }">
|
||||
<!-- Tombol "View" yang hanya menampilkan detail -->
|
||||
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
|
||||
<v-icon>mdi-eye</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon color="red" size="small" @click="deleteItem(item)">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
|
||||
Tidak ada data yang tersedia.
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<!-- Slot kustom untuk footer tabel (paginasi) -->
|
||||
<template v-slot:bottom>
|
||||
<v-row class="ma-2">
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
|
||||
{{ showingEntriesText }}
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="pageCount"
|
||||
rounded="circle"
|
||||
:total-visible="5"
|
||||
></v-pagination>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
<v-dialog v-model="showDeleteDialog" max-width="400px">
|
||||
<v-card class="pa-4 rounded-lg">
|
||||
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
|
||||
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
|
||||
<v-card-actions class="d-flex justify-end">
|
||||
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
|
||||
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// State untuk menampilkan/menyembunyikan formulir
|
||||
const showForm = ref(false);
|
||||
const readOnly = ref(false); // State untuk mode "view"
|
||||
|
||||
// Data dummy untuk Master Klinik
|
||||
const allKlinikData = ref([
|
||||
{ id: 1, kode: 'AN', namaKlinik: 'ANAK', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 2, kode: 'AS', namaKlinik: 'ANESTESI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 3, kode: 'BD', namaKlinik: 'BEDAH', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 4, kode: 'GR', namaKlinik: 'GERIATRI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 5, kode: 'GI', namaKlinik: 'GIGI DAN MULUT', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 6, kode: 'GZ', namaKlinik: 'GIZI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 7, kode: 'HO', namaKlinik: 'HOM', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 8, kode: 'IP', namaKlinik: 'IPD', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 9, kode: 'JT', namaKlinik: 'JANTUNG', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 10, kode: 'JW', namaKlinik: 'JIWA', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 11, kode: 'OB', namaKlinik: 'KANDUNGAN', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 12, kode: 'KT', namaKlinik: 'KEMOTERAPI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 13, kode: 'KN', namaKlinik: 'KOMPLEMENTER', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 14, kode: 'KL', namaKlinik: 'KUL KEL', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 15, kode: 'MT', namaKlinik: 'MATA', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 16, kode: 'MC', namaKlinik: 'MCU', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 17, kode: 'ON', namaKlinik: 'ONKOLOGI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 18, kode: 'PR', namaKlinik: 'PARU', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 19, kode: 'RT', namaKlinik: 'R. TINDAKAN', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 20, kode: 'RD', namaKlinik: 'RADIOTERAPI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 21, kode: 'RM', namaKlinik: 'REHAB MEDIK', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 22, kode: 'NU', namaKlinik: 'SARAF', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
{ id: 23, kode: 'TH', namaKlinik: 'THT', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
|
||||
]);
|
||||
|
||||
const headers = ref([
|
||||
{ title: 'No', key: 'id' },
|
||||
{ title: 'Kode', key: 'kode', sortable: true },
|
||||
{ title: 'Nama Klinik', key: 'namaKlinik', sortable: true },
|
||||
{ title: 'Shift', key: 'shift', sortable: true },
|
||||
{ title: 'Kuota Shift', key: 'kuotaShift', sortable: true },
|
||||
{ title: 'Aksi', key: 'actions', sortable: false },
|
||||
]);
|
||||
|
||||
// Breadcrumbs
|
||||
const breadcrumbs = ref([
|
||||
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
|
||||
]);
|
||||
|
||||
const days = ref([
|
||||
{ name: 'Senin', selected: false },
|
||||
{ name: 'Selasa', selected: false },
|
||||
{ name: 'Rabu', selected: false },
|
||||
{ name: 'Kamis', selected: false },
|
||||
{ name: 'Jum`at', selected: false },
|
||||
]);
|
||||
|
||||
// State untuk tabel dan paginasi
|
||||
const itemsPerPage = ref(10);
|
||||
const search = ref('');
|
||||
const page = ref(1);
|
||||
|
||||
// Computed properties untuk paginasi kustom
|
||||
const pageCount = computed(() => {
|
||||
return Math.ceil(allKlinikData.value.length / itemsPerPage.value);
|
||||
});
|
||||
|
||||
const showingEntriesText = computed(() => {
|
||||
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||
const end = Math.min(page.value * itemsPerPage.value, allKlinikData.value.length);
|
||||
const total = allKlinikData.value.length;
|
||||
return `Showing ${start} to ${end} of ${total} entries`;
|
||||
});
|
||||
|
||||
// State untuk dialog
|
||||
const showDeleteDialog = ref(false);
|
||||
const isEditMode = ref(false);
|
||||
const editedIndex = ref(-1);
|
||||
const editedItem = ref({
|
||||
id: 0,
|
||||
kode: '',
|
||||
namaKlinik: '',
|
||||
shift: 1,
|
||||
kuotaShift: 0,
|
||||
kuotaBangku: 0,
|
||||
jamBuka: '',
|
||||
menitBuka: '',
|
||||
autoShift: false,
|
||||
});
|
||||
|
||||
|
||||
// Fungsi untuk tombol aksi
|
||||
// Fungsi untuk tombol View yang hanya menampilkan data tanpa bisa diedit
|
||||
const viewItem = (item) => {
|
||||
editedItem.value = { ...item };
|
||||
readOnly.value = true;
|
||||
isEditMode.value = false;
|
||||
showForm.value = true;
|
||||
// Update breadcrumbs untuk mode view
|
||||
breadcrumbs.value = [
|
||||
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
|
||||
{ title: 'Detail Klinik', disabled: true, href: '/setting/viewklinik' },
|
||||
];
|
||||
};
|
||||
|
||||
const editItem = (item) => {
|
||||
editedIndex.value = allKlinikData.value.findIndex(d => d.id === item.id);
|
||||
editedItem.value = { ...item };
|
||||
isEditMode.value = true;
|
||||
readOnly.value = false;
|
||||
showForm.value = true;
|
||||
// Update breadcrumbs untuk mode edit
|
||||
breadcrumbs.value = [
|
||||
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
|
||||
{ title: 'Edit Klinik', disabled: true, href: '/setting/editklinik' },
|
||||
];
|
||||
};
|
||||
|
||||
const deleteItem = (item) => {
|
||||
editedIndex.value = allKlinikData.value.findIndex(d => d.id === item.id);
|
||||
showDeleteDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (editedIndex.value > -1) {
|
||||
allKlinikData.value.splice(editedIndex.value, 1);
|
||||
}
|
||||
closeDeleteDialog();
|
||||
};
|
||||
|
||||
const cancelForm = () => {
|
||||
showForm.value = false;
|
||||
isEditMode.value = false;
|
||||
readOnly.value = false;
|
||||
editedItem.value = {
|
||||
id: 0,
|
||||
kode: '',
|
||||
namaKlinik: '',
|
||||
shift: 1,
|
||||
kuotaShift: 0,
|
||||
kuotaBangku: 0,
|
||||
jamBuka: '',
|
||||
menitBuka: '',
|
||||
autoShift: false,
|
||||
};
|
||||
editedIndex.value = -1;
|
||||
// Reset breadcrumbs ke mode tabel
|
||||
breadcrumbs.value = [
|
||||
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
|
||||
];
|
||||
};
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
showDeleteDialog.value = false;
|
||||
editedIndex.value = -1;
|
||||
};
|
||||
|
||||
const saveItem = () => {
|
||||
if (isEditMode.value) {
|
||||
Object.assign(allKlinikData.value[editedIndex.value], editedItem.value);
|
||||
} else {
|
||||
// Generate new ID
|
||||
const newId = allKlinikData.value.length > 0 ? Math.max(...allKlinikData.value.map(item => item.id)) + 1 : 1;
|
||||
editedItem.value.id = newId;
|
||||
allKlinikData.value.push(editedItem.value);
|
||||
}
|
||||
cancelForm(); // Kembali ke tampilan tabel setelah menyimpan
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.master-klinik-page {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.v-data-table :deep(th) {
|
||||
font-weight: bold !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.v-data-table :deep(td) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.v-btn--icon {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.v-select :deep(.v-field__input) {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div class="master-klinik-ruang-page pa-4">
|
||||
<!-- Breadcrumbs -->
|
||||
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
|
||||
<template v-slot:divider>
|
||||
<v-icon icon="mdi-chevron-right"></v-icon>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
|
||||
<!-- Tampilan Formulir Tambah/Edit/View -->
|
||||
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold mb-4">
|
||||
{{ isEditMode ? 'Edit Ruang Klinik' : readOnly ? 'Detail Ruang Klinik' : 'Tambah Ruang Klinik' }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Nama Klinik</v-label>
|
||||
<v-select
|
||||
v-model="editedItem.namaKlinik"
|
||||
:items="['ANAK', 'ANESTESI', 'BEDAH', 'GERIATRI', 'GIGI DAN MULUT', 'GIZI', 'HOM', 'IPD', 'JANTUNG', 'JIWA', 'KANDUNGAN', 'KEMOTERAPI', 'KOMPLEMENTER', 'KUL KEL', 'MATA', 'MCU', 'ONKOLOGI', 'PARU', 'R. TINDAKAN', 'RADIOTERAPI', 'REHAB MEDIK', 'SARAF', 'THT']"
|
||||
placeholder="Pilih Nama Klinik"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Kode Ruang</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.kode"
|
||||
placeholder="Masukkan Kode Ruang"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-label class="font-weight-bold">Nama Ruang</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.namaRuang"
|
||||
placeholder="Masukkan Nama Ruang"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-start pa-4">
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
variant="flat"
|
||||
class="text-capitalize rounded-lg mr-2"
|
||||
@click="cancelForm"
|
||||
>
|
||||
{{ readOnly ? 'Tutup' : 'Batal' }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!readOnly"
|
||||
color="orange-darken-2"
|
||||
variant="flat"
|
||||
class="text-capitalize rounded-lg"
|
||||
@click="saveItem"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- Tampilan Tabel Data -->
|
||||
<div v-else>
|
||||
<!-- Banner biru sebagai pengganti header h1 -->
|
||||
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
|
||||
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
|
||||
<v-icon icon="mdi-hospital-box-outline" class="mr-2" size="40"></v-icon>
|
||||
<span>Master Klinik Ruang</span>
|
||||
</v-card-title>
|
||||
<v-btn
|
||||
color="success"
|
||||
prepend-icon="mdi-plus"
|
||||
rounded
|
||||
class="text-capitalize"
|
||||
@click="showForm = true"
|
||||
>
|
||||
Tambah Baru
|
||||
</v-btn>
|
||||
</v-card>
|
||||
|
||||
<v-card class="pa-4 rounded-lg elevation-2">
|
||||
<v-card-text>
|
||||
<!-- Table controls -->
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-subtitle-1">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50]"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 80px;"
|
||||
rounded
|
||||
class="mr-2"
|
||||
></v-select>
|
||||
<span class="text-subtitle-1">entries</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
rounded
|
||||
clearable
|
||||
></v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table dengan paginasi kustom -->
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="allRuangData"
|
||||
:search="search"
|
||||
:items-per-page="itemsPerPage"
|
||||
v-model:page="page"
|
||||
class="rounded-lg elevation-0 custom-table"
|
||||
>
|
||||
<!-- Slot untuk aksi di setiap baris -->
|
||||
<template v-slot:[`item.actions`]="{ item }">
|
||||
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
|
||||
<v-icon>mdi-eye</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon color="red" size="small" @click="deleteItem(item)">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
|
||||
Tidak ada data yang tersedia.
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<!-- Slot kustom untuk footer tabel (paginasi) -->
|
||||
<template v-slot:bottom>
|
||||
<v-row class="ma-2">
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
|
||||
{{ showingEntriesText }}
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="pageCount"
|
||||
rounded="circle"
|
||||
:total-visible="5"
|
||||
></v-pagination>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
<v-dialog v-model="showDeleteDialog" max-width="400px">
|
||||
<v-card class="pa-4 rounded-lg">
|
||||
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
|
||||
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
|
||||
<v-card-actions class="d-flex justify-end">
|
||||
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
|
||||
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// State untuk menampilkan/menyembunyikan formulir
|
||||
const showForm = ref(false);
|
||||
const readOnly = ref(false);
|
||||
|
||||
// Data dummy untuk Master Klinik Ruang
|
||||
const allRuangData = ref([
|
||||
{ id: 1, namaKlinik: 'ANAK', kode: 'AN-01', namaRuang: 'RUANG ANAK 1' },
|
||||
{ id: 2, namaKlinik: 'ANAK', kode: 'AN-02', namaRuang: 'RUANG ANAK 2' },
|
||||
{ id: 3, namaKlinik: 'ANESTESI', kode: 'AS-01', namaRuang: 'RUANG ANESTESI 1' },
|
||||
{ id: 4, namaKlinik: 'BEDAH', kode: 'BD-01', namaRuang: 'RUANG BEDAH 1' },
|
||||
{ id: 5, namaKlinik: 'GIGI DAN MULUT', kode: 'GI-01', namaRuang: 'RUANG GIGI DAN MULUT 1' },
|
||||
{ id: 6, namaKlinik: 'GERIATRI', kode: 'GR-01', namaRuang: 'RUANG GERIATRI 1' },
|
||||
{ id: 7, namaKlinik: 'GIZI', kode: 'GZ-01', namaRuang: 'RUANG GIZI 1' },
|
||||
{ id: 8, namaKlinik: 'HOM', kode: 'HO-01', namaRuang: 'RUANG HOM 1' },
|
||||
{ id: 9, namaKlinik: 'IPD', kode: 'IP-01', namaRuang: 'RUANG IPD 1' },
|
||||
{ id: 10, namaKlinik: 'JANTUNG', kode: 'JT-01', namaRuang: 'RUANG JANTUNG 1' },
|
||||
{ id: 11, namaKlinik: 'JIWA', kode: 'JW-01', namaRuang: 'RUANG JIWA 1' },
|
||||
{ id: 12, namaKlinik: 'KANDUNGAN', kode: 'OB-01', namaRuang: 'RUANG KANDUNGAN 1' },
|
||||
{ id: 13, namaKlinik: 'KANDUNGAN', kode: 'OB-02', namaRuang: 'RUANG KANDUNGAN 2' },
|
||||
{ id: 14, namaKlinik: 'KEMOTERAPI', kode: 'KT-01', namaRuang: 'RUANG KEMOTERAPI 1' },
|
||||
{ id: 15, namaKlinik: 'KOMPLEMENTER', kode: 'KN-01', namaRuang: 'RUANG KOMPLEMENTER 1' },
|
||||
{ id: 16, namaKlinik: 'KUL KEL', kode: 'KL-01', namaRuang: 'RUANG KUL KEL 1' },
|
||||
{ id: 17, namaKlinik: 'MATA', kode: 'MT-01', namaRuang: 'RUANG MATA 1' },
|
||||
{ id: 18, namaKlinik: 'MCU', kode: 'MC-01', namaRuang: 'RUANG MCU 1' },
|
||||
{ id: 19, namaKlinik: 'ONKOLOGI', kode: 'ON-01', namaRuang: 'RUANG ONKOLOGI 1' },
|
||||
{ id: 20, namaKlinik: 'PARU', kode: 'PR-01', namaRuang: 'RUANG PARU 1' },
|
||||
{ id: 21, namaKlinik: 'R. TINDAKAN', kode: 'RT-01', namaRuang: 'RUANG R. TINDAKAN 1' },
|
||||
{ id: 22, namaKlinik: 'RADIOTERAPI', kode: 'RD-01', namaRuang: 'RUANG RADIOTERAPI 1' },
|
||||
{ id: 23, namaKlinik: 'REHAB MEDIK', kode: 'RM-01', namaRuang: 'RUANG REHAB MEDIK 1' },
|
||||
{ id: 24, namaKlinik: 'SARAF', kode: 'NU-01', namaRuang: 'RUANG SARAF 1' },
|
||||
{ id: 25, namaKlinik: 'THT', kode: 'TH-01', namaRuang: 'RUANG THT 1' },
|
||||
]);
|
||||
|
||||
const headers = ref([
|
||||
{ title: 'No', key: 'id' },
|
||||
{ title: 'Nama Klinik', key: 'namaKlinik', sortable: true },
|
||||
{ title: 'Kode Ruang', key: 'kode', sortable: true },
|
||||
{ title: 'Nama Ruang', key: 'namaRuang', sortable: true },
|
||||
{ title: 'Aksi', key: 'actions', sortable: false },
|
||||
]);
|
||||
|
||||
// Breadcrumbs
|
||||
const breadcrumbs = ref([
|
||||
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
|
||||
]);
|
||||
|
||||
// State untuk tabel dan paginasi
|
||||
const itemsPerPage = ref(10);
|
||||
const search = ref('');
|
||||
const page = ref(1);
|
||||
|
||||
// Computed properties untuk paginasi kustom
|
||||
const pageCount = computed(() => {
|
||||
return Math.ceil(allRuangData.value.length / itemsPerPage.value);
|
||||
});
|
||||
|
||||
const showingEntriesText = computed(() => {
|
||||
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||
const end = Math.min(page.value * itemsPerPage.value, allRuangData.value.length);
|
||||
const total = allRuangData.value.length;
|
||||
return `Showing ${start} to ${end} of ${total} entries`;
|
||||
});
|
||||
|
||||
// State untuk dialog
|
||||
const showDeleteDialog = ref(false);
|
||||
const isEditMode = ref(false);
|
||||
const editedIndex = ref(-1);
|
||||
const editedItem = ref({
|
||||
id: 0,
|
||||
namaKlinik: '',
|
||||
kode: '',
|
||||
namaRuang: '',
|
||||
});
|
||||
|
||||
// Fungsi untuk tombol aksi
|
||||
const viewItem = (item) => {
|
||||
editedItem.value = { ...item };
|
||||
readOnly.value = true;
|
||||
isEditMode.value = false;
|
||||
showForm.value = true;
|
||||
breadcrumbs.value = [
|
||||
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
|
||||
{ title: 'Detail Ruang Klinik', disabled: true, href: '/setting/viewruang' },
|
||||
];
|
||||
};
|
||||
|
||||
const editItem = (item) => {
|
||||
editedIndex.value = allRuangData.value.findIndex(d => d.id === item.id);
|
||||
editedItem.value = { ...item };
|
||||
isEditMode.value = true;
|
||||
readOnly.value = false;
|
||||
showForm.value = true;
|
||||
breadcrumbs.value = [
|
||||
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
|
||||
{ title: 'Edit Ruang Klinik', disabled: true, href: '/setting/editruang' },
|
||||
];
|
||||
};
|
||||
|
||||
const deleteItem = (item) => {
|
||||
editedIndex.value = allRuangData.value.findIndex(d => d.id === item.id);
|
||||
showDeleteDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (editedIndex.value > -1) {
|
||||
allRuangData.value.splice(editedIndex.value, 1);
|
||||
}
|
||||
closeDeleteDialog();
|
||||
};
|
||||
|
||||
const cancelForm = () => {
|
||||
showForm.value = false;
|
||||
isEditMode.value = false;
|
||||
readOnly.value = false;
|
||||
editedItem.value = {
|
||||
id: 0,
|
||||
namaKlinik: '',
|
||||
kode: '',
|
||||
namaRuang: '',
|
||||
};
|
||||
editedIndex.value = -1;
|
||||
breadcrumbs.value = [
|
||||
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
||||
{ title: 'Setting', disabled: false, href: '/setting' },
|
||||
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
|
||||
];
|
||||
};
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
showDeleteDialog.value = false;
|
||||
editedIndex.value = -1;
|
||||
};
|
||||
|
||||
const saveItem = () => {
|
||||
if (isEditMode.value) {
|
||||
Object.assign(allRuangData.value[editedIndex.value], editedItem.value);
|
||||
} else {
|
||||
const newId = allRuangData.value.length > 0 ? Math.max(...allRuangData.value.map(item => item.id)) + 1 : 1;
|
||||
editedItem.value.id = newId;
|
||||
allRuangData.value.push(editedItem.value);
|
||||
}
|
||||
cancelForm();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.master-klinik-ruang-page {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.v-data-table :deep(th) {
|
||||
font-weight: bold !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.v-data-table :deep(td) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.v-btn--icon {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.v-select :deep(.v-field__input) {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,204 +0,0 @@
|
||||
<template>
|
||||
<div class="screen-edit">
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-btn icon="mdi-arrow-left" @click="goBack" class="mr-2"></v-btn>
|
||||
<h2>Edit Screen</h2>
|
||||
</div>
|
||||
|
||||
<!-- Screen Selection Cards -->
|
||||
<div class="screen-cards mb-6">
|
||||
<v-row>
|
||||
<v-col cols="12" md="4" v-for="screen in screens" :key="screen.id">
|
||||
<v-card
|
||||
:class="['screen-card', { active: selectedScreen?.id === screen.id }]"
|
||||
@click="selectScreen(screen)"
|
||||
elevation="2"
|
||||
>
|
||||
<v-card-title class="text-center">
|
||||
{{ screen.nama }}
|
||||
</v-card-title>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Selected Screen Display -->
|
||||
<div v-if="selectedScreen" class="mb-4">
|
||||
<v-chip color="primary" size="large">
|
||||
Selected: {{ selectedScreen.nama }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Clinic Selection Table -->
|
||||
<TabelLayanan
|
||||
v-if="selectedScreen"
|
||||
:headers="computedHeaders"
|
||||
:items="klinikItems"
|
||||
:selectedItems="selectedKlinik"
|
||||
@update:selectedItems="updateSelectedKlinik"
|
||||
/>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex justify-end gap-4 mt-6">
|
||||
<v-btn variant="outlined" @click="cancel">
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn color="warning" @click="submit" :disabled="!selectedScreen">
|
||||
Submit
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import TabelLayanan from '~/components/TabelLayanan.vue';
|
||||
|
||||
// Get route parameter
|
||||
const route = useRoute();
|
||||
const screenId = route.params.id;
|
||||
|
||||
// Data
|
||||
const selectedScreen = ref(null);
|
||||
const selectedKlinik = ref([]);
|
||||
|
||||
const screens = ref([
|
||||
{ id: 1, nama: 'Screen 1' },
|
||||
{ id: 2, nama: 'Screen 2' },
|
||||
{ id: 3, nama: 'Screen 3' }
|
||||
]);
|
||||
|
||||
const klinikItems = ref([
|
||||
{ id: 1, no: 1, nama_klinik: 'ANAK' },
|
||||
{ id: 2, no: 2, nama_klinik: 'ANESTESI' },
|
||||
{ id: 3, no: 3, nama_klinik: 'BEDAH' },
|
||||
{ id: 4, no: 4, nama_klinik: 'GIGI DAN MULUT' },
|
||||
{ id: 5, no: 5, nama_klinik: 'GERIATRI' },
|
||||
{ id: 6, no: 6, nama_klinik: 'GIZI' },
|
||||
{ id: 7, no: 7, nama_klinik: 'IPD' },
|
||||
{ id: 8, no: 8, nama_klinik: 'JANTUNG' },
|
||||
{ id: 9, no: 9, nama_klinik: 'JIWA' },
|
||||
{ id: 10, no: 10, nama_klinik: 'KUL KEL' },
|
||||
{ id: 11, no: 11, nama_klinik: 'KOMPLEMENTER' },
|
||||
{ id: 12, no: 12, nama_klinik: 'MATA' },
|
||||
{ id: 13, no: 13, nama_klinik: 'SARAF' },
|
||||
{ id: 14, no: 14, nama_klinik: 'KANDUNGAN' },
|
||||
{ id: 15, no: 15, nama_klinik: 'ONKOLOGI' },
|
||||
{ id: 16, no: 16, nama_klinik: 'PARU' },
|
||||
{ id: 17, no: 17, nama_klinik: 'RADIOTERAPI' },
|
||||
{ id: 18, no: 18, nama_klinik: 'REHAB MEDIK' },
|
||||
{ id: 19, no: 19, nama_klinik: 'THT' },
|
||||
{ id: 20, no: 20, nama_klinik: 'MCU' },
|
||||
{ id: 21, no: 21, nama_klinik: 'KEMOTERAPI' },
|
||||
{ id: 22, no: 22, nama_klinik: 'R. TINDAKAN' },
|
||||
{ id: 23, no: 23, nama_klinik: 'HOM' }
|
||||
]);
|
||||
|
||||
// Computed headers based on selected screen
|
||||
const computedHeaders = computed(() => {
|
||||
return [
|
||||
{ title: 'No', key: 'no', sortable: false, width: '80px' },
|
||||
{ title: 'Nama Klinik', key: 'nama_klinik', sortable: true },
|
||||
{ title: 'Pilih', key: 'pilih', sortable: false, width: '100px' }
|
||||
];
|
||||
});
|
||||
|
||||
// Methods
|
||||
const selectScreen = (screen) => {
|
||||
selectedScreen.value = screen;
|
||||
loadScreenData(screen.id);
|
||||
};
|
||||
|
||||
const loadScreenData = (screenId) => {
|
||||
// Simulate loading existing selections for the screen
|
||||
switch(parseInt(screenId)) {
|
||||
case 1:
|
||||
selectedKlinik.value = [1, 2, 3, 4, 5, 6, 7, 8]; // ANAK, ANESTESI, etc.
|
||||
break;
|
||||
case 2:
|
||||
selectedKlinik.value = [9, 10, 11, 12, 13, 14, 15, 16]; // JIWA, KUL KEL, etc.
|
||||
break;
|
||||
case 3:
|
||||
selectedKlinik.value = [17, 18, 19, 20, 21, 22, 23]; // RADIOTERAPI, REHAB MEDIK, etc.
|
||||
break;
|
||||
default:
|
||||
selectedKlinik.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const updateSelectedKlinik = (newSelection) => {
|
||||
selectedKlinik.value = newSelection;
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!selectedScreen.value) {
|
||||
alert('Please select a screen first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate API call to save the configuration
|
||||
const data = {
|
||||
screenId: selectedScreen.value.id,
|
||||
selectedKlinik: selectedKlinik.value
|
||||
};
|
||||
|
||||
console.log('Saving configuration:', data);
|
||||
alert('Configuration saved successfully!');
|
||||
goBack();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
goBack();
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
navigateTo('/setting/screen');
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Auto-select screen based on route parameter
|
||||
if (screenId) {
|
||||
const screen = screens.value.find(s => s.id == screenId);
|
||||
if (screen) {
|
||||
selectScreen(screen);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.screen-edit {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.screen-cards {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.screen-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.screen-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.screen-card.active {
|
||||
border-color: #1976d2;
|
||||
background-color: #d4d4d4;
|
||||
}
|
||||
|
||||
.screen-card .v-card-title {
|
||||
font-weight: 600;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,253 +0,0 @@
|
||||
<template>
|
||||
<div class="screen-list">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-monitor</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Screen</h1>
|
||||
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- <v-chip color="success" variant="flat" class="mr-2">
|
||||
Total {{ totalPasien }} Pasien
|
||||
</v-chip> -->
|
||||
<v-chip color="white" variant="flat" class="text-primary" to="/Setting/screen/edit/1">
|
||||
Edit Screen
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="d-flex justify-space-between align-center mb-4">
|
||||
<h2>Screen</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<v-btn color="primary" variant="outlined" prepend-icon="mdi-eye">
|
||||
View
|
||||
</v-btn>
|
||||
<v-btn color="warning" prepend-icon="mdi-pencil">
|
||||
Edit
|
||||
</v-btn>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<span>Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
style="width: 80px"
|
||||
></v-select>
|
||||
<span>entries</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center gap-2">
|
||||
<span>Search:</span>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="width: 200px"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="screenItems"
|
||||
:items-per-page="itemsPerPage"
|
||||
:search="search"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.no="{ index }">
|
||||
{{ index + 1 }}
|
||||
</template>
|
||||
|
||||
<template v-slot:item.klinik="{ item }">
|
||||
<div class="klinik-tags">
|
||||
<v-chip
|
||||
v-for="klinik in item.klinik"
|
||||
:key="klinik"
|
||||
size="small"
|
||||
class="ma-1"
|
||||
color="red"
|
||||
text-color="white"
|
||||
>
|
||||
{{ klinik }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
color="#ff9248"
|
||||
@click="editScreen(item)"
|
||||
style="color: white"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="d-flex justify-space-between align-center mt-4">
|
||||
<div>
|
||||
Showing {{ currentPageStart }} to {{ currentPageEnd }} of
|
||||
{{ totalItems }} entries
|
||||
</div>
|
||||
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="totalPages"
|
||||
:total-visible="5"
|
||||
></v-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
// Data
|
||||
const search = ref("");
|
||||
const itemsPerPage = ref(10);
|
||||
const currentPage = ref(1);
|
||||
|
||||
const headers = [
|
||||
{ title: "No", key: "no", sortable: false, width: "60px" },
|
||||
{ title: "Nama Screen", key: "nama_screen", sortable: true },
|
||||
{ title: "Klinik", key: "klinik", sortable: false },
|
||||
{ title: "Actions", key: "actions", sortable: false, width: "100px" },
|
||||
];
|
||||
|
||||
const screenItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
nama_screen: "Layar Screen 1",
|
||||
klinik: [
|
||||
"ANAK",
|
||||
"ANESTESI",
|
||||
"BEDAH",
|
||||
"GIGI DAN MULUT",
|
||||
"GERIATRI",
|
||||
"GIZI",
|
||||
"IPD",
|
||||
"JANTUNG",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nama_screen: "Layar Screen 2",
|
||||
klinik: [
|
||||
"JIWA",
|
||||
"KUL KEL",
|
||||
"KOMPLEMENTER",
|
||||
"MATA",
|
||||
"SARAF",
|
||||
"KANDUNGAN",
|
||||
"ONKOLOGI",
|
||||
"PARU",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
nama_screen: "Layar Screen 3",
|
||||
klinik: [
|
||||
"RADIOTERAPI",
|
||||
"REHAB MEDIK",
|
||||
"THT",
|
||||
"MCU",
|
||||
"KEMOTERAPI",
|
||||
"R. TINDAKAN",
|
||||
"HOM",
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Computed
|
||||
const totalItems = computed(() => screenItems.value.length);
|
||||
const totalPages = computed(() =>
|
||||
Math.ceil(totalItems.value / itemsPerPage.value)
|
||||
);
|
||||
const currentPageStart = computed(
|
||||
() => (currentPage.value - 1) * itemsPerPage.value + 1
|
||||
);
|
||||
const currentPageEnd = computed(() =>
|
||||
Math.min(currentPage.value * itemsPerPage.value, totalItems.value)
|
||||
);
|
||||
|
||||
// Methods
|
||||
const editScreen = (item) => {
|
||||
navigateTo(`/setting/screen/edit/${item.id}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
.screen-list {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.klinik-tags {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,496 @@
|
||||
<template>
|
||||
<div class="user-login-page pa-4">
|
||||
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
|
||||
<template v-slot:divider>
|
||||
<v-icon icon="mdi-chevron-right"></v-icon>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
|
||||
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
|
||||
<v-card-title class="text-h5 font-weight-bold mb-4">
|
||||
{{ isEditMode ? 'Edit Pengguna' : readOnly ? 'Detail Pengguna' : 'Tambah Pengguna' }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Nama Lengkap</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.namaLengkap"
|
||||
placeholder="Masukkan Nama Lengkap"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Nama User</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.namaUser"
|
||||
placeholder="Masukkan Nama User"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Tipe User</v-label>
|
||||
<v-select
|
||||
v-model="editedItem.tipeUser"
|
||||
:items="['Super Admin', 'Admin', 'Loket', 'Klinik', 'Admin Barcode', 'INOVA', 'Ranap', 'Report Only', 'Farmasi', 'Manager']"
|
||||
placeholder="Pilih Tipe User"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Keterangan</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.keterangan"
|
||||
placeholder="Masukkan Keterangan"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Roles</v-label>
|
||||
<v-select
|
||||
v-model="editedItem.roles"
|
||||
:items="availableRoles"
|
||||
multiple
|
||||
chips
|
||||
placeholder="Pilih Roles Pengguna"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-label class="font-weight-bold">Groups</v-label>
|
||||
<v-select
|
||||
v-model="editedItem.groups"
|
||||
:items="availableGroups"
|
||||
multiple
|
||||
chips
|
||||
placeholder="Pilih Groups Pengguna"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
:readonly="readOnly"
|
||||
></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="!readOnly && !isEditMode">
|
||||
<v-col cols="12">
|
||||
<v-label class="font-weight-bold">Password</v-label>
|
||||
<v-text-field
|
||||
v-model="editedItem.password"
|
||||
placeholder="Masukkan Password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-start pa-4">
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
variant="flat"
|
||||
class="text-capitalize rounded-lg mr-2"
|
||||
@click="cancelForm"
|
||||
>
|
||||
{{ readOnly ? 'Tutup' : 'Batal' }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!readOnly"
|
||||
color="orange-darken-2"
|
||||
variant="flat"
|
||||
class="text-capitalize rounded-lg"
|
||||
@click="saveItem"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<div v-else>
|
||||
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
|
||||
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
|
||||
<v-icon icon="mdi-account-group-outline" class="mr-2" size="40"></v-icon>
|
||||
<span>User Login</span>
|
||||
</v-card-title>
|
||||
<v-btn
|
||||
color="success"
|
||||
prepend-icon="mdi-plus"
|
||||
rounded
|
||||
class="text-capitalize"
|
||||
@click="showAddForm"
|
||||
>
|
||||
Tambah User
|
||||
</v-btn>
|
||||
</v-card>
|
||||
|
||||
<v-card class="pa-4 rounded-lg elevation-2">
|
||||
<v-card-text>
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2 text-subtitle-1">Show</span>
|
||||
<v-select
|
||||
v-model="itemsPerPage"
|
||||
:items="[10, 25, 50, 100]"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 80px;"
|
||||
rounded
|
||||
class="mr-2"
|
||||
></v-select>
|
||||
<span class="text-subtitle-1">entries</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="Search"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
rounded
|
||||
clearable
|
||||
></v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="allUserData"
|
||||
:search="search"
|
||||
:items-per-page="itemsPerPage"
|
||||
v-model:page="page"
|
||||
class="rounded-lg elevation-0 custom-table"
|
||||
>
|
||||
<template v-slot:[`item.roles`]="{ item }">
|
||||
<v-chip
|
||||
v-for="role in item.roles"
|
||||
:key="role"
|
||||
color="blue-lighten-1"
|
||||
size="small"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ role }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.groups`]="{ item }">
|
||||
<v-chip
|
||||
v-for="group in item.groups"
|
||||
:key="group"
|
||||
color="purple-lighten-1"
|
||||
size="small"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ group }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.actions`]="{ item }">
|
||||
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
|
||||
<v-icon>mdi-eye</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon color="red" size="small" @click="deleteItem(item)">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
|
||||
Tidak ada data yang tersedia.
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<template v-slot:bottom>
|
||||
<v-row class="ma-2">
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
|
||||
{{ showingEntriesText }}
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="pageCount"
|
||||
rounded="circle"
|
||||
:total-visible="5"
|
||||
></v-pagination>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Custom Modal/Dialog for Confirmation -->
|
||||
<v-dialog v-model="showDeleteDialog" max-width="400px">
|
||||
<v-card class="pa-4 rounded-lg">
|
||||
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
|
||||
<v-card-text>Apakah Anda yakin ingin menghapus data **{{ itemToDelete?.namaLengkap }}**?</v-card-text>
|
||||
<v-card-actions class="d-flex justify-end">
|
||||
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
|
||||
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar
|
||||
v-model="snackbar.show"
|
||||
:color="snackbar.color"
|
||||
:timeout="snackbar.timeout"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// --- NEW DATA LISTS FOR SELECTION ---
|
||||
const availableRoles = [
|
||||
'User_Standard', 'User_Report', 'User_Klinik', 'User_Farmasi',
|
||||
'Admin_UserManagement', 'Admin_System', 'Manager_View'
|
||||
];
|
||||
const availableGroups = [
|
||||
'/RS/Klinik', '/RS/Loket', '/RS/Farmasi', '/RS/IT', '/RS/Management'
|
||||
];
|
||||
// ------------------------------------
|
||||
|
||||
// Define the core user data structure (without IDs initially)
|
||||
const rawUserData = [
|
||||
{ namaLengkap: 'LOKET 1', namaUser: 'loket1', tipeUser: 'Loket', keterangan: 'Loket 1', roles: ['User_Standard'], groups: ['/RS/Loket'] },
|
||||
{ namaLengkap: 'LOKET 2', namaUser: 'loket2', tipeUser: 'Loket', keterangan: 'Loket 2', roles: ['User_Standard'], groups: ['/RS/Loket'] },
|
||||
{ namaLengkap: 'LOKET 3', namaUser: 'loket3', tipeUser: 'Loket', keterangan: 'Loket 3', roles: ['User_Standard'], groups: ['/RS/Loket'] },
|
||||
{ namaLengkap: 'ANAK', namaUser: 'anak', tipeUser: 'Klinik', keterangan: 'ANAK', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
|
||||
{ namaLengkap: 'ADMIN PRAM', namaUser: 'adminpram', tipeUser: 'Admin', keterangan: 'Administrator Utama', roles: ['Admin_UserManagement', 'User_Report'], groups: ['/RS/IT'] },
|
||||
{ namaLengkap: 'Report Only', namaUser: 'laporan', tipeUser: 'Report Only', keterangan: 'Hanya melihat laporan', roles: ['User_Report'], groups: ['/RS/Management'] },
|
||||
{ namaLengkap: 'Farmasi Utama', namaUser: 'farmasi_utama', tipeUser: 'Farmasi', keterangan: 'Apoteker Penanggung Jawab', roles: ['User_Farmasi'], groups: ['/RS/Farmasi'] },
|
||||
{ namaLengkap: 'Super Admin User', namaUser: 'superadmin', tipeUser: 'Super Admin', keterangan: 'Full Control System', roles: ['Admin_System', 'Admin_UserManagement', 'User_Standard'], groups: ['/RS/IT', '/RS/Management'] },
|
||||
// Adding more generic data to make the list longer and sequential
|
||||
{ namaLengkap: 'Klinik Umum', namaUser: 'klinik_umum', tipeUser: 'Klinik', keterangan: 'Dokter Umum', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
|
||||
{ namaLengkap: 'Manajer Keuangan', namaUser: 'manager_keu', tipeUser: 'Manager', keterangan: 'Pengelola Anggaran', roles: ['Manager_View', 'User_Report'], groups: ['/RS/Management'] },
|
||||
];
|
||||
|
||||
// Map over the raw data to assign sequential IDs starting from 1
|
||||
const allUserData = ref(rawUserData.map((item, index) => ({
|
||||
...item,
|
||||
id: index + 1
|
||||
})));
|
||||
|
||||
// State untuk menampilkan/menyembunyikan formulir
|
||||
const showForm = ref(false);
|
||||
const readOnly = ref(false);
|
||||
|
||||
const headers = ref([
|
||||
{ title: 'No', key: 'id' },
|
||||
{ title: 'Nama Lengkap', key: 'namaLengkap', sortable: true },
|
||||
{ title: 'Nama User', key: 'namaUser', sortable: true },
|
||||
{ title: 'Tipe User', key: 'tipeUser', sortable: true },
|
||||
{ title: 'Roles', key: 'roles', sortable: false },
|
||||
{ title: 'Groups', key: 'groups', sortable: false },
|
||||
{ title: 'Keterangan', key: 'keterangan', sortable: true },
|
||||
{ title: 'Aksi', key: 'actions', sortable: false },
|
||||
]);
|
||||
|
||||
// Breadcrumbs
|
||||
const breadcrumbs = ref([
|
||||
{ title: 'Dashboard', disabled: false, href: '#/dashboard' },
|
||||
{ title: 'Setting', disabled: false, href: '#/setting' },
|
||||
{ title: 'User Login', disabled: false, href: '#/setting/userlogin' },
|
||||
]);
|
||||
|
||||
// State untuk tabel dan paginasi
|
||||
const itemsPerPage = ref(10);
|
||||
const search = ref('');
|
||||
const page = ref(1);
|
||||
const itemToDelete = ref(null);
|
||||
|
||||
// State untuk dialog dan form
|
||||
const showDeleteDialog = ref(false);
|
||||
const isEditMode = ref(false);
|
||||
const editedIndex = ref(-1);
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success',
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
|
||||
// Updated `editedItem` structure with new fields
|
||||
const editedItem = ref({
|
||||
id: 0,
|
||||
namaLengkap: '',
|
||||
namaUser: '',
|
||||
tipeUser: '',
|
||||
keterangan: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
groups: []
|
||||
});
|
||||
|
||||
// Computed properties untuk paginasi kustom
|
||||
const pageCount = computed(() => {
|
||||
return Math.ceil(allUserData.value.length / itemsPerPage.value);
|
||||
});
|
||||
|
||||
const showingEntriesText = computed(() => {
|
||||
const start = (page.value - 1) * itemsPerPage.value + 1;
|
||||
const end = Math.min(page.value * itemsPerPage.value, allUserData.value.length);
|
||||
const total = allUserData.value.length;
|
||||
// Handle case where total is 0 (no data)
|
||||
if (total === 0) return 'Showing 0 to 0 of 0 entries';
|
||||
return `Showing ${start} to ${end} of ${total} entries`;
|
||||
});
|
||||
|
||||
// FUNCTIONS
|
||||
|
||||
const resetForm = () => {
|
||||
editedItem.value = {
|
||||
id: 0,
|
||||
namaLengkap: '',
|
||||
namaUser: '',
|
||||
tipeUser: '',
|
||||
keterangan: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
groups: []
|
||||
};
|
||||
};
|
||||
|
||||
const showAddForm = () => {
|
||||
resetForm();
|
||||
isEditMode.value = false;
|
||||
readOnly.value = false;
|
||||
showForm.value = true;
|
||||
};
|
||||
|
||||
const viewItem = (item) => {
|
||||
// We copy the item data for view mode
|
||||
editedItem.value = Object.assign({}, item);
|
||||
isEditMode.value = false;
|
||||
readOnly.value = true;
|
||||
showForm.value = true;
|
||||
};
|
||||
|
||||
const editItem = (item) => {
|
||||
// Find the index of the item in the actual array
|
||||
editedIndex.value = allUserData.value.findIndex(data => data.id === item.id);
|
||||
// Copy the item data to the editedItem
|
||||
editedItem.value = Object.assign({}, item);
|
||||
// Clear password field for security (will only be set if user types a new one)
|
||||
editedItem.value.password = '';
|
||||
isEditMode.value = true;
|
||||
readOnly.value = false;
|
||||
showForm.value = true;
|
||||
};
|
||||
|
||||
const saveItem = () => {
|
||||
// Basic validation (can be expanded)
|
||||
if (!editedItem.value.namaLengkap || !editedItem.value.namaUser || !editedItem.value.tipeUser) {
|
||||
snackbar.value = { show: true, message: 'Nama Lengkap, Nama User, dan Tipe User wajib diisi!', color: 'error', timeout: 3000 };
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove password from the final object for display purposes,
|
||||
// as it should be securely handled in a real backend API.
|
||||
const { password, ...itemData } = editedItem.value;
|
||||
|
||||
if (isEditMode.value) {
|
||||
// Edit existing item
|
||||
if (editedIndex.value > -1) {
|
||||
Object.assign(allUserData.value[editedIndex.value], itemData);
|
||||
snackbar.value = { show: true, message: 'Data pengguna berhasil diperbarui!', color: 'success', timeout: 3000 };
|
||||
}
|
||||
} else {
|
||||
// Add new item: find the highest current ID and add 1
|
||||
const maxId = allUserData.value.length > 0 ? Math.max(...allUserData.value.map(item => item.id)) : 0;
|
||||
itemData.id = maxId + 1;
|
||||
allUserData.value.push(itemData);
|
||||
snackbar.value = { show: true, message: 'Pengguna baru berhasil ditambahkan!', color: 'success', timeout: 3000 };
|
||||
}
|
||||
|
||||
cancelForm();
|
||||
};
|
||||
|
||||
const deleteItem = (item) => {
|
||||
itemToDelete.value = item;
|
||||
showDeleteDialog.value = true;
|
||||
};
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
showDeleteDialog.value = false;
|
||||
itemToDelete.value = null;
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (itemToDelete.value) {
|
||||
// Find the index of the item to delete
|
||||
const index = allUserData.value.findIndex(data => data.id === itemToDelete.value.id);
|
||||
if (index > -1) {
|
||||
// Remove the item
|
||||
allUserData.value.splice(index, 1);
|
||||
|
||||
// Re-index the remaining items to keep the 'id' column sequential visually
|
||||
allUserData.value = allUserData.value.map((item, i) => ({
|
||||
...item,
|
||||
id: i + 1
|
||||
}));
|
||||
|
||||
snackbar.value = { show: true, message: 'Data pengguna berhasil dihapus!', color: 'warning', timeout: 3000 };
|
||||
}
|
||||
}
|
||||
closeDeleteDialog();
|
||||
};
|
||||
|
||||
const cancelForm = () => {
|
||||
showForm.value = false;
|
||||
resetForm();
|
||||
editedIndex.value = -1;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Custom Table Styling for better visual separation */
|
||||
.custom-table :deep(table) {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.custom-table :deep(th) {
|
||||
background-color: #f5f5f5 !important;
|
||||
font-weight: bold;
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
}
|
||||
|
||||
.custom-table :deep(td) {
|
||||
padding-top: 12px !important;
|
||||
padding-bottom: 12px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,308 +0,0 @@
|
||||
<!-- page edit id data pasien -->
|
||||
<template>
|
||||
<div class="edit-pasien">
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-btn icon="mdi-arrow-left" @click="goBack" class="mr-2"></v-btn>
|
||||
<h2>Edit Pasien</h2>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<v-card class="pa-6" elevation="2">
|
||||
<v-form ref="form" v-model="valid">
|
||||
<v-row>
|
||||
<!-- Tanggal Daftar -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="formData.tanggal_daftar"
|
||||
label="Tanggal Daftar"
|
||||
variant="outlined"
|
||||
readonly
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<!-- Tanggal Periksa -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="formData.tanggal_periksa"
|
||||
label="Tanggal Periksa"
|
||||
variant="outlined"
|
||||
type="date"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<!-- No Barcode -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="formData.no_barcode"
|
||||
label="No Barcode"
|
||||
variant="outlined"
|
||||
readonly
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<!-- No Antrian -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="formData.no_antrian"
|
||||
label="No Antrian"
|
||||
variant="outlined"
|
||||
readonly
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<!-- No Klinik -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="formData.no_klinik"
|
||||
label="No Klinik"
|
||||
variant="outlined"
|
||||
placeholder="Belum Mendapatkan Antrian Klinik"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<!-- No Rekammedik -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="formData.no_rekammedik"
|
||||
label="No Rekammedik"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
|
||||
<!-- Klinik -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="formData.klinik"
|
||||
label="Klinik"
|
||||
:items="klinikOptions"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
></v-select>
|
||||
</v-col>
|
||||
|
||||
<!-- Shift -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="formData.shift"
|
||||
label="Shift"
|
||||
:items="shiftOptions"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
></v-select>
|
||||
</v-col>
|
||||
|
||||
<!-- Keterangan -->
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="formData.keterangan"
|
||||
label="Keterangan"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
readonly
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<span class="text-red font-weight-bold">
|
||||
{{ formData.keterangan }}
|
||||
</span>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
|
||||
<!-- Pembayaran -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="formData.pembayaran"
|
||||
label="Pembayaran"
|
||||
:items="pembayaranOptions"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
></v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex justify-end gap-4 mt-6">
|
||||
<v-btn variant="outlined" @click="cancel">
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
@click="submit"
|
||||
:disabled="!valid"
|
||||
:loading="loading"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const route = useRoute();
|
||||
const pasienId = route.params.id;
|
||||
|
||||
// Form data
|
||||
const valid = ref(false);
|
||||
const loading = ref(false);
|
||||
const form = ref(null);
|
||||
|
||||
const formData = ref({
|
||||
tanggal_daftar: '',
|
||||
tanggal_periksa: '',
|
||||
no_barcode: '',
|
||||
no_antrian: '',
|
||||
no_klinik: '',
|
||||
no_rekammedik: '',
|
||||
klinik: '',
|
||||
shift: '',
|
||||
keterangan: '',
|
||||
pembayaran: ''
|
||||
});
|
||||
|
||||
// Options
|
||||
const klinikOptions = [
|
||||
'HOM',
|
||||
'KANDUNGAN',
|
||||
'ANAK',
|
||||
'IPD',
|
||||
'JIWA',
|
||||
'KUL KEL',
|
||||
'KOMPLEMENTER',
|
||||
'MATA',
|
||||
'SARAF',
|
||||
'ONKOLOGI',
|
||||
'PARU',
|
||||
'RADIOTERAPI',
|
||||
'REHAB MEDIK',
|
||||
'THT',
|
||||
'MCU',
|
||||
'KEMOTERAPI',
|
||||
'R. TINDAKAN',
|
||||
'ANESTESI',
|
||||
'BEDAH',
|
||||
'GIGI DAN MULUT',
|
||||
'GERIATRI',
|
||||
'GIZI',
|
||||
'JANTUNG'
|
||||
];
|
||||
|
||||
const shiftOptions = [
|
||||
'Shift 1 = Mulai Pukul 07:00',
|
||||
'Shift 2 = Mulai Pukul 13:00',
|
||||
'Shift 3 = Mulai Pukul 19:00'
|
||||
];
|
||||
|
||||
const pembayaranOptions = [
|
||||
'JKN',
|
||||
'UMUM',
|
||||
'ASURANSI',
|
||||
'KARYAWAN'
|
||||
];
|
||||
|
||||
// Validation rules
|
||||
const rules = {
|
||||
required: value => !!value || 'Field ini wajib diisi'
|
||||
};
|
||||
|
||||
// Mock data for editing
|
||||
const mockPasienData = {
|
||||
1: {
|
||||
tanggal_daftar: '2025-08-13 00:00:03',
|
||||
tanggal_periksa: '2025-08-27',
|
||||
no_barcode: '25027100007',
|
||||
no_antrian: 'IP1001',
|
||||
no_klinik: 'Belum Mendapatkan Antrian Klinik',
|
||||
no_rekammedik: '11555500',
|
||||
klinik: 'IPD',
|
||||
shift: 'Shift 1 = Mulai Pukul 07:00',
|
||||
keterangan: 'PENDAFTARAN ONLINE',
|
||||
pembayaran: 'JKN'
|
||||
},
|
||||
2: {
|
||||
tanggal_daftar: '2025-07-24 13:50:01',
|
||||
tanggal_periksa: '2025-08-27',
|
||||
no_barcode: '25027100002',
|
||||
no_antrian: 'OB1001',
|
||||
no_klinik: '',
|
||||
no_rekammedik: '',
|
||||
klinik: 'KANDUNGAN',
|
||||
shift: 'Shift 1 = Mulai Pukul 07:00',
|
||||
keterangan: '',
|
||||
pembayaran: 'JKN'
|
||||
}
|
||||
};
|
||||
|
||||
// Methods
|
||||
const loadPasienData = () => {
|
||||
const data = mockPasienData[pasienId];
|
||||
if (data) {
|
||||
formData.value = { ...data };
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!valid.value) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
console.log('Updating pasien data:', formData.value);
|
||||
|
||||
// Show success message (you can use a toast/snackbar here)
|
||||
alert('Data pasien berhasil diperbarui!');
|
||||
|
||||
// Navigate back to list
|
||||
goBack();
|
||||
} catch (error) {
|
||||
console.error('Error updating pasien:', error);
|
||||
alert('Gagal memperbarui data pasien!');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
goBack();
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
navigateTo('/data-pasien');
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadPasienData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edit-pasien {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.text-red {
|
||||
color: #d32f2f;
|
||||
}
|
||||
</style>
|
||||
@@ -1,437 +0,0 @@
|
||||
<!-- pages/data-pasien.vue -->
|
||||
<template>
|
||||
<div class="data-pasien-container">
|
||||
<div class="data-pasien">
|
||||
<!-- Header -->
|
||||
<!-- <div class="d-flex justify-space-between align-center mb-4">
|
||||
<h2>Data Pasien</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<v-btn
|
||||
color="#ff9248"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="addPatient"
|
||||
style="color: white"
|
||||
>
|
||||
Tambah Pasien
|
||||
</v-btn>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">Data Pasien</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-btn
|
||||
color="#ff9248"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="addPatient"
|
||||
style="color: white"
|
||||
>
|
||||
Tambah Pasien
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menggunakan komponen TabelData -->
|
||||
<TabelData
|
||||
:headers="headers"
|
||||
:items="pasienItems"
|
||||
title="Daftar Data Pasien"
|
||||
:show-search="true"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<div class="d-flex gap-1">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="#ff9248"
|
||||
variant="flat"
|
||||
@click="viewPasien(item)"
|
||||
style="color: white"
|
||||
>VIEW</v-btn
|
||||
>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="grey-lighten-4"
|
||||
variant="flat"
|
||||
@click="editPasien(item)"
|
||||
>EDIT</v-btn
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</TabelData>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import TabelData from "@/components/TabelData.vue";
|
||||
|
||||
// Headers untuk tabel
|
||||
const headers = [
|
||||
{ title: "No", key: "no", sortable: false, width: "60px" },
|
||||
{ title: "Tgl Daftar", key: "tgl_daftar", sortable: true, width: "140px" },
|
||||
{ title: "No Barcode", key: "no_barcode", sortable: true, width: "120px" },
|
||||
{ title: "No Antrian", key: "no_antrian", sortable: true, width: "100px" },
|
||||
{ title: "No Klinik", key: "no_klinik", sortable: true, width: "100px" },
|
||||
{ title: "RM", key: "rm", sortable: true, width: "100px" },
|
||||
{ title: "Klinik", key: "klinik", sortable: true, width: "120px" },
|
||||
{ title: "Shift", key: "shift", sortable: true, width: "80px" },
|
||||
{ title: "Ket", key: "keterangan", sortable: false, width: "150px" },
|
||||
{ title: "Pembayaran", key: "pembayaran", sortable: true, width: "100px" },
|
||||
{ title: "Status", key: "status", sortable: true, width: "120px" },
|
||||
{ title: "Aksi", key: "aksi", sortable: false, width: "100px" },
|
||||
];
|
||||
|
||||
// Data pasien dengan informasi lengkap untuk edit
|
||||
const pasienItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
no: 1,
|
||||
tgl_daftar: "2025-07-15 13:47:33",
|
||||
no_barcode: "25027100001",
|
||||
no_antrian: "HO1001",
|
||||
no_klinik: "",
|
||||
rm: "",
|
||||
klinik: "HOM",
|
||||
shift: "Shift 1",
|
||||
keterangan: "",
|
||||
pembayaran: "JKN",
|
||||
status: "Tunggu Daftar",
|
||||
// Data tambahan untuk form edit
|
||||
editData: {
|
||||
tanggal_daftar: "2025-07-15 13:47:33",
|
||||
tanggal_periksa: "2025-08-27",
|
||||
no_barcode: "25027100001",
|
||||
no_antrian: "HO1001",
|
||||
no_klinik: "Belum Mendapatkan Antrian Klinik",
|
||||
no_rekammedik: "",
|
||||
klinik: "HOM",
|
||||
shift: "Shift 1 = Mulai Pukul 07:00",
|
||||
keterangan: "",
|
||||
pembayaran: "JKN",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
no: 2,
|
||||
tgl_daftar: "2025-07-24 13:50:01",
|
||||
no_barcode: "25027100002",
|
||||
no_antrian: "OB1001",
|
||||
no_klinik: "",
|
||||
rm: "",
|
||||
klinik: "KANDUNGAN",
|
||||
shift: "Shift 1",
|
||||
keterangan: "",
|
||||
pembayaran: "JKN",
|
||||
status: "Barcode",
|
||||
editData: {
|
||||
tanggal_daftar: "2025-07-24 13:50:01",
|
||||
tanggal_periksa: "2025-08-27",
|
||||
no_barcode: "25027100002",
|
||||
no_antrian: "OB1001",
|
||||
no_klinik: "",
|
||||
no_rekammedik: "",
|
||||
klinik: "KANDUNGAN",
|
||||
shift: "Shift 1 = Mulai Pukul 07:00",
|
||||
keterangan: "",
|
||||
pembayaran: "JKN",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
no: 3,
|
||||
tgl_daftar: "2025-07-24 13:50:37",
|
||||
no_barcode: "25027100003",
|
||||
no_antrian: "OB1002",
|
||||
no_klinik: "",
|
||||
rm: "",
|
||||
klinik: "KANDUNGAN",
|
||||
shift: "Shift 1",
|
||||
keterangan: "",
|
||||
pembayaran: "JKN",
|
||||
status: "Barcode",
|
||||
editData: {
|
||||
tanggal_daftar: "2025-07-24 13:50:37",
|
||||
tanggal_periksa: "2025-08-27",
|
||||
no_barcode: "25027100003",
|
||||
no_antrian: "OB1002",
|
||||
no_klinik: "",
|
||||
no_rekammedik: "",
|
||||
klinik: "KANDUNGAN",
|
||||
shift: "Shift 1 = Mulai Pukul 07:00",
|
||||
keterangan: "",
|
||||
pembayaran: "JKN",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
no: 4,
|
||||
tgl_daftar: "2025-07-28 08:18:20",
|
||||
no_barcode: "25027100004",
|
||||
no_antrian: "AN1001",
|
||||
no_klinik: "",
|
||||
rm: "",
|
||||
klinik: "ANAK",
|
||||
shift: "Shift 1",
|
||||
keterangan: "",
|
||||
pembayaran: "JKN",
|
||||
status: "Barcode",
|
||||
editData: {
|
||||
tanggal_daftar: "2025-07-28 08:18:20",
|
||||
tanggal_periksa: "2025-08-27",
|
||||
no_barcode: "25027100004",
|
||||
no_antrian: "AN1001",
|
||||
no_klinik: "",
|
||||
no_rekammedik: "",
|
||||
klinik: "ANAK",
|
||||
shift: "Shift 1 = Mulai Pukul 07:00",
|
||||
keterangan: "",
|
||||
pembayaran: "JKN",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
no: 5,
|
||||
tgl_daftar: "2025-08-13 00:00:02",
|
||||
no_barcode: "25027100005",
|
||||
no_antrian: "HO1002",
|
||||
no_klinik: "",
|
||||
rm: "11412684",
|
||||
klinik: "HOM",
|
||||
shift: "Shift 1",
|
||||
keterangan: "Online 25#27100005",
|
||||
pembayaran: "JKN",
|
||||
status: "Tunggu Daftar",
|
||||
editData: {
|
||||
tanggal_daftar: "2025-08-13 00:00:02",
|
||||
tanggal_periksa: "2025-08-27",
|
||||
no_barcode: "25027100005",
|
||||
no_antrian: "HO1002",
|
||||
no_klinik: "Belum Mendapatkan Antrian Klinik",
|
||||
no_rekammedik: "11412684",
|
||||
klinik: "HOM",
|
||||
shift: "Shift 1 = Mulai Pukul 07:00",
|
||||
keterangan: "Online 25#27100005",
|
||||
pembayaran: "JKN",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
no: 6,
|
||||
tgl_daftar: "2025-08-13 00:00:03",
|
||||
no_barcode: "25027100006",
|
||||
no_antrian: "HO1003",
|
||||
no_klinik: "",
|
||||
rm: "",
|
||||
klinik: "HOM",
|
||||
shift: "Shift 1",
|
||||
keterangan: "Online 25#27100006",
|
||||
pembayaran: "JKN",
|
||||
status: "Tunggu Daftar",
|
||||
editData: {
|
||||
tanggal_daftar: "2025-08-13 00:00:03",
|
||||
tanggal_periksa: "2025-08-27",
|
||||
no_barcode: "25027100006",
|
||||
no_antrian: "HO1003",
|
||||
no_klinik: "Belum Mendapatkan Antrian Klinik",
|
||||
no_rekammedik: "",
|
||||
klinik: "HOM",
|
||||
shift: "Shift 1 = Mulai Pukul 07:00",
|
||||
keterangan: "Online 25#27100006",
|
||||
pembayaran: "JKN",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
no: 7,
|
||||
tgl_daftar: "2025-08-13 00:00:03",
|
||||
no_barcode: "25027100007",
|
||||
no_antrian: "IP1001",
|
||||
no_klinik: "",
|
||||
rm: "11555500",
|
||||
klinik: "IPD",
|
||||
shift: "Shift 1",
|
||||
keterangan: "Online 25#27100007",
|
||||
pembayaran: "JKN",
|
||||
status: "Tunggu Daftar",
|
||||
editData: {
|
||||
tanggal_daftar: "2025-08-13 00:00:03",
|
||||
tanggal_periksa: "2025-08-27",
|
||||
no_barcode: "25027100007",
|
||||
no_antrian: "IP1001",
|
||||
no_klinik: "Belum Mendapatkan Antrian Klinik",
|
||||
no_rekammedik: "11555500",
|
||||
klinik: "IPD",
|
||||
shift: "Shift 1 = Mulai Pukul 07:00",
|
||||
keterangan: "PENDAFTARAN ONLINE",
|
||||
pembayaran: "JKN",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
no: 8,
|
||||
tgl_daftar: "2025-08-13 00:00:04",
|
||||
no_barcode: "25027100008",
|
||||
no_antrian: "IP1001",
|
||||
no_klinik: "",
|
||||
rm: "11333855",
|
||||
klinik: "IPD",
|
||||
shift: "Shift 1",
|
||||
keterangan: "Online 25#27100008",
|
||||
pembayaran: "JKN",
|
||||
status: "Tunggu Daftar",
|
||||
editData: {
|
||||
tanggal_daftar: "2025-08-13 00:00:04",
|
||||
tanggal_periksa: "2025-08-27",
|
||||
no_barcode: "25027100008",
|
||||
no_antrian: "IP1001",
|
||||
no_klinik: "Belum Mendapatkan Antrian Klinik",
|
||||
no_rekammedik: "11333855",
|
||||
klinik: "IPD",
|
||||
shift: "Shift 1 = Mulai Pukul 07:00",
|
||||
keterangan: "Online 25#27100008",
|
||||
pembayaran: "JKN",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
no: 9,
|
||||
tgl_daftar: "2025-08-13 00:00:04",
|
||||
no_barcode: "25027100009",
|
||||
no_antrian: "IP1001",
|
||||
no_klinik: "",
|
||||
rm: "11565554",
|
||||
klinik: "IPD",
|
||||
shift: "Shift 1",
|
||||
keterangan: "Online 25#27100009",
|
||||
pembayaran: "JKN",
|
||||
status: "Tunggu Daftar",
|
||||
editData: {
|
||||
tanggal_daftar: "2025-08-13 00:00:04",
|
||||
tanggal_periksa: "2025-08-27",
|
||||
no_barcode: "25027100009",
|
||||
no_antrian: "IP1001",
|
||||
no_klinik: "Belum Mendapatkan Antrian Klinik",
|
||||
no_rekammedik: "11565554",
|
||||
klinik: "IPD",
|
||||
shift: "Shift 1 = Mulai Pukul 07:00",
|
||||
keterangan: "Online 25#27100009",
|
||||
pembayaran: "JKN",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
no: 10,
|
||||
tgl_daftar: "2025-08-13 00:00:04",
|
||||
no_barcode: "25027100010",
|
||||
no_antrian: "IP1001",
|
||||
no_klinik: "",
|
||||
rm: "11627608",
|
||||
klinik: "IPD",
|
||||
shift: "Shift 1",
|
||||
keterangan: "Online 25#27100010",
|
||||
pembayaran: "JKN",
|
||||
status: "Tunggu Daftar",
|
||||
editData: {
|
||||
tanggal_daftar: "2025-08-13 00:00:04",
|
||||
tanggal_periksa: "2025-08-27",
|
||||
no_barcode: "25027100010",
|
||||
no_antrian: "IP1001",
|
||||
no_klinik: "Belum Mendapatkan Antrian Klinik",
|
||||
no_rekammedik: "11627608",
|
||||
klinik: "IPD",
|
||||
shift: "Shift 1 = Mulai Pukul 07:00",
|
||||
keterangan: "Online 25#27100010",
|
||||
pembayaran: "JKN",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Methods
|
||||
const addPatient = () => {
|
||||
// Navigate to add patient page
|
||||
navigateTo("/data-pasien/add");
|
||||
};
|
||||
|
||||
const viewPasien = (item) => {
|
||||
// Implement view functionality
|
||||
console.log("View pasien:", item);
|
||||
// You can navigate to a view page or open a modal
|
||||
// navigateTo(`/data-pasien/view/${item.id}`);
|
||||
};
|
||||
|
||||
const editPasien = (item) => {
|
||||
// Navigate to edit page
|
||||
navigateTo(`/data-pasien/edit/${item.id}`);
|
||||
};
|
||||
|
||||
// Provide data globally untuk diakses oleh halaman edit
|
||||
provide("pasienData", pasienItems);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-pasien-container {
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px;
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-right: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.data-pasien {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.gap-1 {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
})
|
||||
|
||||
// Define a type for the data structure you now return from the API
|
||||
interface SessionData {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
// ... add other user fields
|
||||
};
|
||||
status: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenPayload: any;
|
||||
idTokenPayload: any;
|
||||
fullSessionObject: any;
|
||||
}
|
||||
|
||||
const sessionData = ref<SessionData | null>(null);
|
||||
const loading = ref(true);
|
||||
const authError = ref<any>(null);
|
||||
|
||||
|
||||
// Computeds for easy display
|
||||
const sessionExpiresDate = computed(() => {
|
||||
if (!sessionData.value?.expiresAt) return 'N/A';
|
||||
return new Date(sessionData.value.expiresAt).toLocaleString();
|
||||
});
|
||||
|
||||
const sessionCreatedDate = computed(() => {
|
||||
if (!sessionData.value?.createdAt) return 'N/A';
|
||||
return new Date(sessionData.value.createdAt).toLocaleString();
|
||||
});
|
||||
|
||||
const currentDateTime = computed(() => new Date().toLocaleString());
|
||||
|
||||
// Helper to display JSON data nicely
|
||||
const formatJson = (data: any) => {
|
||||
return JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Fetch the enhanced session data from your API
|
||||
const data = await $fetch<SessionData>('/api/auth/session');
|
||||
sessionData.value = data;
|
||||
authError.value = null;
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('Failed to fetch session data:', e);
|
||||
// Store the error status for display
|
||||
authError.value = e.data?.statusMessage || 'Session check failed. Please log in.';
|
||||
sessionData.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto p-4 max-w-4xl">
|
||||
<h1 class="text-3xl font-bold mb-6 border-b pb-2">Complete Session Data Debug Page</h1>
|
||||
|
||||
<div v-if="loading" class="text-center p-8">
|
||||
<p class="text-xl">Loading session data...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="authError" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<strong class="font-bold">Authentication Error:</strong>
|
||||
<span class="block sm:inline">{{ authError }}</span>
|
||||
<p class="mt-2">If you expect to be logged in, the session may have expired or the cookie is missing/invalid.</p>
|
||||
<NuxtLink to="/LoginPage" class="text-blue-600 hover:underline">Go to Login Page</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sessionData">
|
||||
|
||||
<section class="mb-6 border p-4 rounded-lg bg-gray-50">
|
||||
<h2 class="text-xl font-semibold mb-3">Basic User Information</h2>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<p><strong>Name:</strong> {{ sessionData.user.name }}</p>
|
||||
<p><strong>Email:</strong> {{ sessionData.user.email }}</p>
|
||||
<p><strong>Status:</strong> <span class="text-green-600 font-medium">{{ sessionData.status }}</span></p>
|
||||
<p><strong>Session Expires:</strong> {{ sessionExpiresDate }}</p>
|
||||
<p><strong>Created At:</strong> {{ sessionCreatedDate }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-6 border p-4 rounded-lg">
|
||||
<h2 class="text-xl font-semibold mb-3">Token Information (Raw)</h2>
|
||||
<div class="space-y-3">
|
||||
<details>
|
||||
<summary class="cursor-pointer font-medium hover:text-blue-600">ID Token (session.idToken)</summary>
|
||||
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.idToken }}</pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary class="cursor-pointer font-medium hover:text-blue-600">Access Token (session.accessToken)</summary>
|
||||
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.accessToken }}</pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary class="cursor-pointer font-medium hover:text-blue-600">Refresh Token (session.refreshToken)</summary>
|
||||
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.refreshToken || 'N/A' }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-6 border p-4 rounded-lg">
|
||||
<h2 class="text-xl font-semibold mb-3">Parsed Token Payloads</h2>
|
||||
<div class="space-y-3">
|
||||
<details open>
|
||||
<summary class="cursor-pointer font-medium hover:text-blue-600">Access Token Payload</summary>
|
||||
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.accessTokenPayload) }}</pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary class="cursor-pointer font-medium hover:text-blue-600">ID Token Payload</summary>
|
||||
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.idTokenPayload) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-6 border p-4 rounded-lg">
|
||||
<h2 class="text-xl font-semibold mb-3">Complete Raw Session Data (Debug)</h2>
|
||||
<details>
|
||||
<summary class="cursor-pointer font-medium hover:text-blue-600">Full Session Object</summary>
|
||||
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.fullSessionObject) }}</pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="mb-6 p-4 rounded-lg border-t-2 border-dashed">
|
||||
<h2 class="text-xl font-semibold mb-3">Session Timeline</h2>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 bg-black rounded-full"></span>
|
||||
<p><strong>Session Created:</strong> {{ sessionCreatedDate }}</p>
|
||||
</li>
|
||||
<li class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 bg-black rounded-full"></span>
|
||||
<p><strong>Current Time:</strong> {{ currentDateTime }}</p>
|
||||
</li>
|
||||
<li class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 bg-black rounded-full"></span>
|
||||
<p><strong>Session Expires:</strong> {{ sessionExpiresDate }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Optional: Basic styling for better visibility */
|
||||
summary {
|
||||
list-style: none; /* Removes the default arrow */
|
||||
display: block; /* Allows summary to span full width */
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
/* Adds a custom arrow/chevron */
|
||||
summary::before {
|
||||
content: "▶";
|
||||
margin-right: 0.5em;
|
||||
transition: transform 0.2s;
|
||||
display: inline-block;
|
||||
}
|
||||
details[open] summary::before {
|
||||
content: "▼";
|
||||
/* transform: rotate(90deg); */
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,25 @@
|
||||
// server/api/auth/[...].ts
|
||||
import { NuxtAuthHandler } from '#auth'
|
||||
|
||||
export default NuxtAuthHandler({
|
||||
secret: useRuntimeConfig().authSecret,
|
||||
providers: [
|
||||
{
|
||||
id: 'keycloak',
|
||||
name: 'Keycloak',
|
||||
type: 'oidc',
|
||||
issuer: useRuntimeConfig().keycloakIssuer,
|
||||
clientId: useRuntimeConfig().keycloakClientId,
|
||||
clientSecret: useRuntimeConfig().keycloakClientSecret,
|
||||
checks: ['pkce', 'state'],
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name ?? profile.preferred_username,
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
// server/api/auth/keycloak-callback.ts - FIX APPLIED
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const config = useRuntimeConfig();
|
||||
const query = getQuery(event);
|
||||
|
||||
console.log('🔄 === KEYCLOAK CALLBACK STARTED ===');
|
||||
console.log('📋 Query parameters:', query);
|
||||
|
||||
const code = query.code as string;
|
||||
const state = query.state as string;
|
||||
const error = query.error as string;
|
||||
const storedState = getCookie(event, 'oauth_state');
|
||||
|
||||
if (error) {
|
||||
console.error('❌ OAuth error from Keycloak:', error);
|
||||
const errorDescription = query.error_description as string;
|
||||
console.error('❌ Error description:', errorDescription);
|
||||
|
||||
const errorMsg = encodeURIComponent(`Keycloak error: ${error} - ${errorDescription || 'Please try again'}`);
|
||||
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||
}
|
||||
|
||||
console.log('📝 Code received:', !!code);
|
||||
console.log('🎲 State from URL:', state);
|
||||
console.log('🎲 State from cookie:', storedState);
|
||||
console.log('🎲 State validation:', state === storedState);
|
||||
|
||||
if (!state || state !== storedState) {
|
||||
console.error('❌ Invalid state parameter - possible CSRF attack');
|
||||
console.error(' Expected:', storedState);
|
||||
console.error(' Received:', state);
|
||||
|
||||
const errorMsg = encodeURIComponent('Security validation failed. Please try logging in again.');
|
||||
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||
}
|
||||
|
||||
deleteCookie(event, 'oauth_state');
|
||||
|
||||
if (!code) {
|
||||
console.error('❌ Authorization code not provided');
|
||||
const errorMsg = encodeURIComponent('No authorization code received from Keycloak.');
|
||||
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||
}
|
||||
|
||||
const tokenUrl = `${config.keycloakIssuer}/protocol/openid-connect/token`;
|
||||
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`;
|
||||
|
||||
// ... (Token exchange logic remains the same) ...
|
||||
const tokenPayload = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: config.keycloakClientId,
|
||||
client_secret: config.keycloakClientSecret,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: tokenPayload,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
console.error('❌ Token exchange failed:', errorText);
|
||||
const errorMsg = encodeURIComponent(`Token exchange failed: ${tokenResponse.status} - Please check Keycloak configuration`);
|
||||
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
// ... (Token decoding and sessionData creation remains the same) ...
|
||||
let idTokenPayload;
|
||||
try {
|
||||
idTokenPayload = JSON.parse(
|
||||
Buffer.from(tokens.id_token.split('.')[1], 'base64').toString()
|
||||
);
|
||||
} catch (decodeError) {
|
||||
console.error('❌ Failed to decode ID token:', decodeError);
|
||||
const errorMsg = encodeURIComponent('Invalid ID token format');
|
||||
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||
}
|
||||
|
||||
const sessionData = {
|
||||
user: {
|
||||
id: idTokenPayload.sub,
|
||||
email: idTokenPayload.email,
|
||||
name: idTokenPayload.name || idTokenPayload.preferred_username,
|
||||
preferred_username: idTokenPayload.preferred_username,
|
||||
given_name: idTokenPayload.given_name,
|
||||
family_name: idTokenPayload.family_name,
|
||||
},
|
||||
accessToken: tokens.access_token,
|
||||
idToken: tokens.id_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: Date.now() + (tokens.expires_in * 1000),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 👇 CRITICAL FIX FOR DEPLOYED HTTPS ENVIRONMENTS 👇
|
||||
// ----------------------------------------------------
|
||||
// Check if the request was originally HTTPS (via proxy)
|
||||
const isSecure = process.env.NODE_ENV === 'production' ||
|
||||
event.node.req.headers['x-forwarded-proto'] === 'https';
|
||||
|
||||
console.log('🔗 Setting session cookie with secure flag:', isSecure);
|
||||
|
||||
setCookie(event, 'user_session', JSON.stringify(sessionData), {
|
||||
httpOnly: true,
|
||||
// CRITICAL: Must be TRUE when operating over HTTPS (deployed)
|
||||
secure: isSecure,
|
||||
// Ensures cookie is sent on cross-site redirects (Keycloak -> Your App)
|
||||
sameSite: 'lax',
|
||||
maxAge: tokens.expires_in,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
console.log('✅ Session cookie created successfully');
|
||||
|
||||
// Note: The following line will still log false because the cookie
|
||||
// is in the response header, not the request header yet. This is expected.
|
||||
const testCookie = getCookie(event, 'user_session');
|
||||
console.log('🧪 Cookie test - can read back in this handler (Expected False):', !!testCookie);
|
||||
|
||||
console.log('↪️ Redirecting to dashboard...');
|
||||
return sendRedirect(event, '/dashboard?authenticated=true');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ === CALLBACK ERROR ===');
|
||||
console.error('❌ Error message:', error.message);
|
||||
console.error('❌ Error stack:', error.stack);
|
||||
console.error('❌ ==================');
|
||||
|
||||
const errorMsg = encodeURIComponent(`Authentication failed: ${error.message}`);
|
||||
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// server/api/auth/keycloak-login.ts
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('🔐 Keycloak Login Handler Called')
|
||||
console.log('📍 Method:', getMethod(event))
|
||||
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// Debug: Log runtime config (without secrets)
|
||||
console.log('🔧 Runtime Config Check:')
|
||||
console.log(' - Has keycloakIssuer:', !!config.keycloakIssuer)
|
||||
console.log(' - Has keycloakClientId:', !!config.keycloakClientId)
|
||||
console.log(' - Has keycloakSecret:', !!config.keycloakClientSecret)
|
||||
console.log(' - Issuer value:', config.keycloakIssuer)
|
||||
|
||||
// Validate required configuration
|
||||
if (!config.keycloakIssuer) {
|
||||
throw new Error('KEYCLOAK_ISSUER is not configured')
|
||||
}
|
||||
if (!config.keycloakClientId) {
|
||||
throw new Error('KEYCLOAK_CLIENT_ID is not configured')
|
||||
}
|
||||
|
||||
// Generate state parameter for security
|
||||
const state = randomBytes(32).toString('hex')
|
||||
console.log('🎲 Generated state:', state.substring(0, 8) + '...')
|
||||
|
||||
// Store state in session cookie
|
||||
setCookie(event, 'oauth_state', state, {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
maxAge: 600 // 10 minutes
|
||||
})
|
||||
|
||||
// Build Keycloak authorization URL
|
||||
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`
|
||||
const authUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/auth`)
|
||||
|
||||
authUrl.searchParams.set('client_id', config.keycloakClientId)
|
||||
authUrl.searchParams.set('redirect_uri', redirectUri)
|
||||
authUrl.searchParams.set('response_type', 'code')
|
||||
authUrl.searchParams.set('scope', 'openid profile email')
|
||||
authUrl.searchParams.set('state', state)
|
||||
|
||||
console.log('🏗️ Auth URL built:', authUrl.toString())
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
authUrl: authUrl.toString()
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ Login Error:', error.message)
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to generate authorization URL: ${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// server/api/auth/logout.post.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const config = useRuntimeConfig();
|
||||
console.log('🚪 Logout handler called');
|
||||
|
||||
// Get the current session to retrieve tokens
|
||||
const sessionCookie = getCookie(event, 'user_session');
|
||||
let idToken = null;
|
||||
|
||||
if (sessionCookie) {
|
||||
try {
|
||||
const session = JSON.parse(sessionCookie);
|
||||
idToken = session.idToken;
|
||||
console.log('🔑 ID token found in session:', !!idToken);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not parse session cookie:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ No session cookie found');
|
||||
}
|
||||
|
||||
// Clear all auth-related cookies
|
||||
console.log('🧹 Clearing session cookies...');
|
||||
deleteCookie(event, 'user_session');
|
||||
deleteCookie(event, 'oauth_state');
|
||||
|
||||
// Also clear with different path variations to be thorough
|
||||
deleteCookie(event, 'user_session', { path: '/' });
|
||||
deleteCookie(event, 'oauth_state', { path: '/' });
|
||||
|
||||
console.log('✅ Session cleared successfully');
|
||||
|
||||
// Construct the Keycloak logout URL with proper parameters
|
||||
const logoutUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/logout`);
|
||||
|
||||
// Add required parameters for proper Keycloak logout - REDIRECT TO LOGIN PAGE
|
||||
logoutUrl.searchParams.set('client_id', config.keycloakClientId);
|
||||
logoutUrl.searchParams.set('post_logout_redirect_uri', `${config.public.authUrl}/LoginPage?logout=success`);
|
||||
|
||||
// If we have an ID token, add it for proper session termination
|
||||
if (idToken) {
|
||||
logoutUrl.searchParams.set('id_token_hint', idToken);
|
||||
console.log('🔑 Added id_token_hint to logout URL');
|
||||
} else {
|
||||
console.warn('⚠️ No ID token available for logout hint');
|
||||
}
|
||||
|
||||
console.log('🔗 Keycloak logout URL constructed:', logoutUrl.toString());
|
||||
|
||||
// Return the logout URL to the client for redirect
|
||||
// This approach gives better control to the client-side code
|
||||
return {
|
||||
success: true,
|
||||
logoutUrl: logoutUrl.toString(),
|
||||
message: 'Session cleared successfully'
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ Logout error:', error);
|
||||
console.error('❌ Error stack:', error.stack);
|
||||
|
||||
// Even if there's an error, try to provide a basic logout URL - REDIRECT TO LOGIN PAGE
|
||||
const config = useRuntimeConfig();
|
||||
const fallbackLogoutUrl = `${config.keycloakIssuer}/protocol/openid-connect/logout?client_id=${config.keycloakClientId}&post_logout_redirect_uri=${encodeURIComponent(config.public.authUrl + '/LoginPage?logout=success')}`;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
logoutUrl: fallbackLogoutUrl,
|
||||
error: 'Logout encountered an error, but providing fallback logout URL',
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// server/api/auth/session.get.ts
|
||||
|
||||
// Helper function to safely decode the JWT payload (Access Token or ID Token)
|
||||
const decodeTokenPayload = (token: string | undefined): any | null => {
|
||||
if (!token) return null;
|
||||
try {
|
||||
// Tokens are base64 encoded and separated by '.'
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null; // Not a valid JWT format
|
||||
|
||||
const payloadBase64 = parts[1];
|
||||
|
||||
// Decode from base64 and parse the JSON
|
||||
// Note: Using Buffer.from is standard in Node.js server environments (like Nitro/H3)
|
||||
return JSON.parse(Buffer.from(payloadBase64, 'base64').toString());
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to decode token payload:', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// --- START OF THE SINGLE EXPORT DEFAULT HANDLER ---
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('🔍 Session endpoint called');
|
||||
|
||||
const sessionCookie = getCookie(event, 'user_session');
|
||||
console.log('🍪 Session cookie exists:', !!sessionCookie);
|
||||
|
||||
if (!sessionCookie) {
|
||||
console.log('❌ No session cookie found');
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'No session cookie found'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const session = JSON.parse(sessionCookie);
|
||||
console.log('📋 Session parsed successfully');
|
||||
|
||||
const isExpired = Date.now() > session.expiresAt;
|
||||
console.log(' Is Expired:', isExpired);
|
||||
|
||||
// Check if the token has expired
|
||||
if (isExpired) {
|
||||
console.log('⏰ Session has expired, clearing cookie');
|
||||
deleteCookie(event, 'user_session');
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Session expired'
|
||||
});
|
||||
}
|
||||
|
||||
// Decode tokens and prepare the enhanced response data
|
||||
const idTokenPayload = decodeTokenPayload(session.idToken);
|
||||
const accessTokenPayload = decodeTokenPayload(session.accessToken);
|
||||
|
||||
// Final response object for the frontend debug page
|
||||
const sessionResponse = {
|
||||
// Basic User Info
|
||||
user: session.user,
|
||||
|
||||
// Raw Tokens
|
||||
idToken: session.idToken,
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
|
||||
// Session Timestamps
|
||||
expiresAt: session.expiresAt,
|
||||
createdAt: session.createdAt,
|
||||
|
||||
// Parsed Payloads
|
||||
idTokenPayload: idTokenPayload,
|
||||
accessTokenPayload: accessTokenPayload,
|
||||
|
||||
// Raw Session Data (for Debug section)
|
||||
fullSessionObject: session,
|
||||
|
||||
status: 'authenticated',
|
||||
};
|
||||
|
||||
console.log('✅ Session is valid, returning full session data');
|
||||
return sessionResponse;
|
||||
|
||||
} catch (parseError) {
|
||||
console.error('❌ Failed to parse session cookie:', parseError);
|
||||
// If JSON parsing fails or any other error occurs, the session is invalid
|
||||
deleteCookie(event, 'user_session');
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid session data'
|
||||
});
|
||||
}
|
||||
});
|
||||
// --- END OF THE SINGLE EXPORT DEFAULT HANDLER ---
|
||||
@@ -0,0 +1,82 @@
|
||||
// stores/navItems.ts
|
||||
import { defineStore } from 'pinia';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
|
||||
interface NavItem {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
// Initial default navigation items
|
||||
const defaultNavItems: NavItem[] = [
|
||||
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
|
||||
// Add other main menu items
|
||||
{ id: 2, name: "Loket Admin", icon: "mdi-account-supervisor", path: "/LoketAdmin" },
|
||||
{ id: 3, name: "Ranap Admin", icon: "mdi-bed", path: "/RanapAdmin" },
|
||||
{ id: 4, name: "Klinik Admin", icon: "mdi-hospital-box", path: "/KlinikAdmin" },
|
||||
{ id: 5, name: "Klinik Ruang Admin", icon: "mdi-hospital-marker", path: "/KlinikRuangAdmin" },
|
||||
{
|
||||
id: 6,
|
||||
name: "Anjungan",
|
||||
icon: "mdi-account-box-multiple",
|
||||
path: "",
|
||||
children: [
|
||||
{ id: 7, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-account-box" },
|
||||
{ id: 8, name: "Admin Anjungan", path: "/Anjungan/AdminAnjungan", icon: "mdi-account-cog" },
|
||||
],
|
||||
},
|
||||
{ id: 9, name: "Fast Track", icon: "mdi-clock-fast", path: "/FastTrack" },
|
||||
{ id: 10, name: "Data Pasien", icon: "mdi-account-multiple", path: "/DataPasien" },
|
||||
{
|
||||
id: 11,
|
||||
name: "Screen",
|
||||
icon: "mdi-monitor",
|
||||
path: "",
|
||||
children: [
|
||||
{ id: 12, name: "Antrian Masuk 1", path: "/Screen/Antrian Masuk 1", icon: "mdi-monitor" },
|
||||
{ id: 13, name: "Antrian Masuk 2", path: "/Screen/Antrian Masuk 2", icon: "mdi-monitor" },
|
||||
// ... more screen pages
|
||||
],
|
||||
},
|
||||
{ id: 14, name: "List Pasien", icon: "mdi-format-list-bulleted", path: "/ListPasien" },
|
||||
{
|
||||
id: 15 ,
|
||||
name: "Setting",
|
||||
icon: "mdi-cog",
|
||||
path: "",
|
||||
children: [
|
||||
{ id: 16, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-shield-lock-outline" },
|
||||
{ id: 17, name: "User Login", path: "/setting/UserLogin", icon: "mdi-account-circle" },
|
||||
{ id: 18, name: "Master Loket", path: "/setting/MasterLoket", icon: "mdi-counter" },
|
||||
{ id: 19, name: "Master Klinik", path: "/setting/MasterKlinik", icon: "mdi-hospital" },
|
||||
{ id: 20, name: "Master Klinik Ruang", path: "/setting/MasterKlinikRuang", icon: "mdi-hospital-box" },
|
||||
{ id: 21, name: "Screen", path: "/setting/Screen", icon: "mdi-monitor" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const useNavItemsStore = defineStore('navItems', () => {
|
||||
const navItems = useLocalStorage<NavItem[]>('navItems', defaultNavItems);
|
||||
|
||||
function updateNavItems(newItems: NavItem[]) {
|
||||
// This will update the local storage and the state
|
||||
navItems.value = newItems.map((item, index) => ({
|
||||
...item,
|
||||
id: index + 1
|
||||
}));
|
||||
}
|
||||
|
||||
function addNavItem(newItem: Omit<NavItem, 'id'>) {
|
||||
const newId = navItems.value.length > 0 ? Math.max(...navItems.value.map(item => item.id)) + 1 : 1;
|
||||
navItems.value.push({ ...newItem, id: newId });
|
||||
}
|
||||
|
||||
return {
|
||||
navItems,
|
||||
updateNavItems,
|
||||
addNavItem,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// types/auth.ts - Enhanced with better error handling and optional fields
|
||||
export interface User {
|
||||
id: string
|
||||
email?: string
|
||||
name?: string
|
||||
preferred_username?: string
|
||||
given_name?: string
|
||||
family_name?: string
|
||||
roles?: string[]
|
||||
realm_access?: {
|
||||
roles: string[]
|
||||
}
|
||||
// Add any other Keycloak user properties you might need
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
success?: boolean // Add success indicator
|
||||
user: User | null
|
||||
accessToken?: string
|
||||
refreshToken?: string // Often useful to track
|
||||
expiresAt?: number
|
||||
error?: string // For error cases
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
data?: {
|
||||
authUrl: string
|
||||
}
|
||||
error?: string // Add error message support
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
success: boolean
|
||||
logoutUrl?: string // Make optional in case of errors
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Additional utility types for auth state
|
||||
export interface AuthState {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// Token information interface
|
||||
export interface TokenInfo {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
idToken?: string
|
||||
expiresAt: number
|
||||
tokenType?: string
|
||||
}
|
||||
|
||||
// Keycloak specific user info (if you need more detailed typing)
|
||||
export interface KeycloakUserInfo extends User {
|
||||
sub: string
|
||||
email_verified?: boolean
|
||||
preferred_username?: string
|
||||
given_name?: string
|
||||
family_name?: string
|
||||
name?: string
|
||||
}
|
||||
Reference in New Issue
Block a user