Author SHA1 Message Date
Fanrouver 9261df87e0 push update akbar 2025-10-01 15:05:35 +07:00
Fanrouver d735824d57 akbar first commit 2025-10-01 11:28:06 +07:00
Fanrouver 55d8920a82 wednesday 20 august 2025 2025-08-20 15:03:50 +07:00
Fanrouver 81398549e2 wednesday 13 2025 progress klinik, non functionality script 2025-08-13 11:36:20 +07:00
Fanrouver 0ad29ae5fa first Commit on develop branch 2025-08-12 15:11:09 +07:00
51 changed files with 8793 additions and 8365 deletions

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
<template>
<v-app-bar app color="#ff9248" dark>
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
<v-toolbar-title class="ml-2 font-weight-bold">
<span class="text-blue-darken-2">Antrean</span> RSSA
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Show loading state or user info -->
<div v-if="isLoading" class="d-flex align-center">
<v-progress-circular indeterminate size="20" class="mr-2"></v-progress-circular>
<span class="mr-2">Loading...</span>
</div>
<template v-else-if="isAuthenticated && user">
<ProfilePopup
:user="user"
@logout="handleLogout"
/><template>
<v-app-bar app color="blue-grey-darken-3" dark flat>
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
<v-toolbar-title class="ml-2 font-weight-bold">
<span class="text-orange-darken-2">Antrian</span> RSSA
</v-toolbar-title>
<v-spacer></v-spacer>
<div v-if="isLoading" class="d-flex align-center">
<v-progress-circular indeterminate color="orange-darken-2" size="20" class="mr-2"></v-progress-circular>
<span class="text-caption">Loading...</span>
</div>
<template v-else-if="isAuthenticated && user">
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
variant="flat"
rounded="xl"
color="transparent"
class="pa-2 text-capitalize"
>
<div class="d-flex align-center">
<v-avatar color="orange-darken-2" size="36" class="mr-2">
<span class="text-white font-weight-bold">{{ user.name?.charAt(0) || 'U' }}</span>
</v-avatar>
<span class="text-subtitle-1 font-weight-bold text-white">{{ user.name || 'User' }}</span>
<v-icon right size="20" class="ml-1">mdi-chevron-down</v-icon>
</div>
</v-btn>
</template>
<ProfilePopup
:user="user"
@logout="handleLogout"
/>
</v-menu>
</template>
<template v-else>
<v-btn @click="redirectToLogin" color="orange-darken-2" variant="flat" rounded="lg" class="text-capitalize">
<v-icon left>mdi-login</v-icon>
Login
</v-btn>
</template>
</v-app-bar>
</template>
<span class="mr-2">{{ user.name || user.preferred_username || user.email }}</span>
</template>
<template v-else>
<v-btn @click="redirectToLogin" color="white" text>
Login
</v-btn>
</template>
</v-app-bar>
</template>
<script setup>
import ProfilePopup from './ProfilePopup.vue';
// Emit untuk parent component
const emit = defineEmits(['toggle-rail']);
// Use auth composable
const { user, isAuthenticated, isLoading, checkAuth, logout } = useAuth()
// Handle logout - use the composable's logout method
const handleLogout = async () => {
console.log("🚪 AppBar logout initiated...")
try {
await logout()
} catch (error) {
console.error("❌ AppBar logout error:", error)
}
}
// Redirect to login if not authenticated
const redirectToLogin = () => {
navigateTo('/LoginPage')
}
// Check authentication on mount
onMounted(async () => {
await checkAuth()
})
</script>
+124
View File
@@ -0,0 +1,124 @@
<template>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
Edit Hak Akses Menu | {{ localItem.namaTipeUser }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
<v-table density="compact" class="elevation-1 rounded-lg">
<thead>
<tr>
<th class="text-left">No</th>
<th class="text-left">Menu</th>
<th class="text-center">Akses</th>
<th class="text-center">Lihat</th>
<th class="text-center">Tambah</th>
<th class="text-center">Edit</th>
<th class="text-center">Hapus</th>
</tr>
</thead>
<tbody>
<tr v-for="(menu, index) in localItem.hakAksesMenu" :key="menu.name">
<td>{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center">
<v-checkbox v-model="menu.canAccess" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canView" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canAdd" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canEdit" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canDelete" hide-details></v-checkbox>
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="$emit('cancel')"
>
Batal
</v-btn>
<v-btn
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="$emit('save', localItem)"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
// Define types for better readability and safety
interface HakAksesMenu {
name: string;
canAccess: boolean;
canView: boolean;
canAdd: boolean;
canEdit: boolean;
canDelete: boolean;
}
interface HakAksesData {
id: number;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
}
// Define props with type validation
const props = defineProps({
item: {
type: Object as () => HakAksesData,
required: true,
// Add custom validator for more robust checks
validator: (value: HakAksesData) => {
return 'namaTipeUser' in value && 'hakAksesMenu' in value;
},
},
});
// Define emits for clarity
const emits = defineEmits(['save', 'cancel']);
// Use a local copy to avoid mutating the prop directly
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
// Watch the prop for changes and update the local copy
watch(() => props.item, (newItem) => {
localItem.value = JSON.parse(JSON.stringify(newItem));
});
</script>
<style scoped>
.v-table :deep(th) {
font-weight: bold !important;
background-color: #f9fafb !important;
}
.v-table :deep(td) {
vertical-align: middle;
}
.v-checkbox :deep(.v-selection-control__input) {
color: #2196F3 !important;
}
</style>
+155
View File
@@ -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>
+75
View File
@@ -0,0 +1,75 @@
<!-- <template>
<v-dialog v-model="dialog" max-width="500px">
<v-card>
<v-card-title class="text-h6 font-weight-bold">
Atur Urutan Menu
</v-card-title>
<v-card-text>
<v-list dense>
<draggable v-model="localMenus" item-key="title" @end="onDragEnd">
<template #item="{ element }">
<v-list-item class="reorder-item">
<v-list-item-content>
<v-list-item-title>{{ element.title }}</v-list-item-title>
</v-list-item-content>
<v-list-item-icon>
<v-icon>mdi-drag</v-icon>
</v-list-item-icon>
</v-list-item>
</template>
</draggable>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey-darken-1" text @click="dialog = false">Batal</v-btn>
<v-btn color="blue" text @click="saveOrder">Simpan Urutan</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, watch } from 'vue';
import draggable from 'vuedraggable';
import navigationItems from '~/data/menu.js';
const dialog = ref(false);
const localMenus = ref([]);
// Watch for changes in the dialog's visibility
watch(dialog, (val) => {
if (val) {
// Make a deep copy to avoid mutating the original data
localMenus.value = JSON.parse(JSON.stringify(navigationItems));
}
});
const onDragEnd = (event) => {
// Logic to handle the end of a drag event
};
const saveOrder = () => {
// Emit an event with the new menu order
// You would then handle this in the parent component
// to update the ~/data/menu.js file (or your state management)
// and trigger a UI refresh.
dialog.value = false;
};
const openDialog = () => {
dialog.value = true;
};
defineExpose({ openDialog });
</script>
<style scoped>
.reorder-item {
cursor: grab;
border-bottom: 1px solid #eee;
}
.reorder-item:active {
cursor: grabbing;
}
</style> -->
+103
View File
@@ -0,0 +1,103 @@
<!-- components/sideBar.vue -->
<template>
<v-navigation-drawer
:model-value="drawer"
:rail="rail"
permanent
app
@update:model-value="emit('update:drawer', $event)"
>
<v-list density="compact" nav>
<template v-for="item in items" :key="item.name">
<v-menu
v-if="item.children"
open-on-hover
:location="rail ? 'end' : undefined"
:offset="10"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.name"
:value="item.name"
:to="!rail ? item.path : undefined"
:link="!rail"
></v-list-item>
</template>
<v-card class="py-2" min-width="200">
<v-card-title class="text-subtitle-1 font-weight-bold px-4 py-2">
{{ item.name }}
</v-card-title>
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item
v-for="child in item.children"
:key="child.name"
:to="child.path"
:title="child.name"
:prepend-icon="child.icon"
link
class="px-4"
></v-list-item>
</v-list>
</v-card>
</v-menu>
<v-tooltip
v-else
:disabled="!rail"
open-on-hover
location="end"
:text="item.name"
>
<template #activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.name"
:to="item.path"
link
></v-list-item>
</template>
</v-tooltip>
</template>
</v-list>
</v-navigation-drawer>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
const props = defineProps({
items: {
type: Array as () => NavItem[],
required: true,
},
rail: {
type: Boolean,
required: true,
},
drawer: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['update:drawer']);
</script>
<style scoped>
.v-navigation-drawer__content {
background-color: #ffffff;
}
</style>
-347
View File
@@ -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>
-52
View File
@@ -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>
-320
View File
@@ -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>
+135
View File
@@ -0,0 +1,135 @@
// composables/useAuth.ts - Enhanced version with better error handling
import type { User, SessionResponse, LoginResponse, LogoutResponse } from '~/types/auth'
export const useAuth = () => {
const user = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value)
const clearError = () => {
error.value = null
}
const checkAuth = async (): Promise<User | null> => {
try {
isLoading.value = true
clearError()
const response = await $fetch<SessionResponse>('/api/auth/session')
if (response.success === false && response.error) {
error.value = response.error
user.value = null
return null
}
user.value = response.user
return response.user
} catch (fetchError: any) {
console.error('Session check failed:', fetchError)
error.value = 'Failed to check authentication status'
user.value = null
return null
} finally {
isLoading.value = false
}
}
const login = async (): Promise<void> => {
try {
clearError()
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
method: 'POST'
})
if (response?.success && response?.data?.authUrl) {
console.log('🔗 Redirecting to Keycloak login...')
window.location.href = response.data.authUrl
} else {
const errorMsg = response?.error || 'Failed to get authorization URL'
error.value = errorMsg
throw new Error(errorMsg)
}
} catch (loginError: any) {
console.error('❌ Login error:', loginError)
error.value = loginError.message || 'Login failed'
throw loginError
}
}
const logout = async (): Promise<void> => {
try {
isLoading.value = true
clearError()
console.log('🚪 Starting logout process...')
const response = await $fetch<LogoutResponse>('/api/auth/logout', {
method: 'POST'
})
// Clear user immediately regardless of response
user.value = null
if (response?.success && response?.logoutUrl) {
console.log('🔗 Redirecting to Keycloak logout...')
window.location.href = response.logoutUrl
} else {
const warningMsg = response?.error || response?.message || 'No logout URL received'
console.warn('⚠️', warningMsg)
error.value = warningMsg
await navigateTo('/LoginPage')
}
} catch (logoutError: any) {
console.error('❌ Logout error:', logoutError)
error.value = logoutError.message || 'Logout failed'
user.value = null
await navigateTo('/LoginPage')
} finally {
isLoading.value = false
}
}
// Helper function to refresh user data
const refreshUser = async (): Promise<boolean> => {
const userData = await checkAuth()
return !!userData
}
// Helper function to check if user has specific role
const hasRole = (role: string): boolean => {
if (!user.value) return false
// Check in roles array
if (user.value.roles?.includes(role)) return true
// Check in realm_access.roles
if (user.value.realm_access?.roles?.includes(role)) return true
return false
}
// Helper function to check if user has any of the specified roles
const hasAnyRole = (roles: string[]): boolean => {
return roles.some(role => hasRole(role))
}
return {
// State
user: readonly(user),
isAuthenticated,
isLoading: readonly(isLoading),
error: readonly(error),
// Actions
checkAuth,
login,
logout,
refreshUser,
clearError,
// Utilities
hasRole,
hasAnyRole
}
}
-299
View File
@@ -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>
+14
View File
@@ -0,0 +1,14 @@
<template>
<v-app>
<v-main>
<slot />
</v-main>
</v-app>
</template>
<style>
/* Pastikan v-main mengisi seluruh tinggi halaman untuk mengaktifkan centering */
.v-main {
min-height: 100vh;
}
</style>
+37
View File
@@ -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>
+108
View File
@@ -0,0 +1,108 @@
// export default defineNuxtRouteMiddleware(async (to) => {
// console.log('🛡️ Auth middleware triggered for:', to.path)
// // Skip middleware on server-side during build/generation
// if (process.server && process.env.NODE_ENV === 'development') {
// console.log('⏭️ Skipping auth check on server-side during development')
// return
// }
// // Allow the login page to handle its own checks
// if (to.path === '/LoginPage') {
// console.log('⏭️ Allowing access to LoginPage')
// return
// }
// // This is the crucial change: check for the authentication signal
// const isAuthRedirect = to.query.authenticated === 'true';
// // If this is a redirect from a successful login, we need to let the route load
// if (isAuthRedirect) {
// console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
// // We navigate to a clean URL to remove the query parameter
// return navigateTo({ path: to.path, query: {} }, { replace: true });
// }
// try {
// console.log('🔍 Checking authentication status...')
// const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null)
// if (session && session.user) {
// console.log('✅ User is authenticated:', session.user.name || session.user.email)
// return
// } else {
// console.log('❌ No valid session found, redirecting to login')
// return navigateTo('/LoginPage')
// }
// } catch (error) {
// console.error('❌ Auth middleware error:', error)
// console.log('🔄 Redirecting to login due to error')
// return navigateTo('/LoginPage')
// }
// })
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
import type { RouteLocationNormalized } from 'vue-router';
// Define the shape of the user object returned by your authentication API.
// This provides type safety for session.user.
interface User {
name?: string | null;
email: string;
// Add other properties from your user object as needed.
}
// Define the shape of the full session object returned by the API.
interface Session {
user: User;
}
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
console.log('🛡️ Auth middleware triggered for:', to.path);
// Skip middleware on server-side during development build/generation
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping auth check on server-side during development');
return;
}
// Allow the login page to handle its own checks without redirection loops
if (to.path === '/LoginPage') {
console.log('⏭️ Allowing access to LoginPage');
return;
}
// Check for the authentication signal from a successful login redirect
const isAuthRedirect: boolean = to.query.authenticated === 'true';
// If this is a redirect from a successful login, allow the route to load.
// We navigate to a clean URL to remove the query parameter.
if (isAuthRedirect) {
console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
return navigateTo({ path: to.path, query: {} }, { replace: true });
}
try {
console.log('🔍 Checking authentication status...');
// Use the defined Session interface to type the fetch response
const session: Session | null = await $fetch<Session>('/api/auth/session').catch(() => null);
// Check if a valid session and user exist using optional chaining
if (session?.user) {
console.log('✅ User is authenticated:', session.user.name || session.user.email);
return;
} else {
console.log('❌ No valid session found, redirecting to login');
return navigateTo('/LoginPage');
}
} catch (error) {
console.error('❌ Auth middleware error:', error);
console.log('🔄 Redirecting to login due to error');
return navigateTo('/LoginPage');
}
});
+44
View File
@@ -0,0 +1,44 @@
// middleware/guest.ts
export default defineNuxtRouteMiddleware(async (to) => {
console.log('👋 Guest middleware triggered for:', to.path);
// Skip middleware on server-side during build/generation
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping guest check on server-side during development');
return;
}
const isAuthRedirect = to.query.authenticated === 'true';
const isLogoutSuccess = to.query.logout === 'success';
// If this is a logout success, allow access to login page
if (isLogoutSuccess) {
console.log('✅ Logout success detected, allowing access to login page');
return;
}
// If this is a redirect from a successful login, we need to wait
if (isAuthRedirect) {
console.log('⏳ Client-side is processing a new login session, waiting for session to be established...');
// We navigate to a clean URL to remove the query parameter from the user's view
return navigateTo({ path: to.path, query: {} }, { replace: true });
}
try {
console.log('🔍 Checking if user is already authenticated...');
// The $fetch will automatically send the new user_session cookie
const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null);
if (session && session.user) {
console.log('✅ User already authenticated, redirecting to dashboard');
return navigateTo('/dashboard');
} else {
console.log('️ No session found, staying on login page');
return;
}
} catch (error) {
console.log('️ Auth check failed (expected for login), staying on login page');
return;
}
});
+21 -3
View File
@@ -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 })
]
}
})
+2309 -1906
View File
File diff suppressed because it is too large Load diff
+7 -1
View File
@@ -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": {
-242
View File
@@ -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>
+189
View File
@@ -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>
-686
View File
@@ -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>
-543
View File
@@ -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>
+476
View File
@@ -0,0 +1,476 @@
<template>
<v-container fluid class="pa-6 bg-grey-lighten-4">
<div class="d-flex justify-space-between align-center mb-6">
<div>
<h1 class="text-h4 font-weight-bold">Dashboard</h1>
<p v-if="user" class="text-subtitle-1 text-grey-darken-1 mt-1">
Selamat Datang, {{ user.name || user.preferred_username }}!
</p>
</div>
<div class="d-flex align-center">
<v-chip color="green-lighten-1" class="mr-2 pa-3 font-weight-bold">
<v-icon start icon="mdi-calendar"></v-icon>
{{ currentDate }}
</v-chip>
</div>
</div>
<v-row class="mb-6">
<v-col cols="12" sm="6" md="3">
<v-card class="pa-4 rounded-xl elevation-6" color="blue-lighten-1" theme="dark">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-group</v-icon>
<div>
<div class="text-h4 font-weight-black">2635</div>
<div class="text-subtitle-1">Total Visitors</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-4 rounded-xl elevation-6" color="cyan-lighten-1" theme="dark">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-multiple-plus</v-icon>
<div>
<div class="text-h4 font-weight-black">759</div>
<div class="text-subtitle-1">Offline Registrants</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-4 rounded-xl elevation-6" color="green-lighten-1" theme="dark">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-multiple-plus-outline</v-icon>
<div>
<div class="text-h4 font-weight-black">1876</div>
<div class="text-subtitle-1">Online Registrants</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-4 rounded-xl elevation-6" color="orange-lighten-1" theme="dark">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-ticket</v-icon>
<div>
<div class="text-h4 font-weight-black">248</div>
<div class="text-subtitle-1">Total Tickets Printed</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h6 font-weight-bold">Grafik Jumlah Antrean</span>
<v-btn-toggle v-model="queueTimePeriod" mandatory divided variant="outlined" color="primary" class="text-caption">
<v-btn size="small" value="day">Hari</v-btn>
<v-btn size="small" value="week">Minggu</v-btn>
<v-btn size="small" value="month">Bulan</v-btn>
<v-btn size="small" value="year">Tahun</v-btn>
</v-btn-toggle>
</v-card-title>
<v-card-text>
<Bar
:data="queueChartData"
:options="queueChartOptions"
style="height: 300px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h6 font-weight-bold">Monthly Visitor Data</span>
<div>
<v-chip
:color="activeYear === '2024' ? 'green-lighten-1' : 'grey-lighten-2'"
class="mr-2 text-caption font-weight-bold cursor-pointer"
@click="changeYear('2024')"
>
2024
</v-chip>
<v-chip
:color="activeYear === '2025' ? 'green-lighten-1' : 'grey-lighten-2'"
class="text-caption font-weight-bold cursor-pointer"
@click="changeYear('2025')"
>
2025
</v-chip>
</div>
</v-card-title>
<v-card-text>
<Bar
:data="barData"
:options="barOptions"
style="height: 300px"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="text-h6 font-weight-bold">Realtime Ticket Queue</v-card-title>
<v-card-text>
<Line
ref="realtimeChart"
:data="initialLineData"
:options="lineOptions"
style="height: 300px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="text-h6 font-weight-bold">Registrant Breakdown</v-card-title>
<v-card-text>
<Pie
:data="pieData"
:options="pieOptions"
style="height: 300px"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-overlay v-model="isLoading" contained class="align-center justify-center">
<v-progress-circular indeterminate size="64" color="primary"></v-progress-circular>
<p class="mt-4">Loading dashboard...</p>
</v-overlay>
</v-container>
</template>
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue';
import { Bar, Pie, Line } from 'vue-chartjs';
import dayjs from 'dayjs';
// Tambahkan plugin Dayjs
import weekday from 'dayjs/plugin/weekday';
import weekOfYear from 'dayjs/plugin/weekOfYear';
dayjs.extend(weekday);
dayjs.extend(weekOfYear);
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
ArcElement,
PointElement,
LineElement,
} from 'chart.js';
definePageMeta({
middleware:['auth']
})
// Register necessary Chart.js elements
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
// Use the auth composable
const { user, isLoading, checkAuth, logout } = useAuth()
const isLoggingOut = ref(false)
// Dashboard data
const currentDate = ref('');
const activeYear = ref('2025');
const queueTimePeriod = ref('month');
// --- MOCK DATA DINAMIS ANTREEAN (Tetap) ---
const mockQueueData = ref([
// ... (data tetap sama)
{ date: '2025-09-22', count: 120 },
{ date: '2025-09-23', count: 150 },
{ date: '2025-09-24', count: 135 },
{ date: '2025-09-25', count: 160 },
{ date: '2025-09-26', count: 145 },
{ date: '2025-09-27', count: 170 },
{ date: '2025-09-28', count: 180 },
{ date: '2025-08-10', count: 300 },
{ date: '2025-08-20', count: 350 },
{ date: '2025-09-01', count: 400 },
{ date: '2025-09-15', count: 450 },
{ date: '2024-01-01', count: 1200 },
{ date: '2024-06-01', count: 1800 },
{ date: '2025-01-01', count: 2000 },
{ date: '2025-06-01', count: 2500 },
]);
// --- AKHIR MOCK DATA ANTREEAN ---
// --- REFACTORED REALTIME LOGIC ---
const realtimeChart = ref(null); // Ref untuk mengakses komponen Line
const maxDataPoints = 10;
let realtimeInterval = null;
// Initial data structure (non-reactive for update function)
const initialLineData = {
labels: Array.from({ length: maxDataPoints }, (_, i) =>
dayjs().subtract((maxDataPoints - 1 - i) * 5, 'second').format('HH:mm:ss')
),
datasets: [
{
label: 'Tickets Processed',
backgroundColor: '#FF5722',
borderColor: '#FF5722',
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 50) + 100),
fill: false,
tension: 0.1,
},
],
};
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Crucial: Disable animation for smooth realtime scrolling
},
plugins: {
legend: { display: true },
title: { display: false }
},
scales: {
y: {
beginAtZero: true,
suggestedMax: 200,
title: { display: true, text: 'Count' },
grid: { display: true }
},
x: {
title: { display: true, text: 'Time (HH:MM:SS)' },
grid: { display: false }
}
}
});
// Function to simulate realtime data update using chart.update()
const updateRealtimeData = () => {
const chart = realtimeChart.value?.chart;
if (!chart) return;
// 1. Get the current data arrays
const dataArray = chart.data.datasets[0].data;
const labelArray = chart.data.labels;
// 2. Shift (remove) the oldest data point and label
dataArray.shift();
labelArray.shift();
// 3. Generate new data point and time label
const newDataPoint = Math.floor(Math.random() * 50) + 100;
const newTimeLabel = dayjs().format('HH:mm:ss');
// 4. Push the new data and label
dataArray.push(newDataPoint);
labelArray.push(newTimeLabel);
// 5. CRUCIAL: Tell Chart.js to redraw itself without destroying the instance
chart.update();
};
// --- END REFACTORED REALTIME LOGIC ---
// Example data for both years (Tetap)
const visitorData2024 = [150, 200, 350, 400, 380, 500, 550, 600, 520, 480, 650, 700];
const visitorData2025 = [200, 250, 400, 450, 420, 550, 600, 650, 570, 520, 700, 750];
// Check authentication and setup on page load
onMounted(async () => {
try {
const sessionUser = await checkAuth()
if (sessionUser) {
// Set current date
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
currentDate.value = new Date().toLocaleDateString('id-ID', options);
// Start realtime data update interval
// Pastikan chart instance sudah siap sebelum memulai interval
realtimeInterval = setInterval(updateRealtimeData, 5000);
} else {
await navigateTo('/LoginPage');
}
} catch (error) {
console.error('Auth check error:', error);
await navigateTo('/LoginPage');
}
});
// Clear the interval when the component is unmounted
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
}
});
// Updated logout handler (Tetap)
const handleLogout = async () => {
if (isLoggingOut.value) return
try {
isLoggingOut.value = true
console.log('🚪 Dashboard logout initiated...')
await logout()
} catch (error) {
console.error('❌ Dashboard logout error:', error)
} finally {
isLoggingOut.value = false
}
};
// Function to change the active year (Tetap)
const changeYear = (year) => {
activeYear.value = year;
};
// --- LOGIKA UTAMA UNTUK GRAFIK ANTREEAN (Tetap) ---
const processQueueData = (data, period) => {
const grouped = {};
const sortedDates = data.map(item => ({
...item,
date: dayjs(item.date)
})).sort((a, b) => a.date.valueOf() - b.date.valueOf());
sortedDates.forEach(item => {
let key;
let label;
if (period === 'day') {
key = item.date.format('YYYY-MM-DD');
label = item.date.format('DD/MM');
} else if (period === 'week') {
key = item.date.format('YYYY-WW');
label = `Wk ${item.date.week()} ${item.date.year()}`;
} else if (period === 'month') {
key = item.date.format('YYYY-MM');
label = item.date.format('MMM YYYY');
} else if (period === 'year') {
key = item.date.format('YYYY');
label = item.date.format('YYYY');
}
if (!grouped[key]) {
grouped[key] = { label: label, count: 0 };
}
grouped[key].count += item.count;
});
const finalLabels = Object.values(grouped).map(g => g.label);
const finalCounts = Object.values(grouped).map(g => g.count);
return {
labels: finalLabels,
datasets: [
{
label: `Total Antrean per ${period}`,
backgroundColor: '#FFB300',
data: finalCounts,
},
],
};
};
const queueChartData = computed(() => {
if (!mockQueueData.value.length) return { labels: [], datasets: [] };
return processQueueData(mockQueueData.value, queueTimePeriod.value);
});
const queueChartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true },
},
scales: {
y: {
beginAtZero: true,
grid: { display: true }
},
x: {
grid: { display: false }
}
}
});
// --- AKHIR LOGIKA GRAFIK ANTREEAN ---
// Computed property Monthly Visitor (Tetap)
const barData = computed(() => {
const dataForYear = activeYear.value === '2024' ? visitorData2024 : visitorData2025;
return {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'],
datasets: [
{
label: `Total Visitors ${activeYear.value}`,
backgroundColor: '#2196F3',
data: dataForYear,
},
],
};
});
// Bar chart options (Tetap)
const barOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
grid: { display: false }
},
x: {
grid: { display: false }
}
}
});
// Pie chart data (Tetap)
const pieData = ref({
labels: ['Offline Registrants', 'Online Registrants'],
datasets: [
{
backgroundColor: ['#2196F3', '#4CAF50'],
data: [759, 1876],
},
],
});
// Pie chart options (Tetap)
const pieOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed || 0;
return `${label}: ${value}`;
}
}
}
}
});
</script>
<style scoped>
.cursor-pointer {
cursor: pointer;
}
</style>
-1106
View File
File diff suppressed because it is too large Load diff
+372
View File
@@ -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>
+293
View File
@@ -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>
-635
View File
@@ -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>
+868
View File
@@ -0,0 +1,868 @@
<!-- // Pages/LoginPage.vue -->
<template>
<v-container fluid fill-height class="login-background">
<!-- Navigation Bar -->
<v-app-bar class="navbar" flat>
<v-toolbar-title class="brand-logo">
<v-icon class="mr-2" color="white">mdi-hospital-building</v-icon>
<span class="font-weight-bold text-blue-darken-3">ANTREAN</span> <span class="font-weight-bold" >RSSA</span>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Navigation with Dropdown Menus -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
variant="text"
color="white"
class="nav-link"
v-bind="props"
>
Tentang
<v-icon right small>mdi-chevron-down</v-icon>
</v-btn>
</template>
<v-list class="nav-dropdown">
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-hospital-building</v-icon>
<v-list-item-title>Profil Rumah Sakit</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
variant="text"
color="white"
class="nav-link"
v-bind="props"
>
Kontak
<v-icon right small>mdi-chevron-down</v-icon>
</v-btn>
</template>
<v-list class="nav-dropdown">
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-phone</v-icon>
<v-list-item-title>Hubungi Kami</v-list-item-title>
</v-list-item>
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-map-marker</v-icon>
<v-list-item-title>Alamat & Lokasi</v-list-item-title>
</v-list-item>
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-email</v-icon>
<v-list-item-title>Email</v-list-item-title>
</v-list-item>
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-help-circle</v-icon>
<v-list-item-title>Bantuan</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn icon color="white" class="ml-4">
<v-icon>mdi-menu</v-icon>
</v-btn>
</v-app-bar>
<!-- Floating Medical Icons Background -->
<div class="floating-medical-icon icon-1">
<v-icon size="144">mdi-heart-pulse</v-icon>
</div>
<div class="floating-medical-icon icon-2">
<v-icon size="108">mdi-medical-bag</v-icon>
</div>
<div class="floating-medical-icon icon-3">
<v-icon size="126">mdi-stethoscope</v-icon>
</div>
<div class="floating-medical-icon icon-4">
<v-icon size="120">mdi-hospital-box</v-icon>
</div>
<div class="floating-medical-icon icon-5">
<v-icon size="96">mdi-pill</v-icon>
</div>
<div class="floating-medical-icon icon-6">
<v-icon size="114">mdi-bandage</v-icon>
</div>
<v-row class="fill-height align-center justify-center">
<!-- Left Content -->
<v-col cols="12" md="6" class="text-section">
<div class="hero-content">
<div class="logo-section mb-6">
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
class="mt-3 hospital-logo"
/>
<h1 class="hero-title">Sistem Terbaik</h1>
<h1 class="hero-title">Untuk Pelayanan</h1>
<h1 class="hero-title">Kesehatan</h1>
</div>
<p class="hero-description">
Tingkatkan efisiensi layanan rumah sakit dengan sistem antrean RSSA yang canggih dan intuitif.
Dirancang dengan kesederhanaan, keamanan, dan kecepatan, memastikan perjalanan
Anda ke platform kami semudah mungkin. Mari kita buat inovasi menjadi sederhana!
</p>
</div>
</v-col>
<!-- Right Login Card -->
<v-col cols="12" md="6" class="d-flex justify-center">
<v-card class="login-card white-card rounded-xl pa-8" max-width="450" width="100%">
<!-- Header -->
<div class="text-center mb-6">
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
</div>
<!-- Logo Section -->
<div class="d-flex flex-column align-center text-center mb-6">
<span class="text-h5 font-weight-bold app-title-dark text-blue-darken-3">Antrean RSSA</span>
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
class="mt-3 hospital-logo"
/>
</div>
<!-- Alert Messages -->
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
{{ errorMessage }}
</v-alert>
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
<!-- SSO Info -->
<div class="text-center mb-6">
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
</div>
<!-- Keycloak Login Button -->
<v-btn
@click="handleLogin"
class="login-btn"
block
rounded="lg"
size="large"
:loading="isLoading"
:disabled="isLoading"
>
<v-icon left>mdi-shield-key</v-icon>
<span class="font-weight-bold">
{{ isLoading ? 'Connecting to Keycloak...' : 'Login dengan Keycloak' }}
</span>
<v-icon right>mdi-arrow-right</v-icon>
</v-btn>
<!-- Registration Section -->
<v-divider class="my-6 custom-divider-dark"></v-divider>
<div class="text-center">
<v-btn
@click="showRegistrationDialog = true"
class="register-btn-dark"
variant="outlined"
block
rounded="lg"
size="large"
>
<v-icon left>mdi-account-plus</v-icon>
<span class="font-weight-bold">Daftar Akun Baru</span>
</v-btn>
<div class="text-center mt-4">
<span class="help-text-dark">
Belum memiliki akun?
</span>
<br>
<v-btn
@click="showAdminContact = true"
variant="text"
color="#0053AD"
size="small"
class="contact-link-dark mt-1"
>
Hubungi Administrator
</v-btn>
</div>
</div>
<!-- Password Help -->
<div class="text-center mt-4">
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
</div>
</v-card>
</v-col>
</v-row>
<!-- Registration Information Dialog -->
<v-dialog v-model="showRegistrationDialog" max-width="500">
<v-card class="white-dialog rounded-xl">
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#0053AD">mdi-account-plus</v-icon>
Pendaftaran Akun Baru
</v-card-title>
<v-card-text class="text-grey-darken-2 pa-6">
<div class="text-center mb-4">
<v-icon size="64" color="#0053AD" class="mb-4">mdi-information</v-icon>
</div>
<p class="text-body-1 mb-4">
Untuk mendaftar akun baru pada sistem Antrean RSSA, silakan ikuti langkah berikut:
</p>
<v-list class="transparent">
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-numeric-1-circle</v-icon>
</template>
<v-list-item-title class="text-grey-darken-2">
Hubungi Administrator IT Rumah Sakit
</v-list-item-title>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-numeric-2-circle</v-icon>
</template>
<v-list-item-title class="text-grey-darken-2">
Siapkan dokumen identitas dan surat penugasan
</v-list-item-title>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-numeric-3-circle</v-icon>
</template>
<v-list-item-title class="text-grey-darken-2">
Tunggu proses verifikasi dan aktivasi akun
</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="pa-6 bg-grey-lighten-5">
<v-spacer></v-spacer>
<v-btn
@click="showRegistrationDialog = false"
color="grey"
variant="outlined"
rounded
>
Tutup
</v-btn>
<v-btn
@click="showRegistrationDialog = false; showAdminContact = true"
color="#0053AD"
rounded
>
<v-icon left>mdi-phone</v-icon>
Hubungi Admin
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Admin Contact Dialog -->
<v-dialog v-model="showAdminContact" max-width="500">
<v-card class="white-dialog rounded-xl">
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#0053AD">mdi-account-tie</v-icon>
Kontak Administrator
</v-card-title>
<v-card-text class="text-grey-darken-2 pa-6">
<div class="text-center mb-4">
<v-icon size="64" color="#0053AD" class="mb-4">mdi-phone-settings</v-icon>
</div>
<v-list class="transparent">
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-office-building</v-icon>
</template>
<div>
<v-list-item-title class="text-grey-darken-2 font-weight-bold">
IT Support RSSA
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Bagian Teknologi Informasi
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-phone</v-icon>
</template>
<div>
<v-list-item-title class="text-grey-darken-2">
(0341) 343343 ext. 1234
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Telepon Internal
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-email</v-icon>
</template>
<div>
<v-list-item-title class="text-grey-darken-2">
it-support@rssa.malang.go.id
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Email Resmi
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-clock</v-icon>
</template>
<div>
<v-list-item-title class="text-grey-darken-2">
Senin - Jumat: 07:00 - 15:00
</v-list-item-title>
<v-list-item-subtitle class="text-grey-darken-1">
Jam Operasional
</v-list-item-subtitle>
</div>
</v-list-item>
</v-list>
<v-alert
type="info"
variant="tonal"
class="mt-4"
color="blue"
>
<div class="text-grey-darken-2">
<strong>Catatan:</strong> Pendaftaran akun memerlukan verifikasi dokumen dan dapat memakan waktu 1-2 hari kerja.
</div>
</v-alert>
</v-card-text>
<v-card-actions class="pa-6 bg-grey-lighten-5">
<v-spacer></v-spacer>
<v-btn
@click="showAdminContact = false"
color="grey"
variant="outlined"
rounded
>
Tutup
</v-btn>
<v-btn
@click="copyContactInfo"
color="#0053AD"
rounded
>
<v-icon left>mdi-content-copy</v-icon>
Salin Info
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import type { LoginResponse } from '~/types/auth'
// Use guest middleware to redirect if already authenticated
definePageMeta({
layout: 'empty',
middleware:['auth','guest']
})
// Reactive state
const isLoading = ref<boolean>(false)
const errorMessage = ref<string>('')
const successMessage = ref<string>('')
const showRegistrationDialog = ref<boolean>(false)
const showAdminContact = ref<boolean>(false)
// Check URL parameters for errors
const route = useRoute()
onMounted(() => {
if (route.query.error) {
errorMessage.value = decodeURIComponent(route.query.error as string)
}
})
// Custom login handler
const handleLogin = async (): Promise<void> => {
isLoading.value = true
errorMessage.value = ''
successMessage.value = ''
try {
console.log('Starting login process...')
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
method: 'POST'
})
if (response?.success && response?.data?.authUrl) {
console.log('Redirecting to Keycloak...')
successMessage.value = 'Redirecting to Keycloak...'
setTimeout(() => {
window.location.href = response.data!.authUrl
}, 500)
} else {
throw new Error('Failed to get authorization URL')
}
} catch (error: any) {
console.error('Login error:', error)
errorMessage.value = `Login failed: ${error.message || 'Please try again.'}`
} finally {
isLoading.value = false
}
}
// Copy contact information to clipboard
const copyContactInfo = async (): Promise<void> => {
const contactInfo = `
IT Support RSSA
Telepon: (0341) 343343 ext. 1234
Email: [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>
-754
View File
@@ -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>
+526
View File
@@ -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>
+175
View File
@@ -0,0 +1,175 @@
<template>
<v-main class="ranap-admin-page">
<v-container fluid class="pa-6">
<!-- Modern Header -->
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center pa-4">
<v-icon size="48" color="white" class="mr-4">mdi-hospital-box-outline</v-icon>
<h1 class="text-h4 font-weight-bold text-white">Ranap Admin</h1>
</v-card>
<v-card class="elevation-2 rounded-xl pa-4">
<div class="d-flex justify-space-between align-center mb-4">
<div class="d-flex align-center">
<span class="text-caption mr-2">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
density="compact"
hide-details
variant="solo"
flat
class="pagination-select"
:menu-props="{ attach: true }"
></v-select>
<span class="text-caption ml-2">entries</span>
</div>
<v-text-field
v-model="search"
label="Search"
append-inner-icon="mdi-magnify"
single-line
hide-details
density="compact"
variant="solo"
flat
class="search-field"
></v-text-field>
</div>
<v-data-table
:headers="headers"
:items="filteredItems"
:items-per-page="itemsPerPage"
class="elevation-0"
item-key="noAntrean"
>
<template v-slot:item.aksi="{ item }">
<v-btn small color="success" class="text-white" @click="selectItem(item)">
Selesai
</v-btn>
</template>
<template v-slot:bottom>
<div class="d-flex justify-space-between align-center pa-2">
<span class="text-caption">Showing {{ (page - 1) * itemsPerPage + 1 }} to {{ Math.min(page * itemsPerPage, filteredItems.length) }} of {{ filteredItems.length }} entries</span>
<v-pagination
v-model="page"
:length="pageCount"
:total-visible="5"
rounded="circle"
></v-pagination>
</div>
</template>
</v-data-table>
</v-card>
<v-row class="mt-6">
<v-col cols="12" md="4" class="pr-md-2">
<v-card class="elevation-2 rounded-xl next-card pa-4 d-flex flex-column align-center text-center">
<h2 class="text-h3 font-weight-bold next-title">NEXT</h2>
<span class="text-h4 font-weight-medium my-4 next-content">{{ nextAntrean || 'Kosong' }}</span>
<p class="text-caption">Klik untuk memanggil pasien selanjutnya</p>
</v-card>
</v-col>
</v-row>
</v-container>
</v-main>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
const headers = [
{ title: 'No', align: 'start', sortable: false, key: 'no' },
{ title: 'No. Antrean', key: 'noAntrean' },
{ title: 'Daftar', key: 'daftar' },
{ title: 'Pelayanan', key: 'pelayanan' },
{ title: 'Aksi', key: 'aksi' },
];
const items = ref([
{ no: 1, noAntrean: '001', daftar: '26 Aug 2025 07:10:31', pelayanan: 'Belum Dilayani' },
{ no: 2, noAntrean: '002', daftar: '26 Aug 2025 07:10:35', pelayanan: 'Belum Dilayani' },
{ no: 3, noAntrean: '003', daftar: '26 Aug 2025 07:10:44', pelayanan: 'Belum Dilayani' },
{ no: 4, noAntrean: '004', daftar: '26 Aug 2025 07:10:46', pelayanan: 'Belum Dilayani' },
{ no: 5, noAntrean: '005', daftar: '26 Aug 2025 07:10:47', pelayanan: 'Belum Dilayani' },
{ no: 6, noAntrean: '006', daftar: '26 Aug 2025 07:10:49', pelayanan: 'Belum Dilayani' },
{ no: 7, noAntrean: '007', daftar: '26 Aug 2025 07:10:51', pelayanan: 'Belum Dilayani' },
{ no: 8, noAntrean: '008', daftar: '26 Aug 2025 07:10:53', pelayanan: 'Belum Dilayani' },
{ no: 9, noAntrean: '009', daftar: '26 Aug 2025 07:10:54', pelayanan: 'Belum Dilayani' },
{ no: 10, noAntrean: '010', daftar: '26 Aug 2025 07:10:55', pelayanan: 'Belum Dilayani' },
]);
const search = ref('');
const itemsPerPage = ref(10);
const page = ref(1);
const nextAntrean = ref('001');
const filteredItems = computed(() => {
if (!search.value) {
return items.value;
}
return items.value.filter(item =>
Object.values(item).some(val =>
String(val).toLowerCase().includes(search.value.toLowerCase())
)
);
});
const pageCount = computed(() => {
return Math.ceil(filteredItems.value.length / itemsPerPage.value);
});
const selectItem = (item) => {
console.log('Item selected:', item);
// Di sini Anda bisa menambahkan logika untuk mengubah status pasien menjadi "Dilayani" atau memindahkannya ke antrean berikutnya.
};
</script>
<style scoped>
.ranap-admin-page {
background-color: #f5f5f5;
min-height: 100vh;
}
.header-banner {
background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
color: white;
padding: 24px;
}
.search-field, .pagination-select {
max-width: 250px;
}
.v-data-table :deep(table) {
border-collapse: separate;
border-spacing: 0 10px;
}
.v-data-table :deep(tbody tr) {
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.05);
border-radius: 8px;
overflow: hidden;
}
.next-card {
background-color: #00A896;
color: white;
height: 200px;
}
.next-title {
font-size: 3.5rem;
}
.next-content {
font-size: 3rem;
line-height: 1;
}
</style>
-113
View File
@@ -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>
+571
View File
@@ -0,0 +1,571 @@
<template>
<div class="hak-akses-page pa-6">
<v-breadcrumbs :items="breadcrumbs" class="pl-0 mb-4">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<v-card v-if="viewMode === 'add' || viewMode === 'editName'" class="pa-6 rounded-xl elevation-4">
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
<v-icon :icon="viewMode === 'add' ? 'mdi-plus' : 'mdi-pencil'" class="mr-2 text-primary" size="28"></v-icon>
<span>{{ formTitle }}</span>
</v-card-title>
<v-divider class="mb-4"></v-divider>
<v-card-text class="px-0">
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User</v-label>
<v-text-field
v-model="editedItem.namaTipeUser"
placeholder="Masukkan Nama Tipe User"
variant="outlined"
density="comfortable"
class="mt-1"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-0 mt-4">
<v-btn
color="grey-darken-1"
variant="flat"
rounded="lg"
class="text-capitalize mr-2"
@click="cancelForm"
>
Batal
</v-btn>
<v-btn
color="primary"
variant="flat"
rounded="lg"
class="text-capitalize"
@click="saveItem"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
<EditHakAkses
v-else-if="viewMode === 'editAccess'"
:item="editedItem"
@save="updateItemAccess"
@cancel="cancelForm"
/>
<v-card v-else-if="viewMode === 'view'" class="pa-6 rounded-xl elevation-4">
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
<v-icon icon="mdi-eye-outline" class="mr-2 text-info" size="28"></v-icon>
<span>{{ formTitle }}</span>
</v-card-title>
<v-divider class="mb-4"></v-divider>
<v-card-text class="px-0">
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User</v-label>
<v-text-field
v-model="editedItem.namaTipeUser"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
<v-table density="comfortable" class="elevation-1 rounded-xl">
<thead>
<tr>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">No</th>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Akses</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Lihat</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Tambah</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Edit</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Hapus</th>
</tr>
</thead>
<tbody>
<tr v-for="(menu, index) in editedItem.hakAksesMenu" :key="index">
<td>{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center">
<v-icon :color="menu.canAccess ? 'green' : 'grey-lighten-2'">{{ menu.canAccess ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canView ? 'green' : 'grey-lighten-2'">{{ menu.canView ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canAdd ? 'green' : 'grey-lighten-2'">{{ menu.canAdd ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canEdit ? 'green' : 'grey-lighten-2'">{{ menu.canEdit ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canDelete ? 'green' : 'grey-lighten-2'">{{ menu.canDelete ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-0 mt-4">
<v-btn
color="grey-darken-1"
variant="flat"
rounded="lg"
class="text-capitalize"
@click="cancelForm"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
<div v-else>
<v-card class="d-flex flex-column flex-sm-row justify-space-between align-center pa-6 mb-6 rounded-xl bg-blue-lighten-5 elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0 text-blue-darken-3">
<v-icon icon="mdi-shield-lock-outline" class="mr-2" size="40"></v-icon>
<span>Hak Akses</span>
</v-card-title>
<div class="d-flex mt-4 mt-sm-0">
<v-btn
color="success"
prepend-icon="mdi-plus"
rounded="lg"
class="text-capitalize mr-2"
variant="flat"
@click="showAddForm"
>
Tambah Baru
</v-btn>
<v-btn
color="info"
prepend-icon="mdi-format-list-numbered"
rounded="lg"
class="text-capitalize"
variant="flat"
@click="toggleReorderMode"
>
{{ reorderMode ? 'Selesai' : 'Atur Urutan' }}
</v-btn>
</div>
</v-card>
<v-card class="pa-6 rounded-xl elevation-2">
<v-card-text class="pa-0">
<div class="d-flex flex-column flex-sm-row align-center justify-space-between mb-4">
<div class="d-flex align-center mb-4 mb-sm-0">
<span class="mr-2 text-subtitle-1 text-medium-emphasis">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50]"
variant="outlined"
density="compact"
hide-details
style="max-width: 80px;"
rounded="lg"
class="mr-2"
></v-select>
<span class="text-subtitle-1 text-medium-emphasis">entries</span>
</div>
<div class="d-flex align-center">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
variant="outlined"
density="compact"
hide-details
rounded="lg"
clearable
></v-text-field>
</div>
</div>
<v-data-table
:headers="headers"
:items="allHakAksesData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
class="elevation-0 custom-table"
>
<template v-slot:[`item.actions`]="{ item }">
<div class="d-flex justify-center">
<v-btn icon size="small" variant="text" class="mr-1" @click="viewItem(item)">
<v-icon color="blue-darken-1">mdi-eye-outline</v-icon>
</v-btn>
<v-btn icon size="small" variant="text" class="mr-1" @click="editItem(item)">
<v-icon color="orange-darken-1">mdi-pencil-outline</v-icon>
</v-btn>
<v-btn icon size="small" variant="text" class="mr-1" @click="deleteItem(item)">
<v-icon color="red-darken-1">mdi-delete-outline</v-icon>
</v-btn>
<v-btn icon size="small" variant="text" @click="editAccess(item)">
<v-icon color="green-darken-1">mdi-lock-check-outline</v-icon>
</v-btn>
</div>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<template v-slot:item.id="{ item }">
<div class="text-center">{{ item.id }}</div>
</template>
<template v-slot:bottom>
<v-row class="ma-2 pa-2">
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey-darken-1">
{{ showingEntriesText }}
</v-col>
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
<v-pagination
v-model="page"
:length="pageCount"
rounded="circle"
:total-visible="5"
></v-pagination>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card class="pa-6 rounded-xl elevation-4">
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" rounded="lg" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red-darken-1" variant="text" rounded="lg" @click="confirmDelete">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="reorderMode" max-width="600px">
<v-card class="pa-6 rounded-xl elevation-4">
<v-card-title class="text-h6 font-weight-bold">Atur Urutan Menu</v-card-title>
<v-card-text>
<VueDraggableNext
v-model="draggableMenus"
item-key="name"
tag="v-list"
class="pa-0"
handle=".handle"
:animation="200"
>
<template #item="{ element }">
<v-list-item class="rounded-lg elevation-1 my-2" :title="element.name">
<template #prepend>
<v-icon icon="mdi-drag-vertical" class="handle"></v-icon>
</template>
</v-list-item>
</template>
</VueDraggableNext>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" rounded="lg" @click="cancelReorder">Batal</v-btn>
<v-btn color="primary" variant="text" rounded="lg" @click="saveReorder">Simpan Urutan</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useLocalStorage, useSessionStorage } from '@vueuse/core';
import { VueDraggableNext } from 'vue-draggable-next';
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
import { useNavItemsStore } from '@/stores/navItems';
definePageMeta({
middleware: ['auth']
})
interface HakAksesMenu {
name: string;
canAccess: boolean;
canView: boolean;
canAdd: boolean;
canEdit: boolean;
canDelete: boolean;
}
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
interface HakAksesData {
id: number;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
}
interface BackendPermissionItem {
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
page_name: string;
pageID: number;
}
interface SessionData {
roles: string[];
groups: string[];
}
// State for views
const viewMode = ref<'table' | 'add' | 'editName' | 'editAccess' | 'view'>('table');
const reorderMode = ref(false);
const allHakAksesData = useLocalStorage<HakAksesData[]>('allHakAksesData', []);
const navItemsStore = useNavItemsStore();
const draggableMenus = ref<NavItem[]>([]);
// Data table headers
const headers = ref([
{ title: 'No', key: 'id' as const },
{ title: 'Nama Tipe User', key: 'namaTipeUser' as const, sortable: true },
{ title: 'Aksi', align: 'center' as const, key: 'actions' as const, sortable: false },
]);
// Breadcrumbs
const breadcrumbs = computed(() => {
const baseCrumbs = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Hak Akses', disabled: false, href: '/setting/hakakses' },
];
if (viewMode.value === 'add') {
return [...baseCrumbs, { title: 'Tambah Hak Akses', disabled: true, href: '/setting/tambahhakakses' }];
} else if (viewMode.value === 'editName') {
return [...baseCrumbs, { title: 'Edit Nama Tipe User', disabled: true, href: '/setting/editnamahakakses' }];
} else if (viewMode.value === 'editAccess') {
return [...baseCrumbs, { title: 'Edit Hak Akses', disabled: true, href: '/setting/edithakakses' }];
} else if (viewMode.value === 'view') {
return [...baseCrumbs, { title: 'Detail Hak Akses', disabled: true, href: '/setting/viewhakakses' }];
}
return baseCrumbs;
});
// Table and pagination state
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
const editedIndex = ref(-1);
const editedItem = ref<HakAksesData>({
id: 0,
namaTipeUser: '',
hakAksesMenu: [],
});
// --- Menu management logic ---
// This is your list of all possible menus with a path and icon
const defaultMenuItems = () => ([
{ name: 'Dashboard', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/dashboard', icon: 'mdi-view-dashboard' },
{ name: 'Setting', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/setting', icon: 'mdi-cog' },
{ name: 'Setting / Hak Akses', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/setting/hakakses', icon: 'mdi-shield-lock-outline' },
// ... and so on for all your pages
]);
// Computed properties
const pageCount = computed(() => {
return Math.ceil(allHakAksesData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allHakAksesData.value.length);
const total = allHakAksesData.value.length;
return `Showing ${start} to ${end} of ${total} entries`;
});
const formTitle = computed(() => {
if (viewMode.value === 'editName') return 'Edit Nama Tipe User';
if (viewMode.value === 'editAccess') return 'Edit Hak Akses';
if (viewMode.value === 'view') return 'Detail Hak Akses';
return 'Tambah Hak Akses';
});
// Delete dialog state
const showDeleteDialog = ref(false);
// Functions to reorder data and sync
const toggleReorderMode = () => {
reorderMode.value = !reorderMode.value;
if (reorderMode.value) {
draggableMenus.value = navItemsStore.navItems.map(item => ({ ...item }));
}
};
const saveReorder = () => {
navItemsStore.updateNavItems(draggableMenus.value);
reorderMode.value = false;
reindexData();
};
const cancelReorder = () => {
reorderMode.value = false;
};
// Re-indexes IDs to be sequential
const reindexData = () => {
allHakAksesData.value.forEach((item, index) => {
item.id = index + 1;
});
};
// Functions for actions
const showAddForm = () => {
resetForm();
viewMode.value = 'add';
};
const viewItem = (item: HakAksesData) => {
editedItem.value = { ...item };
viewMode.value = 'view';
};
const editItem = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
editedItem.value = { ...item };
viewMode.value = 'editName';
};
const editAccess = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
const baseMenuItems = defaultMenuItems();
const existingAccess = item.hakAksesMenu;
const mergedMenuItems = baseMenuItems.map(defaultMenu => {
const existingMenu = existingAccess.find(exist => exist.name === defaultMenu.name);
return existingMenu ? { ...defaultMenu, ...existingMenu } : defaultMenu;
});
editedItem.value = {
...item,
hakAksesMenu: mergedMenuItems
};
viewMode.value = 'editAccess';
};
const deleteItem = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
showDeleteDialog.value = true;
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
editedIndex.value = -1;
};
const confirmDelete = () => {
if (editedIndex.value > -1) {
allHakAksesData.value.splice(editedIndex.value, 1);
reindexData();
}
closeDeleteDialog();
};
const cancelForm = () => {
viewMode.value = 'table';
resetForm();
};
const updateItemAccess = (updatedItem: HakAksesData) => {
if (editedIndex.value > -1) {
Object.assign(allHakAksesData.value[editedIndex.value], updatedItem);
}
cancelForm();
};
const resetForm = () => {
editedItem.value = {
id: 0,
namaTipeUser: '',
hakAksesMenu: navItemsStore.navItems.map(navItem => ({
name: navItem.name,
canAccess: false,
canView: false,
canAdd: false,
canEdit: false,
canDelete: false,
})),
};
};
const saveItem = () => {
if (editedIndex.value > -1) {
// Edit item
Object.assign(allHakAksesData.value[editedIndex.value], editedItem.value);
} else {
// Add item with a new ID
editedItem.value.id = allHakAksesData.value.length + 1;
allHakAksesData.value.push(editedItem.value);
reindexData(); // Re-index to ensure sequential IDs
}
cancelForm();
};
// Handle initial data load and ID re-indexing
onMounted(() => {
reindexData();
});
</script>
<style scoped>
.hak-akses-page {
font-family: 'Roboto', sans-serif;
background-color: #f5f7fa;
min-height: 100vh;
}
.custom-table {
border: none;
}
.v-data-table :deep(th) {
font-weight: bold !important;
color: #333 !important;
}
.v-data-table :deep(td) {
vertical-align: middle;
}
.v-btn--icon {
border-radius: 8px;
}
.v-select :deep(.v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.handle {
cursor: grab;
}
</style>
-156
View File
@@ -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>
+491
View File
@@ -0,0 +1,491 @@
<template>
<div class="master-klinik-page pa-4">
<!-- Breadcrumbs -->
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<!-- Tampilan Formulir Tambah/Edit/View -->
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
{{ isEditMode ? 'Edit Klinik' : readOnly ? 'Detail Klinik' : 'Tambah Klinik' }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Kode</v-label>
<v-text-field
v-model="editedItem.kode"
placeholder="Masukkan Kode Klinik"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama</v-label>
<v-text-field
v-model="editedItem.namaKlinik"
placeholder="Masukkan Nama Klinik"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Shift</v-label>
<v-text-field
v-model="editedItem.shift"
placeholder="Jumlah Shift"
type="number"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Kuota Shift</v-label>
<v-text-field
v-model="editedItem.kuotaShift"
placeholder="Kuota Per Shift"
type="number"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row class="d-flex align-center">
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Jam Buka Shift</v-label>
<div class="d-flex align-center">
<v-text-field
v-model="editedItem.jamBuka"
placeholder="Jam Buka Shift 1"
variant="outlined"
density="comfortable"
class="mr-2"
:readonly="readOnly"
></v-text-field>
<span class="text-h6 font-weight-bold mr-2">:</span>
<v-text-field
v-model="editedItem.menitBuka"
placeholder="Menit Buka Shift 1"
variant="outlined"
density="comfortable"
:readonly="readOnly"
></v-text-field>
</div>
</v-col>
<v-col cols="12" md="6">
<v-switch
v-model="editedItem.autoShift"
inset
color="primary"
label="Auto Shift"
hide-details
:readonly="readOnly"
></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold">Kuota Bangku</v-label>
<v-text-field
v-model="editedItem.kuotaBangku"
placeholder="Masukkan Kuota Bangku Klinik"
type="number"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold">Jadwal Klinik</v-label>
<v-table class="rounded-lg elevation-1 mt-2">
<thead>
<tr>
<th class="text-left text-uppercase font-weight-bold">#</th>
<th class="text-left text-uppercase font-weight-bold">Hari</th>
<th class="text-left text-uppercase font-weight-bold">Pilih</th>
</tr>
</thead>
<tbody>
<tr v-for="(day, index) in days" :key="index">
<td>{{ index + 1 }}</td>
<td>{{ day.name }}</td>
<td>
<v-checkbox
v-model="day.selected"
color="primary"
hide-details
:readonly="readOnly"
></v-checkbox>
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-start pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="cancelForm"
>
{{ readOnly ? 'Tutup' : 'Batal' }}
</v-btn>
<v-btn
v-if="!readOnly"
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="saveItem"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
<!-- Tampilan Tabel Data -->
<div v-else>
<!-- Banner biru sebagai pengganti header h1 -->
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-hospital" class="mr-2" size="40"></v-icon>
<span>Master Klinik</span>
</v-card-title>
<v-btn
color="success"
prepend-icon="mdi-plus"
rounded
class="text-capitalize"
@click="showForm = true"
>
Tambah Baru
</v-btn>
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-text>
<!-- Table controls -->
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50]"
variant="outlined"
density="compact"
hide-details
style="max-width: 80px;"
rounded
class="mr-2"
></v-select>
<span class="text-subtitle-1">entries</span>
</div>
<div class="d-flex align-center">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
variant="outlined"
density="compact"
hide-details
rounded
clearable
></v-text-field>
</div>
</div>
<!-- Data Table dengan paginasi kustom -->
<v-data-table
:headers="headers"
:items="allKlinikData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
class="rounded-lg elevation-0 custom-table"
>
<!-- Slot untuk aksi di setiap baris -->
<template v-slot:[`item.actions`]="{ item }">
<!-- Tombol "View" yang hanya menampilkan detail -->
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="red" size="small" @click="deleteItem(item)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<!-- Slot kustom untuk footer tabel (paginasi) -->
<template v-slot:bottom>
<v-row class="ma-2">
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
{{ showingEntriesText }}
</v-col>
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
<v-pagination
v-model="page"
:length="pageCount"
rounded="circle"
:total-visible="5"
></v-pagination>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
</div>
<!-- Delete Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card class="pa-4 rounded-lg">
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
// State untuk menampilkan/menyembunyikan formulir
const showForm = ref(false);
const readOnly = ref(false); // State untuk mode "view"
// Data dummy untuk Master Klinik
const allKlinikData = ref([
{ id: 1, kode: 'AN', namaKlinik: 'ANAK', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 2, kode: 'AS', namaKlinik: 'ANESTESI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 3, kode: 'BD', namaKlinik: 'BEDAH', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 4, kode: 'GR', namaKlinik: 'GERIATRI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 5, kode: 'GI', namaKlinik: 'GIGI DAN MULUT', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 6, kode: 'GZ', namaKlinik: 'GIZI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 7, kode: 'HO', namaKlinik: 'HOM', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 8, kode: 'IP', namaKlinik: 'IPD', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 9, kode: 'JT', namaKlinik: 'JANTUNG', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 10, kode: 'JW', namaKlinik: 'JIWA', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 11, kode: 'OB', namaKlinik: 'KANDUNGAN', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 12, kode: 'KT', namaKlinik: 'KEMOTERAPI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 13, kode: 'KN', namaKlinik: 'KOMPLEMENTER', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 14, kode: 'KL', namaKlinik: 'KUL KEL', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 15, kode: 'MT', namaKlinik: 'MATA', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 16, kode: 'MC', namaKlinik: 'MCU', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 17, kode: 'ON', namaKlinik: 'ONKOLOGI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 18, kode: 'PR', namaKlinik: 'PARU', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 19, kode: 'RT', namaKlinik: 'R. TINDAKAN', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 20, kode: 'RD', namaKlinik: 'RADIOTERAPI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 21, kode: 'RM', namaKlinik: 'REHAB MEDIK', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 22, kode: 'NU', namaKlinik: 'SARAF', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 23, kode: 'TH', namaKlinik: 'THT', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
]);
const headers = ref([
{ title: 'No', key: 'id' },
{ title: 'Kode', key: 'kode', sortable: true },
{ title: 'Nama Klinik', key: 'namaKlinik', sortable: true },
{ title: 'Shift', key: 'shift', sortable: true },
{ title: 'Kuota Shift', key: 'kuotaShift', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
]);
// Breadcrumbs
const breadcrumbs = ref([
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
]);
const days = ref([
{ name: 'Senin', selected: false },
{ name: 'Selasa', selected: false },
{ name: 'Rabu', selected: false },
{ name: 'Kamis', selected: false },
{ name: 'Jum`at', selected: false },
]);
// State untuk tabel dan paginasi
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
// Computed properties untuk paginasi kustom
const pageCount = computed(() => {
return Math.ceil(allKlinikData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allKlinikData.value.length);
const total = allKlinikData.value.length;
return `Showing ${start} to ${end} of ${total} entries`;
});
// State untuk dialog
const showDeleteDialog = ref(false);
const isEditMode = ref(false);
const editedIndex = ref(-1);
const editedItem = ref({
id: 0,
kode: '',
namaKlinik: '',
shift: 1,
kuotaShift: 0,
kuotaBangku: 0,
jamBuka: '',
menitBuka: '',
autoShift: false,
});
// Fungsi untuk tombol aksi
// Fungsi untuk tombol View yang hanya menampilkan data tanpa bisa diedit
const viewItem = (item) => {
editedItem.value = { ...item };
readOnly.value = true;
isEditMode.value = false;
showForm.value = true;
// Update breadcrumbs untuk mode view
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
{ title: 'Detail Klinik', disabled: true, href: '/setting/viewklinik' },
];
};
const editItem = (item) => {
editedIndex.value = allKlinikData.value.findIndex(d => d.id === item.id);
editedItem.value = { ...item };
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
// Update breadcrumbs untuk mode edit
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
{ title: 'Edit Klinik', disabled: true, href: '/setting/editklinik' },
];
};
const deleteItem = (item) => {
editedIndex.value = allKlinikData.value.findIndex(d => d.id === item.id);
showDeleteDialog.value = true;
};
const confirmDelete = () => {
if (editedIndex.value > -1) {
allKlinikData.value.splice(editedIndex.value, 1);
}
closeDeleteDialog();
};
const cancelForm = () => {
showForm.value = false;
isEditMode.value = false;
readOnly.value = false;
editedItem.value = {
id: 0,
kode: '',
namaKlinik: '',
shift: 1,
kuotaShift: 0,
kuotaBangku: 0,
jamBuka: '',
menitBuka: '',
autoShift: false,
};
editedIndex.value = -1;
// Reset breadcrumbs ke mode tabel
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
];
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
editedIndex.value = -1;
};
const saveItem = () => {
if (isEditMode.value) {
Object.assign(allKlinikData.value[editedIndex.value], editedItem.value);
} else {
// Generate new ID
const newId = allKlinikData.value.length > 0 ? Math.max(...allKlinikData.value.map(item => item.id)) + 1 : 1;
editedItem.value.id = newId;
allKlinikData.value.push(editedItem.value);
}
cancelForm(); // Kembali ke tampilan tabel setelah menyimpan
};
</script>
<style scoped>
.master-klinik-page {
font-family: 'Roboto', sans-serif;
background-color: #f5f7fa;
min-height: 100vh;
}
.custom-table {
border: none;
}
.v-data-table :deep(th) {
font-weight: bold !important;
color: #333 !important;
}
.v-data-table :deep(td) {
vertical-align: middle;
}
.v-btn--icon {
border-radius: 8px;
}
.v-select :deep(.v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
</style>
+376
View File
@@ -0,0 +1,376 @@
<template>
<div class="master-klinik-ruang-page pa-4">
<!-- Breadcrumbs -->
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<!-- Tampilan Formulir Tambah/Edit/View -->
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
{{ isEditMode ? 'Edit Ruang Klinik' : readOnly ? 'Detail Ruang Klinik' : 'Tambah Ruang Klinik' }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama Klinik</v-label>
<v-select
v-model="editedItem.namaKlinik"
:items="['ANAK', 'ANESTESI', 'BEDAH', 'GERIATRI', 'GIGI DAN MULUT', 'GIZI', 'HOM', 'IPD', 'JANTUNG', 'JIWA', 'KANDUNGAN', 'KEMOTERAPI', 'KOMPLEMENTER', 'KUL KEL', 'MATA', 'MCU', 'ONKOLOGI', 'PARU', 'R. TINDAKAN', 'RADIOTERAPI', 'REHAB MEDIK', 'SARAF', 'THT']"
placeholder="Pilih Nama Klinik"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Kode Ruang</v-label>
<v-text-field
v-model="editedItem.kode"
placeholder="Masukkan Kode Ruang"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold">Nama Ruang</v-label>
<v-text-field
v-model="editedItem.namaRuang"
placeholder="Masukkan Nama Ruang"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-start pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="cancelForm"
>
{{ readOnly ? 'Tutup' : 'Batal' }}
</v-btn>
<v-btn
v-if="!readOnly"
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="saveItem"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
<!-- Tampilan Tabel Data -->
<div v-else>
<!-- Banner biru sebagai pengganti header h1 -->
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-hospital-box-outline" class="mr-2" size="40"></v-icon>
<span>Master Klinik Ruang</span>
</v-card-title>
<v-btn
color="success"
prepend-icon="mdi-plus"
rounded
class="text-capitalize"
@click="showForm = true"
>
Tambah Baru
</v-btn>
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-text>
<!-- Table controls -->
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50]"
variant="outlined"
density="compact"
hide-details
style="max-width: 80px;"
rounded
class="mr-2"
></v-select>
<span class="text-subtitle-1">entries</span>
</div>
<div class="d-flex align-center">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
variant="outlined"
density="compact"
hide-details
rounded
clearable
></v-text-field>
</div>
</div>
<!-- Data Table dengan paginasi kustom -->
<v-data-table
:headers="headers"
:items="allRuangData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
class="rounded-lg elevation-0 custom-table"
>
<!-- Slot untuk aksi di setiap baris -->
<template v-slot:[`item.actions`]="{ item }">
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="red" size="small" @click="deleteItem(item)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<!-- Slot kustom untuk footer tabel (paginasi) -->
<template v-slot:bottom>
<v-row class="ma-2">
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
{{ showingEntriesText }}
</v-col>
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
<v-pagination
v-model="page"
:length="pageCount"
rounded="circle"
:total-visible="5"
></v-pagination>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
</div>
<!-- Delete Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card class="pa-4 rounded-lg">
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
// State untuk menampilkan/menyembunyikan formulir
const showForm = ref(false);
const readOnly = ref(false);
// Data dummy untuk Master Klinik Ruang
const allRuangData = ref([
{ id: 1, namaKlinik: 'ANAK', kode: 'AN-01', namaRuang: 'RUANG ANAK 1' },
{ id: 2, namaKlinik: 'ANAK', kode: 'AN-02', namaRuang: 'RUANG ANAK 2' },
{ id: 3, namaKlinik: 'ANESTESI', kode: 'AS-01', namaRuang: 'RUANG ANESTESI 1' },
{ id: 4, namaKlinik: 'BEDAH', kode: 'BD-01', namaRuang: 'RUANG BEDAH 1' },
{ id: 5, namaKlinik: 'GIGI DAN MULUT', kode: 'GI-01', namaRuang: 'RUANG GIGI DAN MULUT 1' },
{ id: 6, namaKlinik: 'GERIATRI', kode: 'GR-01', namaRuang: 'RUANG GERIATRI 1' },
{ id: 7, namaKlinik: 'GIZI', kode: 'GZ-01', namaRuang: 'RUANG GIZI 1' },
{ id: 8, namaKlinik: 'HOM', kode: 'HO-01', namaRuang: 'RUANG HOM 1' },
{ id: 9, namaKlinik: 'IPD', kode: 'IP-01', namaRuang: 'RUANG IPD 1' },
{ id: 10, namaKlinik: 'JANTUNG', kode: 'JT-01', namaRuang: 'RUANG JANTUNG 1' },
{ id: 11, namaKlinik: 'JIWA', kode: 'JW-01', namaRuang: 'RUANG JIWA 1' },
{ id: 12, namaKlinik: 'KANDUNGAN', kode: 'OB-01', namaRuang: 'RUANG KANDUNGAN 1' },
{ id: 13, namaKlinik: 'KANDUNGAN', kode: 'OB-02', namaRuang: 'RUANG KANDUNGAN 2' },
{ id: 14, namaKlinik: 'KEMOTERAPI', kode: 'KT-01', namaRuang: 'RUANG KEMOTERAPI 1' },
{ id: 15, namaKlinik: 'KOMPLEMENTER', kode: 'KN-01', namaRuang: 'RUANG KOMPLEMENTER 1' },
{ id: 16, namaKlinik: 'KUL KEL', kode: 'KL-01', namaRuang: 'RUANG KUL KEL 1' },
{ id: 17, namaKlinik: 'MATA', kode: 'MT-01', namaRuang: 'RUANG MATA 1' },
{ id: 18, namaKlinik: 'MCU', kode: 'MC-01', namaRuang: 'RUANG MCU 1' },
{ id: 19, namaKlinik: 'ONKOLOGI', kode: 'ON-01', namaRuang: 'RUANG ONKOLOGI 1' },
{ id: 20, namaKlinik: 'PARU', kode: 'PR-01', namaRuang: 'RUANG PARU 1' },
{ id: 21, namaKlinik: 'R. TINDAKAN', kode: 'RT-01', namaRuang: 'RUANG R. TINDAKAN 1' },
{ id: 22, namaKlinik: 'RADIOTERAPI', kode: 'RD-01', namaRuang: 'RUANG RADIOTERAPI 1' },
{ id: 23, namaKlinik: 'REHAB MEDIK', kode: 'RM-01', namaRuang: 'RUANG REHAB MEDIK 1' },
{ id: 24, namaKlinik: 'SARAF', kode: 'NU-01', namaRuang: 'RUANG SARAF 1' },
{ id: 25, namaKlinik: 'THT', kode: 'TH-01', namaRuang: 'RUANG THT 1' },
]);
const headers = ref([
{ title: 'No', key: 'id' },
{ title: 'Nama Klinik', key: 'namaKlinik', sortable: true },
{ title: 'Kode Ruang', key: 'kode', sortable: true },
{ title: 'Nama Ruang', key: 'namaRuang', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
]);
// Breadcrumbs
const breadcrumbs = ref([
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
]);
// State untuk tabel dan paginasi
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
// Computed properties untuk paginasi kustom
const pageCount = computed(() => {
return Math.ceil(allRuangData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allRuangData.value.length);
const total = allRuangData.value.length;
return `Showing ${start} to ${end} of ${total} entries`;
});
// State untuk dialog
const showDeleteDialog = ref(false);
const isEditMode = ref(false);
const editedIndex = ref(-1);
const editedItem = ref({
id: 0,
namaKlinik: '',
kode: '',
namaRuang: '',
});
// Fungsi untuk tombol aksi
const viewItem = (item) => {
editedItem.value = { ...item };
readOnly.value = true;
isEditMode.value = false;
showForm.value = true;
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
{ title: 'Detail Ruang Klinik', disabled: true, href: '/setting/viewruang' },
];
};
const editItem = (item) => {
editedIndex.value = allRuangData.value.findIndex(d => d.id === item.id);
editedItem.value = { ...item };
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
{ title: 'Edit Ruang Klinik', disabled: true, href: '/setting/editruang' },
];
};
const deleteItem = (item) => {
editedIndex.value = allRuangData.value.findIndex(d => d.id === item.id);
showDeleteDialog.value = true;
};
const confirmDelete = () => {
if (editedIndex.value > -1) {
allRuangData.value.splice(editedIndex.value, 1);
}
closeDeleteDialog();
};
const cancelForm = () => {
showForm.value = false;
isEditMode.value = false;
readOnly.value = false;
editedItem.value = {
id: 0,
namaKlinik: '',
kode: '',
namaRuang: '',
};
editedIndex.value = -1;
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
];
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
editedIndex.value = -1;
};
const saveItem = () => {
if (isEditMode.value) {
Object.assign(allRuangData.value[editedIndex.value], editedItem.value);
} else {
const newId = allRuangData.value.length > 0 ? Math.max(...allRuangData.value.map(item => item.id)) + 1 : 1;
editedItem.value.id = newId;
allRuangData.value.push(editedItem.value);
}
cancelForm();
};
</script>
<style scoped>
.master-klinik-ruang-page {
font-family: 'Roboto', sans-serif;
background-color: #f5f7fa;
min-height: 100vh;
}
.custom-table {
border: none;
}
.v-data-table :deep(th) {
font-weight: bold !important;
color: #333 !important;
}
.v-data-table :deep(td) {
vertical-align: middle;
}
.v-btn--icon {
border-radius: 8px;
}
.v-select :deep(.v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
</style>
-204
View File
@@ -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>
-253
View File
@@ -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>
+496
View File
@@ -0,0 +1,496 @@
<template>
<div class="user-login-page pa-4">
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
{{ isEditMode ? 'Edit Pengguna' : readOnly ? 'Detail Pengguna' : 'Tambah Pengguna' }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama Lengkap</v-label>
<v-text-field
v-model="editedItem.namaLengkap"
placeholder="Masukkan Nama Lengkap"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama User</v-label>
<v-text-field
v-model="editedItem.namaUser"
placeholder="Masukkan Nama User"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Tipe User</v-label>
<v-select
v-model="editedItem.tipeUser"
:items="['Super Admin', 'Admin', 'Loket', 'Klinik', 'Admin Barcode', 'INOVA', 'Ranap', 'Report Only', 'Farmasi', 'Manager']"
placeholder="Pilih Tipe User"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Keterangan</v-label>
<v-text-field
v-model="editedItem.keterangan"
placeholder="Masukkan Keterangan"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Roles</v-label>
<v-select
v-model="editedItem.roles"
:items="availableRoles"
multiple
chips
placeholder="Pilih Roles Pengguna"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Groups</v-label>
<v-select
v-model="editedItem.groups"
:items="availableGroups"
multiple
chips
placeholder="Pilih Groups Pengguna"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
</v-row>
<v-row v-if="!readOnly && !isEditMode">
<v-col cols="12">
<v-label class="font-weight-bold">Password</v-label>
<v-text-field
v-model="editedItem.password"
placeholder="Masukkan Password"
type="password"
variant="outlined"
density="comfortable"
class="mb-2"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-start pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="cancelForm"
>
{{ readOnly ? 'Tutup' : 'Batal' }}
</v-btn>
<v-btn
v-if="!readOnly"
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="saveItem"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
<div v-else>
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-account-group-outline" class="mr-2" size="40"></v-icon>
<span>User Login</span>
</v-card-title>
<v-btn
color="success"
prepend-icon="mdi-plus"
rounded
class="text-capitalize"
@click="showAddForm"
>
Tambah User
</v-btn>
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-text>
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
variant="outlined"
density="compact"
hide-details
style="max-width: 80px;"
rounded
class="mr-2"
></v-select>
<span class="text-subtitle-1">entries</span>
</div>
<div class="d-flex align-center">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
variant="outlined"
density="compact"
hide-details
rounded
clearable
></v-text-field>
</div>
</div>
<v-data-table
:headers="headers"
:items="allUserData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
class="rounded-lg elevation-0 custom-table"
>
<template v-slot:[`item.roles`]="{ item }">
<v-chip
v-for="role in item.roles"
:key="role"
color="blue-lighten-1"
size="small"
class="mr-1 mb-1"
>
{{ role }}
</v-chip>
</template>
<template v-slot:[`item.groups`]="{ item }">
<v-chip
v-for="group in item.groups"
:key="group"
color="purple-lighten-1"
size="small"
class="mr-1 mb-1"
>
{{ group }}
</v-chip>
</template>
<template v-slot:[`item.actions`]="{ item }">
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="red" size="small" @click="deleteItem(item)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<template v-slot:bottom>
<v-row class="ma-2">
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
{{ showingEntriesText }}
</v-col>
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
<v-pagination
v-model="page"
:length="pageCount"
rounded="circle"
:total-visible="5"
></v-pagination>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
<!-- Custom Modal/Dialog for Confirmation -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card class="pa-4 rounded-lg">
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data **{{ itemToDelete?.namaLengkap }}**?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
>
{{ snackbar.message }}
</v-snackbar>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
// --- NEW DATA LISTS FOR SELECTION ---
const availableRoles = [
'User_Standard', 'User_Report', 'User_Klinik', 'User_Farmasi',
'Admin_UserManagement', 'Admin_System', 'Manager_View'
];
const availableGroups = [
'/RS/Klinik', '/RS/Loket', '/RS/Farmasi', '/RS/IT', '/RS/Management'
];
// ------------------------------------
// Define the core user data structure (without IDs initially)
const rawUserData = [
{ namaLengkap: 'LOKET 1', namaUser: 'loket1', tipeUser: 'Loket', keterangan: 'Loket 1', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'LOKET 2', namaUser: 'loket2', tipeUser: 'Loket', keterangan: 'Loket 2', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'LOKET 3', namaUser: 'loket3', tipeUser: 'Loket', keterangan: 'Loket 3', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'ANAK', namaUser: 'anak', tipeUser: 'Klinik', keterangan: 'ANAK', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
{ namaLengkap: 'ADMIN PRAM', namaUser: 'adminpram', tipeUser: 'Admin', keterangan: 'Administrator Utama', roles: ['Admin_UserManagement', 'User_Report'], groups: ['/RS/IT'] },
{ namaLengkap: 'Report Only', namaUser: 'laporan', tipeUser: 'Report Only', keterangan: 'Hanya melihat laporan', roles: ['User_Report'], groups: ['/RS/Management'] },
{ namaLengkap: 'Farmasi Utama', namaUser: 'farmasi_utama', tipeUser: 'Farmasi', keterangan: 'Apoteker Penanggung Jawab', roles: ['User_Farmasi'], groups: ['/RS/Farmasi'] },
{ namaLengkap: 'Super Admin User', namaUser: 'superadmin', tipeUser: 'Super Admin', keterangan: 'Full Control System', roles: ['Admin_System', 'Admin_UserManagement', 'User_Standard'], groups: ['/RS/IT', '/RS/Management'] },
// Adding more generic data to make the list longer and sequential
{ namaLengkap: 'Klinik Umum', namaUser: 'klinik_umum', tipeUser: 'Klinik', keterangan: 'Dokter Umum', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
{ namaLengkap: 'Manajer Keuangan', namaUser: 'manager_keu', tipeUser: 'Manager', keterangan: 'Pengelola Anggaran', roles: ['Manager_View', 'User_Report'], groups: ['/RS/Management'] },
];
// Map over the raw data to assign sequential IDs starting from 1
const allUserData = ref(rawUserData.map((item, index) => ({
...item,
id: index + 1
})));
// State untuk menampilkan/menyembunyikan formulir
const showForm = ref(false);
const readOnly = ref(false);
const headers = ref([
{ title: 'No', key: 'id' },
{ title: 'Nama Lengkap', key: 'namaLengkap', sortable: true },
{ title: 'Nama User', key: 'namaUser', sortable: true },
{ title: 'Tipe User', key: 'tipeUser', sortable: true },
{ title: 'Roles', key: 'roles', sortable: false },
{ title: 'Groups', key: 'groups', sortable: false },
{ title: 'Keterangan', key: 'keterangan', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
]);
// Breadcrumbs
const breadcrumbs = ref([
{ title: 'Dashboard', disabled: false, href: '#/dashboard' },
{ title: 'Setting', disabled: false, href: '#/setting' },
{ title: 'User Login', disabled: false, href: '#/setting/userlogin' },
]);
// State untuk tabel dan paginasi
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
const itemToDelete = ref(null);
// State untuk dialog dan form
const showDeleteDialog = ref(false);
const isEditMode = ref(false);
const editedIndex = ref(-1);
const snackbar = ref({
show: false,
message: '',
color: 'success',
timeout: 3000,
});
// Updated `editedItem` structure with new fields
const editedItem = ref({
id: 0,
namaLengkap: '',
namaUser: '',
tipeUser: '',
keterangan: '',
password: '',
roles: [],
groups: []
});
// Computed properties untuk paginasi kustom
const pageCount = computed(() => {
return Math.ceil(allUserData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allUserData.value.length);
const total = allUserData.value.length;
// Handle case where total is 0 (no data)
if (total === 0) return 'Showing 0 to 0 of 0 entries';
return `Showing ${start} to ${end} of ${total} entries`;
});
// FUNCTIONS
const resetForm = () => {
editedItem.value = {
id: 0,
namaLengkap: '',
namaUser: '',
tipeUser: '',
keterangan: '',
password: '',
roles: [],
groups: []
};
};
const showAddForm = () => {
resetForm();
isEditMode.value = false;
readOnly.value = false;
showForm.value = true;
};
const viewItem = (item) => {
// We copy the item data for view mode
editedItem.value = Object.assign({}, item);
isEditMode.value = false;
readOnly.value = true;
showForm.value = true;
};
const editItem = (item) => {
// Find the index of the item in the actual array
editedIndex.value = allUserData.value.findIndex(data => data.id === item.id);
// Copy the item data to the editedItem
editedItem.value = Object.assign({}, item);
// Clear password field for security (will only be set if user types a new one)
editedItem.value.password = '';
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
};
const saveItem = () => {
// Basic validation (can be expanded)
if (!editedItem.value.namaLengkap || !editedItem.value.namaUser || !editedItem.value.tipeUser) {
snackbar.value = { show: true, message: 'Nama Lengkap, Nama User, dan Tipe User wajib diisi!', color: 'error', timeout: 3000 };
return;
}
// Remove password from the final object for display purposes,
// as it should be securely handled in a real backend API.
const { password, ...itemData } = editedItem.value;
if (isEditMode.value) {
// Edit existing item
if (editedIndex.value > -1) {
Object.assign(allUserData.value[editedIndex.value], itemData);
snackbar.value = { show: true, message: 'Data pengguna berhasil diperbarui!', color: 'success', timeout: 3000 };
}
} else {
// Add new item: find the highest current ID and add 1
const maxId = allUserData.value.length > 0 ? Math.max(...allUserData.value.map(item => item.id)) : 0;
itemData.id = maxId + 1;
allUserData.value.push(itemData);
snackbar.value = { show: true, message: 'Pengguna baru berhasil ditambahkan!', color: 'success', timeout: 3000 };
}
cancelForm();
};
const deleteItem = (item) => {
itemToDelete.value = item;
showDeleteDialog.value = true;
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
itemToDelete.value = null;
};
const confirmDelete = () => {
if (itemToDelete.value) {
// Find the index of the item to delete
const index = allUserData.value.findIndex(data => data.id === itemToDelete.value.id);
if (index > -1) {
// Remove the item
allUserData.value.splice(index, 1);
// Re-index the remaining items to keep the 'id' column sequential visually
allUserData.value = allUserData.value.map((item, i) => ({
...item,
id: i + 1
}));
snackbar.value = { show: true, message: 'Data pengguna berhasil dihapus!', color: 'warning', timeout: 3000 };
}
}
closeDeleteDialog();
};
const cancelForm = () => {
showForm.value = false;
resetForm();
editedIndex.value = -1;
};
</script>
<style scoped>
/* Custom Table Styling for better visual separation */
.custom-table :deep(table) {
border-collapse: collapse;
}
.custom-table :deep(th) {
background-color: #f5f5f5 !important;
font-weight: bold;
font-size: 0.875rem; /* text-sm */
}
.custom-table :deep(td) {
padding-top: 12px !important;
padding-bottom: 12px !important;
}
</style>
-308
View File
@@ -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>
-437
View File
@@ -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>
+174
View File
@@ -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

+25
View File
@@ -0,0 +1,25 @@
// server/api/auth/[...].ts
import { NuxtAuthHandler } from '#auth'
export default NuxtAuthHandler({
secret: useRuntimeConfig().authSecret,
providers: [
{
id: 'keycloak',
name: 'Keycloak',
type: 'oidc',
issuer: useRuntimeConfig().keycloakIssuer,
clientId: useRuntimeConfig().keycloakClientId,
clientSecret: useRuntimeConfig().keycloakClientSecret,
checks: ['pkce', 'state'],
profile(profile) {
return {
id: profile.sub,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: profile.picture,
}
},
}
]
})
+141
View File
@@ -0,0 +1,141 @@
// server/api/auth/keycloak-callback.ts - FIX APPLIED
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig();
const query = getQuery(event);
console.log('🔄 === KEYCLOAK CALLBACK STARTED ===');
console.log('📋 Query parameters:', query);
const code = query.code as string;
const state = query.state as string;
const error = query.error as string;
const storedState = getCookie(event, 'oauth_state');
if (error) {
console.error('❌ OAuth error from Keycloak:', error);
const errorDescription = query.error_description as string;
console.error('❌ Error description:', errorDescription);
const errorMsg = encodeURIComponent(`Keycloak error: ${error} - ${errorDescription || 'Please try again'}`);
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
console.log('📝 Code received:', !!code);
console.log('🎲 State from URL:', state);
console.log('🎲 State from cookie:', storedState);
console.log('🎲 State validation:', state === storedState);
if (!state || state !== storedState) {
console.error('❌ Invalid state parameter - possible CSRF attack');
console.error('   Expected:', storedState);
console.error('   Received:', state);
const errorMsg = encodeURIComponent('Security validation failed. Please try logging in again.');
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
deleteCookie(event, 'oauth_state');
if (!code) {
console.error('❌ Authorization code not provided');
const errorMsg = encodeURIComponent('No authorization code received from Keycloak.');
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
const tokenUrl = `${config.keycloakIssuer}/protocol/openid-connect/token`;
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`;
// ... (Token exchange logic remains the same) ...
const tokenPayload = new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.keycloakClientId,
client_secret: config.keycloakClientSecret,
code,
redirect_uri: redirectUri,
});
const tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: tokenPayload,
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
console.error('❌ Token exchange failed:', errorText);
const errorMsg = encodeURIComponent(`Token exchange failed: ${tokenResponse.status} - Please check Keycloak configuration`);
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
const tokens = await tokenResponse.json();
// ... (Token decoding and sessionData creation remains the same) ...
let idTokenPayload;
try {
idTokenPayload = JSON.parse(
Buffer.from(tokens.id_token.split('.')[1], 'base64').toString()
);
} catch (decodeError) {
console.error('❌ Failed to decode ID token:', decodeError);
const errorMsg = encodeURIComponent('Invalid ID token format');
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
const sessionData = {
user: {
id: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name || idTokenPayload.preferred_username,
preferred_username: idTokenPayload.preferred_username,
given_name: idTokenPayload.given_name,
family_name: idTokenPayload.family_name,
},
accessToken: tokens.access_token,
idToken: tokens.id_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + (tokens.expires_in * 1000),
createdAt: Date.now(),
};
// ----------------------------------------------------
// 👇 CRITICAL FIX FOR DEPLOYED HTTPS ENVIRONMENTS 👇
// ----------------------------------------------------
// Check if the request was originally HTTPS (via proxy)
const isSecure = process.env.NODE_ENV === 'production' ||
event.node.req.headers['x-forwarded-proto'] === 'https';
console.log('🔗 Setting session cookie with secure flag:', isSecure);
setCookie(event, 'user_session', JSON.stringify(sessionData), {
httpOnly: true,
// CRITICAL: Must be TRUE when operating over HTTPS (deployed)
secure: isSecure,
// Ensures cookie is sent on cross-site redirects (Keycloak -> Your App)
sameSite: 'lax',
maxAge: tokens.expires_in,
path: '/',
});
console.log('✅ Session cookie created successfully');
// Note: The following line will still log false because the cookie
// is in the response header, not the request header yet. This is expected.
const testCookie = getCookie(event, 'user_session');
console.log('🧪 Cookie test - can read back in this handler (Expected False):', !!testCookie);
console.log('↪️ Redirecting to dashboard...');
return sendRedirect(event, '/dashboard?authenticated=true');
} catch (error: any) {
console.error('❌ === CALLBACK ERROR ===');
console.error('❌ Error message:', error.message);
console.error('❌ Error stack:', error.stack);
console.error('❌ ==================');
const errorMsg = encodeURIComponent(`Authentication failed: ${error.message}`);
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
});
+65
View File
@@ -0,0 +1,65 @@
// server/api/auth/keycloak-login.ts
import { randomBytes } from 'crypto'
export default defineEventHandler(async (event) => {
console.log('🔐 Keycloak Login Handler Called')
console.log('📍 Method:', getMethod(event))
try {
const config = useRuntimeConfig()
// Debug: Log runtime config (without secrets)
console.log('🔧 Runtime Config Check:')
console.log(' - Has keycloakIssuer:', !!config.keycloakIssuer)
console.log(' - Has keycloakClientId:', !!config.keycloakClientId)
console.log(' - Has keycloakSecret:', !!config.keycloakClientSecret)
console.log(' - Issuer value:', config.keycloakIssuer)
// Validate required configuration
if (!config.keycloakIssuer) {
throw new Error('KEYCLOAK_ISSUER is not configured')
}
if (!config.keycloakClientId) {
throw new Error('KEYCLOAK_CLIENT_ID is not configured')
}
// Generate state parameter for security
const state = randomBytes(32).toString('hex')
console.log('🎲 Generated state:', state.substring(0, 8) + '...')
// Store state in session cookie
setCookie(event, 'oauth_state', state, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 600 // 10 minutes
})
// Build Keycloak authorization URL
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`
const authUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/auth`)
authUrl.searchParams.set('client_id', config.keycloakClientId)
authUrl.searchParams.set('redirect_uri', redirectUri)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', 'openid profile email')
authUrl.searchParams.set('state', state)
console.log('🏗️ Auth URL built:', authUrl.toString())
return {
success: true,
data: {
authUrl: authUrl.toString()
}
}
} catch (error: any) {
console.error('❌ Login Error:', error.message)
throw createError({
statusCode: 500,
statusMessage: `Failed to generate authorization URL: ${error.message}`
})
}
})
+74
View File
@@ -0,0 +1,74 @@
// server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig();
console.log('🚪 Logout handler called');
// Get the current session to retrieve tokens
const sessionCookie = getCookie(event, 'user_session');
let idToken = null;
if (sessionCookie) {
try {
const session = JSON.parse(sessionCookie);
idToken = session.idToken;
console.log('🔑 ID token found in session:', !!idToken);
} catch (error) {
console.warn('⚠️ Could not parse session cookie:', error);
}
} else {
console.warn('⚠️ No session cookie found');
}
// Clear all auth-related cookies
console.log('🧹 Clearing session cookies...');
deleteCookie(event, 'user_session');
deleteCookie(event, 'oauth_state');
// Also clear with different path variations to be thorough
deleteCookie(event, 'user_session', { path: '/' });
deleteCookie(event, 'oauth_state', { path: '/' });
console.log('✅ Session cleared successfully');
// Construct the Keycloak logout URL with proper parameters
const logoutUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/logout`);
// Add required parameters for proper Keycloak logout - REDIRECT TO LOGIN PAGE
logoutUrl.searchParams.set('client_id', config.keycloakClientId);
logoutUrl.searchParams.set('post_logout_redirect_uri', `${config.public.authUrl}/LoginPage?logout=success`);
// If we have an ID token, add it for proper session termination
if (idToken) {
logoutUrl.searchParams.set('id_token_hint', idToken);
console.log('🔑 Added id_token_hint to logout URL');
} else {
console.warn('⚠️ No ID token available for logout hint');
}
console.log('🔗 Keycloak logout URL constructed:', logoutUrl.toString());
// Return the logout URL to the client for redirect
// This approach gives better control to the client-side code
return {
success: true,
logoutUrl: logoutUrl.toString(),
message: 'Session cleared successfully'
};
} catch (error: any) {
console.error('❌ Logout error:', error);
console.error('❌ Error stack:', error.stack);
// Even if there's an error, try to provide a basic logout URL - REDIRECT TO LOGIN PAGE
const config = useRuntimeConfig();
const fallbackLogoutUrl = `${config.keycloakIssuer}/protocol/openid-connect/logout?client_id=${config.keycloakClientId}&post_logout_redirect_uri=${encodeURIComponent(config.public.authUrl + '/LoginPage?logout=success')}`;
return {
success: false,
logoutUrl: fallbackLogoutUrl,
error: 'Logout encountered an error, but providing fallback logout URL',
message: error.message
};
}
});
+95
View File
@@ -0,0 +1,95 @@
// server/api/auth/session.get.ts
// Helper function to safely decode the JWT payload (Access Token or ID Token)
const decodeTokenPayload = (token: string | undefined): any | null => {
if (!token) return null;
try {
// Tokens are base64 encoded and separated by '.'
const parts = token.split('.');
if (parts.length < 2) return null; // Not a valid JWT format
const payloadBase64 = parts[1];
// Decode from base64 and parse the JSON
// Note: Using Buffer.from is standard in Node.js server environments (like Nitro/H3)
return JSON.parse(Buffer.from(payloadBase64, 'base64').toString());
} catch (e) {
console.error('❌ Failed to decode token payload:', e);
return null;
}
};
// --- START OF THE SINGLE EXPORT DEFAULT HANDLER ---
export default defineEventHandler(async (event) => {
console.log('🔍 Session endpoint called');
const sessionCookie = getCookie(event, 'user_session');
console.log('🍪 Session cookie exists:', !!sessionCookie);
if (!sessionCookie) {
console.log('❌ No session cookie found');
throw createError({
statusCode: 401,
statusMessage: 'No session cookie found'
});
}
try {
const session = JSON.parse(sessionCookie);
console.log('📋 Session parsed successfully');
const isExpired = Date.now() > session.expiresAt;
console.log('   Is Expired:', isExpired);
// Check if the token has expired
if (isExpired) {
console.log('⏰ Session has expired, clearing cookie');
deleteCookie(event, 'user_session');
throw createError({
statusCode: 401,
statusMessage: 'Session expired'
});
}
// Decode tokens and prepare the enhanced response data
const idTokenPayload = decodeTokenPayload(session.idToken);
const accessTokenPayload = decodeTokenPayload(session.accessToken);
// Final response object for the frontend debug page
const sessionResponse = {
// Basic User Info
user: session.user,
// Raw Tokens
idToken: session.idToken,
accessToken: session.accessToken,
refreshToken: session.refreshToken,
// Session Timestamps
expiresAt: session.expiresAt,
createdAt: session.createdAt,
// Parsed Payloads
idTokenPayload: idTokenPayload,
accessTokenPayload: accessTokenPayload,
// Raw Session Data (for Debug section)
fullSessionObject: session,
status: 'authenticated',
};
console.log('✅ Session is valid, returning full session data');
return sessionResponse;
} catch (parseError) {
console.error('❌ Failed to parse session cookie:', parseError);
// If JSON parsing fails or any other error occurs, the session is invalid
deleteCookie(event, 'user_session');
throw createError({
statusCode: 401,
statusMessage: 'Invalid session data'
});
}
});
// --- END OF THE SINGLE EXPORT DEFAULT HANDLER ---
View File
+82
View File
@@ -0,0 +1,82 @@
// stores/navItems.ts
import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core';
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
// Initial default navigation items
const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
// Add other main menu items
{ id: 2, name: "Loket Admin", icon: "mdi-account-supervisor", path: "/LoketAdmin" },
{ id: 3, name: "Ranap Admin", icon: "mdi-bed", path: "/RanapAdmin" },
{ id: 4, name: "Klinik Admin", icon: "mdi-hospital-box", path: "/KlinikAdmin" },
{ id: 5, name: "Klinik Ruang Admin", icon: "mdi-hospital-marker", path: "/KlinikRuangAdmin" },
{
id: 6,
name: "Anjungan",
icon: "mdi-account-box-multiple",
path: "",
children: [
{ id: 7, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-account-box" },
{ id: 8, name: "Admin Anjungan", path: "/Anjungan/AdminAnjungan", icon: "mdi-account-cog" },
],
},
{ id: 9, name: "Fast Track", icon: "mdi-clock-fast", path: "/FastTrack" },
{ id: 10, name: "Data Pasien", icon: "mdi-account-multiple", path: "/DataPasien" },
{
id: 11,
name: "Screen",
icon: "mdi-monitor",
path: "",
children: [
{ id: 12, name: "Antrian Masuk 1", path: "/Screen/Antrian Masuk 1", icon: "mdi-monitor" },
{ id: 13, name: "Antrian Masuk 2", path: "/Screen/Antrian Masuk 2", icon: "mdi-monitor" },
// ... more screen pages
],
},
{ id: 14, name: "List Pasien", icon: "mdi-format-list-bulleted", path: "/ListPasien" },
{
id: 15 ,
name: "Setting",
icon: "mdi-cog",
path: "",
children: [
{ id: 16, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-shield-lock-outline" },
{ id: 17, name: "User Login", path: "/setting/UserLogin", icon: "mdi-account-circle" },
{ id: 18, name: "Master Loket", path: "/setting/MasterLoket", icon: "mdi-counter" },
{ id: 19, name: "Master Klinik", path: "/setting/MasterKlinik", icon: "mdi-hospital" },
{ id: 20, name: "Master Klinik Ruang", path: "/setting/MasterKlinikRuang", icon: "mdi-hospital-box" },
{ id: 21, name: "Screen", path: "/setting/Screen", icon: "mdi-monitor" },
],
},
];
export const useNavItemsStore = defineStore('navItems', () => {
const navItems = useLocalStorage<NavItem[]>('navItems', defaultNavItems);
function updateNavItems(newItems: NavItem[]) {
// This will update the local storage and the state
navItems.value = newItems.map((item, index) => ({
...item,
id: index + 1
}));
}
function addNavItem(newItem: Omit<NavItem, 'id'>) {
const newId = navItems.value.length > 0 ? Math.max(...navItems.value.map(item => item.id)) + 1 : 1;
navItems.value.push({ ...newItem, id: newId });
}
return {
navItems,
updateNavItems,
addNavItem,
};
});
+66
View File
@@ -0,0 +1,66 @@
// types/auth.ts - Enhanced with better error handling and optional fields
export interface User {
id: string
email?: string
name?: string
preferred_username?: string
given_name?: string
family_name?: string
roles?: string[]
realm_access?: {
roles: string[]
}
// Add any other Keycloak user properties you might need
}
export interface SessionResponse {
success?: boolean // Add success indicator
user: User | null
accessToken?: string
refreshToken?: string // Often useful to track
expiresAt?: number
error?: string // For error cases
}
export interface LoginResponse {
success: boolean
data?: {
authUrl: string
}
error?: string // Add error message support
message?: string
}
export interface LogoutResponse {
success: boolean
logoutUrl?: string // Make optional in case of errors
error?: string
message?: string
}
// Additional utility types for auth state
export interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
}
// Token information interface
export interface TokenInfo {
accessToken: string
refreshToken?: string
idToken?: string
expiresAt: number
tokenType?: string
}
// Keycloak specific user info (if you need more detailed typing)
export interface KeycloakUserInfo extends User {
sub: string
email_verified?: boolean
preferred_username?: string
given_name?: string
family_name?: string
name?: string
}