diff --git a/components/layout/full/vertical-header/ProfileDD.vue b/components/layout/full/vertical-header/ProfileDD.vue index dcc4369..f19e6ac 100644 --- a/components/layout/full/vertical-header/ProfileDD.vue +++ b/components/layout/full/vertical-header/ProfileDD.vue @@ -10,7 +10,6 @@ const auth = useAuth(); // Load user data on mount onMounted(async () => { if (!auth.user.value) { - console.log('ProfileDD: Loading user data...'); await auth.checkAuth(); } }); @@ -113,7 +112,7 @@ const sessionInfo = computed(() => ({ :loading="auth.isLoading.value" :disabled="auth.isLoading.value" > - Logout + Keluar diff --git a/components/layout/full/vertical-sidebar/VerticalSidebar.vue b/components/layout/full/vertical-sidebar/VerticalSidebar.vue index 4a11733..d98c378 100644 --- a/components/layout/full/vertical-sidebar/VerticalSidebar.vue +++ b/components/layout/full/vertical-sidebar/VerticalSidebar.vue @@ -1,5 +1,5 @@ +``` + +#### Option B: Global di nuxt.config.ts +```typescript +export default defineNuxtConfig({ + router: { + middleware: ['auth', 'check-page-access'] + } +}) +``` + +### 2. Manual Check di Component + +Untuk show/hide content berdasarkan akses: + +```vue + + + +``` + +### 3. Conditional Rendering dengan Allowed Pages + +```vue + + + +``` + +## Mengelola Hak Akses + +### 1. Menambah Pages ke Role + +Di halaman **Hak Akses** (`/setting/hak-akses`): + +1. Pilih role yang ingin diedit +2. Set status menjadi **Aktif** +3. Pilih pages yang boleh diakses +4. Simpan + +### 2. Format Path di Pages Array + +Path harus **exact match** dengan route path: + +✅ **Benar:** +- `/dashboard` +- `/antrean/all` +- `/setting/hak-akses` + +❌ **Salah:** +- `dashboard` (tanpa slash) +- `/dashboard/` (dengan trailing slash) +- `/antrean/*` (wildcard tidak support) + +### 3. Testing Hak Akses + +1. Login dengan user yang punya role tertentu +2. Cek console log: `Allowed pages for user: [...]` +3. Sidebar akan otomatis filter +4. Coba akses URL langsung yang tidak ada di allowed pages +5. Harus redirect ke dashboard atau first allowed page + +## Troubleshooting + +### Menu Tidak Muncul di Sidebar + +**Penyebab:** +1. Status hakAkses masih **tidak aktif** +2. Path di pages array tidak match dengan `to` di sidebarItem.ts +3. User tidak punya role yang sesuai + +**Solusi:** +```typescript +// Check di console +const { getAllowedPages } = useHakAkses(); +const pages = await getAllowedPages(); +console.log('Allowed pages:', pages); + +// Check role user +const { getRoles } = useRoles(); +console.log('User roles:', getRoles()); +``` + +### User Bisa Akses Page Tanpa Permission + +**Penyebab:** +- Middleware belum ditambahkan di page + +**Solusi:** +```vue + +``` + +### Error "You do not have access to any pages" + +**Penyebab:** +- Semua hakAkses user statusnya **tidak aktif** +- Pages array kosong di semua hakAkses user + +**Solusi:** +1. Buka `/setting/hak-akses` +2. Edit hakAkses yang sesuai dengan role user +3. Set status **Aktif** +4. Tambahkan minimal `/dashboard` di pages +5. Simpan + +## Best Practices + +### 1. Default Pages untuk Semua Role + +Pastikan semua role aktif punya akses ke: +- `/dashboard` - Landing page setelah login + +### 2. Hierarchical Access + +Jika user punya akses ke child path, berikan juga akses ke parent: +```json +{ + "pages": [ + "/setting", // Parent + "/setting/hak-akses", // Child + "/setting/user" // Child + ] +} +``` + +### 3. Testing Multiple Roles + +Test dengan user yang punya multiple roles untuk pastikan combine pages works: + +```typescript +// User dengan role: ['admin', 'dokter'] +// hakAkses admin: ['/dashboard', '/setting/user'] +// hakAkses dokter: ['/dashboard', '/antrean/all'] +// Result: ['/dashboard', '/setting/user', '/antrean/all'] ✅ +``` + +### 4. Sync Status + +Selalu pastikan: +1. ✅ Role di Keycloak +2. ✅ Auto-sync ke hakAkses.json saat login +3. ✅ Status diaktifkan di UI hak akses +4. ✅ Pages ditambahkan + +## API Endpoints + +### Get All Hak Akses +```typescript +const response = await $fetch('/api/hak-akses'); +// Returns all hakAkses with their pages +``` + +### Get User Allowed Pages (Client Side) +```typescript +const { getAllowedPages } = useHakAkses(); +const pages = await getAllowedPages(); +// Returns only pages from active hakAkses that match user's roles +``` + +## Security Notes + +1. **Server-side validation** diperlukan untuk API calls yang sensitive +2. Middleware hanya **client-side protection** - jangan andalkan untuk security critical operations +3. Gunakan `requireRole()` atau `requireAnyRole()` di server API handlers untuk real protection +4. Pages array di hakAkses.json bisa diedit manual, tapi lebih baik via UI + +## Example: Complete Setup + +```typescript +// 1. User login dengan role: 'manage-account' + +// 2. Auto-sync creates/updates hakAkses.json: +{ + "namaHakAkses": "manage-account", + "status": "tidak aktif", // Default + "pages": [] +} + +// 3. Admin edit via UI: +{ + "namaHakAkses": "manage-account", + "status": "aktif", // ← Changed + "pages": [ + "/dashboard", + "/antrean/all", + "/setting/hak-akses" + ] // ← Added +} + +// 4. User refresh/re-login: +// - Sidebar shows: Dashboard, Antrean > Semua, Settings > Hak Akses +// - Can access: /dashboard, /antrean/all, /setting/hak-akses +// - Cannot access: /antrean/list-kategori, /setting/user (will redirect) +``` diff --git a/data/mock/hakAkses.json b/data/mock/hakAkses.json index c4f77c3..68d97c4 100644 --- a/data/mock/hakAkses.json +++ b/data/mock/hakAkses.json @@ -1,32 +1,20 @@ [ { - "id": 1, - "namaHakAkses": "Super Admin", - "jumlahHalaman": 15, - "status": "aktif" + "id": "a37f9466-ea80-47a8-8540-2043f6c1ba06", + "namaHakAkses": "manage-account", + "status": "tidak aktif", + "pages": [] }, { - "id": 2, - "namaHakAkses": "Admin Operasi", - "jumlahHalaman": 8, - "status": "aktif" + "id": "aec03180-cae0-4164-a861-12e2b60861c1", + "namaHakAkses": "manage-account-links", + "status": "tidak aktif", + "pages": [] }, { - "id": 3, - "namaHakAkses": "Dokter", - "jumlahHalaman": 5, - "status": "aktif" - }, - { - "id": 4, - "namaHakAkses": "Perawat", - "jumlahHalaman": 4, - "status": "aktif" - }, - { - "id": 5, - "namaHakAkses": "Admin Rekam Medis", - "jumlahHalaman": 6, - "status": "tidak aktif" + "id": "4917180d-1b8c-4f92-a8a6-ae40852415ae", + "namaHakAkses": "view-profile", + "status": "tidak aktif", + "pages": [] } -] +] \ No newline at end of file diff --git a/data/mock/users.json b/data/mock/users.json index 303f0a4..cf878ba 100644 --- a/data/mock/users.json +++ b/data/mock/users.json @@ -1,42 +1,24 @@ [ { - "id": 1, - "namaUser": "Dr. John Doe", - "username": "john.doe", - "email": "john.doe@hospital.com", - "hakAkses": "Dokter", + "id": "d6621539-9e8e-4937-ba9a-fca68f625e39", + "namaUser": "yusron", + "email": "yusron.sandbox@gmai.com", + "hakAkses": [ + "manage-account", + "manage-account-links", + "view-profile" + ], "status": "aktif" }, { - "id": 2, - "namaUser": "Admin Sistem", - "username": "admin", - "email": "admin@hospital.com", - "hakAkses": "Super Admin", + "id": "a2507dbf-5dfc-4f38-bdd5-bb227eb20015", + "namaUser": "Akbar Attallah", + "email": "akbarantrean@gmail.com", + "hakAkses": [ + "manage-account", + "manage-account-links", + "view-profile" + ], "status": "aktif" - }, - { - "id": 3, - "namaUser": "Perawat Siti", - "username": "siti.perawat", - "email": "siti@hospital.com", - "hakAkses": "Perawat", - "status": "aktif" - }, - { - "id": 4, - "namaUser": "Admin Operasi 1", - "username": "admin.op1", - "email": "adminop1@hospital.com", - "hakAkses": "Admin Operasi", - "status": "aktif" - }, - { - "id": 5, - "namaUser": "RM Staff", - "username": "rm.staff", - "email": "rm@hospital.com", - "hakAkses": "Admin Rekam Medis", - "status": "tidak aktif" } -] +] \ No newline at end of file diff --git a/data/users.db b/data/users.db index 5bc6f90..5db7387 100644 Binary files a/data/users.db and b/data/users.db differ diff --git a/middleware/checkPageAccess.ts b/middleware/checkPageAccess.ts new file mode 100644 index 0000000..f1ff24b --- /dev/null +++ b/middleware/checkPageAccess.ts @@ -0,0 +1,41 @@ +// middleware/checkPageAccess.ts +// Middleware to check if user has access to the page based on hakAkses + +export default defineNuxtRouteMiddleware(async (to, from) => { + // Skip check for auth pages, error pages + const publicPaths = ['/auth/login', '/auth/register', '/']; + if (publicPaths.includes(to.path)) { + return; + } + + const { user } = useUserInfo(); + + // If not authenticated, let auth middleware handle it + if (!user.value) { + return; + } + + const { getAllowedPages } = useHakAkses(); + + try { + const allowedPages = await getAllowedPages(); + + // Check if user has access to this page + if (!allowedPages.includes(to.path)) { + console.warn(`Access denied to ${to.path}. User allowed pages:`, allowedPages); + + // Redirect to dashboard or first allowed page + if (allowedPages.length > 0) { + const firstPage = allowedPages.includes('/dashboard') ? '/dashboard' : allowedPages[0]; + return navigateTo(firstPage); + } else { + // No access to any page - redirect to login with error + return navigateTo('/auth/login?error=' + encodeURIComponent('You do not have access to any pages. Please contact administrator.')); + } + } + } catch (error) { + console.error('Error checking page access:', error); + // Allow access on error to prevent blocking user + return; + } +}); diff --git a/pages/antrean/all.vue b/pages/antrean/all.vue index 215b11c..ca2d160 100644 --- a/pages/antrean/all.vue +++ b/pages/antrean/all.vue @@ -4,7 +4,7 @@ import TableAntrian from '@/components/pendaftaran/TableAntrian.vue'; import ModalPendaftaran from '@/components/pendaftaran/ModalPendaftaran.vue'; import ModalDetailPendaftaran from '@/components/pendaftaran/ModalDetailPendaftaran.vue'; import ModalUpdateStatus from '@/components/pendaftaran/ModalUpdateStatus.vue'; -import { getAntrianOperasi } from '~/services/antrean'; +import { getAntrianOperasi, deleteAntrianOperasi } from '~/services/antrean'; import { Icon } from '@iconify/vue'; import type { AntreanOperasi } from '~/types/antrean'; import { STATUS } from '~/types/antrean'; @@ -110,7 +110,7 @@ const showSnackbar = (message: string, color: string = 'success') => { snackbar.value = true; }; -const headers = [ +const allHeaders = [ { title: 'Nomor', key: 'NoUrutKategori', width: '50px', align: 'center' as const, sortable: false }, { title: 'Nomor Spesialis', key: 'NoUrutSpesialis', width: '100px', align: 'center' as const, sortable: false }, { title: 'Nomor Sub Spesialis', key: 'NoUrutSubSpesialis', width: '140px', align: 'center' as const, sortable: false }, @@ -122,12 +122,34 @@ const headers = [ { title: 'Actions', key: 'actions', sortable: false, width: '120px' } ]; -const actions = [ - { icon: 'mdi-eye', color: 'info', tooltip: 'View', event: 'view' }, - { icon: 'mdi-pencil', color: 'primary', tooltip: 'Edit', event: 'edit' }, - { icon: 'mdi-clipboard-check', color: 'success', tooltip: 'Update Status', event: 'updateStatus' }, - { icon: 'mdi-delete', color: 'error', tooltip: 'Delete', event: 'delete' } -]; +// Hide queue number columns when filtering +const headers = computed(() => { + const isFiltering = search.value || statusFilter.value; + if (isFiltering) { + return allHeaders.filter(header => + !['NoUrutKategori', 'NoUrutSpesialis', 'NoUrutSubSpesialis'].includes(header.key) + ); + } + return allHeaders; +}); + +// Function to get actions based on item status +const getActionsForItem = (item: AntreanOperasi) => { + const allActions = [ + { icon: 'mdi-eye', color: 'info', tooltip: 'View', event: 'view' }, + { icon: 'mdi-pencil', color: 'primary', tooltip: 'Edit', event: 'edit' }, + { icon: 'mdi-clipboard-check', color: 'success', tooltip: 'Update Status', event: 'updateStatus' }, + { icon: 'mdi-delete', color: 'error', tooltip: 'Delete', event: 'delete' } + ]; + + // If status is Selesai or Batal, only show View action + if (item.StatusOperasi === STATUS.SELESAI || item.StatusOperasi === STATUS.BATAL) { + return allActions.filter(action => action.event === 'view'); + } + + // For other statuses (Belum, Tunda), show all actions + return allActions; +}; const handleView = async (item: unknown) => { const data = item as AntreanOperasi; @@ -153,9 +175,21 @@ const handleUpdateStatus = async (item: unknown) => { showUpdateStatusModal.value = true; }; -const handleDelete = (item: unknown) => { +const handleDelete = async (item: unknown) => { + const data = item as AntreanOperasi; if(confirm('Apakah Anda yakin ingin menghapus data ini?')) { - showSnackbar('Data berhasil dihapus.', 'success'); + try { + const response = await deleteAntrianOperasi(data.id); + if (response.success) { + showSnackbar('Data berhasil dihapus.', 'success'); + fetchData(); // Refresh the table after deletion + } else { + showSnackbar(response.message || 'Gagal menghapus data', 'error'); + } + } catch (error: any) { + console.error('Error deleting antrian operasi:', error); + showSnackbar(error.response?.data?.message || 'Gagal menghapus data', 'error'); + } } }; @@ -229,7 +263,7 @@ const handleUpdateStatusSuccess = () => { >([]); const kategoriAntrianData = ref>([]); +const antrianPerHariData = ref>([]); // Fetch data perbandingan status antrian const fetchStatusAntrian = async () => { @@ -84,6 +88,89 @@ const fetchKategoriAntrian = async () => { } }; +// Fetch data antrian per hari +const fetchAntrianPerHari = async () => { + loadingAntrianPerHari.value = true; + try { + const response = await api.get('/dashboard/antrian-per-hari', { + params: { + year: selectedYear.value, + month: selectedMonth.value + 1 // API expects 1-12, not 0-11 + } + }); + + if (response.data?.success && response.data?.data) { + antrianPerHariData.value = response.data.data; + } + } catch (error) { + console.error('Error fetching antrian per hari:', error); + antrianPerHariData.value = []; + } finally { + loadingAntrianPerHari.value = false; + } +}; + +// Fetch data antrian per spesialis +const fetchAntrianPerSpesialis = async () => { + loadingAntrianPerSpesialis.value = true; + try { + const response = await api.get('/dashboard/table-antrian-per-spesialis', { + params: { + year: selectedYear.value, + month: selectedMonth.value + 1 // API expects 1-12, not 0-11 + } + }); + + if (response.data?.success && response.data?.data) { + // Transform API data to match table headers (lowercase keys) + antrianPerSpesialis.value = response.data.data.map((item: any) => ({ + spesialis: item.Spesialis, + total: item.Total, + belum: item.Belum, + selesai: item.Selesai, + tunda: item.Tunda, + batal: item.Batal + })); + } + } catch (error) { + console.error('Error fetching antrian per spesialis:', error); + antrianPerSpesialis.value = []; + } finally { + loadingAntrianPerSpesialis.value = false; + } +}; + +// Fetch data antrian per subspesialis +const fetchAntrianPerSubspesialis = async () => { + loadingAntrianPerSubspesialis.value = true; + try { + const response = await api.get('/dashboard/table-antrian-per-subspesialis', { + params: { + year: selectedYear.value, + month: selectedMonth.value + 1 // API expects 1-12, not 0-11 + } + }); + + if (response.data?.success && response.data?.data) { + // Transform API data to match table headers (lowercase keys) + antrianPerSubspesialis.value = response.data.data.map((item: any) => ({ + spesialis: item.Spesialis, + subspesialis: item.SubSpesialis, + total: item.Total, + belum: item.Belum, + selesai: item.Selesai, + tunda: item.Tunda, + batal: item.Batal + })); + } + } catch (error) { + console.error('Error fetching antrian per subspesialis:', error); + antrianPerSubspesialis.value = []; + } finally { + loadingAntrianPerSubspesialis.value = false; + } +}; + // Computed properties untuk total masing-masing status const totalBelum = computed(() => { const item = statusAntrianData.value.find(d => d.statust === 'Belum'); @@ -118,6 +205,9 @@ const handleModalSuccess = () => { // Refresh data dashboard setelah pendaftaran berhasil fetchStatusAntrian(); fetchKategoriAntrian(); + fetchAntrianPerHari(); + fetchAntrianPerSpesialis(); + fetchAntrianPerSubspesialis(); console.log('Pendaftaran berhasil'); }; @@ -199,9 +289,16 @@ const pieKategoriChartSeries = computed(() => { // Data untuk Line Chart - Antrian Per Hari dalam 1 Bulan const lineChartOptions = computed(() => { - const daysInMonth = new Date(selectedYear.value, selectedMonth.value + 1, 0).getDate(); const monthName = months[selectedMonth.value].text.substring(0, 3); + // Get categories from API data or generate default + const categories = antrianPerHariData.value.length > 0 + ? antrianPerHariData.value.map(item => { + const date = new Date(item.TanggalDaftar); + return `${date.getDate()} ${monthName}`; + }) + : []; + return { chart: { type: 'line', @@ -222,7 +319,7 @@ const lineChartOptions = computed(() => { width: 3 }, xaxis: { - categories: Array.from({ length: daysInMonth }, (_, i) => `${i + 1} ${monthName}`), + categories: categories, title: { text: 'Tanggal' } @@ -251,56 +348,42 @@ const lineChartOptions = computed(() => { }); const lineChartSeries = computed(() => { - const daysInMonth = new Date(selectedYear.value, selectedMonth.value + 1, 0).getDate(); - - // Generate random data for demo purposes - const generateData = (base: number, variance: number) => { - return Array.from({ length: daysInMonth }, () => - Math.floor(Math.random() * variance) + base - ); - }; + // If no data from API, return empty series + if (antrianPerHariData.value.length === 0) { + return [ + { name: 'Belum', data: [] }, + { name: 'Selesai', data: [] }, + { name: 'Tunda', data: [] }, + { name: 'Batal', data: [] } + ]; + } + // Map data from API return [ { name: 'Belum', - data: generateData(15, 20) + data: antrianPerHariData.value.map(item => item.Belum) }, { name: 'Selesai', - data: generateData(25, 25) + data: antrianPerHariData.value.map(item => item.Selesai) }, { name: 'Tunda', - data: generateData(5, 10) + data: antrianPerHariData.value.map(item => item.Tunda) }, { name: 'Batal', - data: generateData(2, 6) + data: antrianPerHariData.value.map(item => item.Batal) } ]; }); -// Data antrian per spesialis -const antrianPerSpesialis = ref([ - { spesialis: 'Bedah Umum', total: 15, belum: 12, selesai: 3, tunda: 1, batal: 1 }, - { spesialis: 'Bedah Ortopedi', total: 10, belum: 8, selesai: 2, tunda: 1, batal: 0 }, - { spesialis: 'Bedah Saraf', total: 8, belum: 7, selesai: 1, tunda: 0, batal: 0 }, - { spesialis: 'Bedah Jantung', total: 12, belum: 10, selesai: 2, tunda: 0, batal: 0 }, - { spesialis: 'Bedah Plastik', total: 7, belum: 5, selesai: 2, tunda: 0, batal: 0 }, - { spesialis: 'Bedah Urologi', total: 5, belum: 3, selesai: 2, tunda: 0, batal: 0 } -]); +// Data antrian per spesialis - populated from API +const antrianPerSpesialis = ref>([]); -// Data antrian per subspesialis -const antrianPerSubspesialis = ref([ - { spesialis: 'Bedah Umum', subspesialis: 'Digestif', total: 8, belum: 6, selesai: 2, tunda: 1, batal: 0 }, - { spesialis: 'Bedah Umum', subspesialis: 'Onkologi', total: 7, belum: 5, selesai: 2, tunda: 0, batal: 0 }, - { spesialis: 'Bedah Ortopedi', subspesialis: 'Traumatologi', total: 6, belum: 5, selesai: 1, tunda: 0, batal: 0 }, - { spesialis: 'Bedah Ortopedi', subspesialis: 'Spine', total: 4, belum: 3, selesai: 1, tunda: 0, batal: 0 }, - { spesialis: 'Bedah Saraf', subspesialis: 'Tumor', total: 5, belum: 4, selesai: 1, tunda: 0, batal: 0 }, - { spesialis: 'Bedah Saraf', subspesialis: 'Vaskular', total: 3, belum: 3, selesai: 0, tunda: 0, batal: 0 }, - { spesialis: 'Bedah Jantung', subspesialis: 'Dewasa', total: 7, belum: 6, selesai: 1, tunda: 0, batal: 0 }, - { spesialis: 'Bedah Jantung', subspesialis: 'Anak', total: 5, belum: 4, selesai: 1, tunda: 0, batal: 0 } -]); +// Data antrian per subspesialis - populated from API +const antrianPerSubspesialis = ref>([]); const searchSpesialis = ref(''); const searchSubspesialis = ref(''); @@ -328,12 +411,18 @@ const headersSubspesialis = [ watch([selectedMonth, selectedYear], () => { fetchStatusAntrian(); fetchKategoriAntrian(); + fetchAntrianPerHari(); + fetchAntrianPerSpesialis(); + fetchAntrianPerSubspesialis(); }); // Fetch data saat component mounted onMounted(() => { fetchStatusAntrian(); fetchKategoriAntrian(); + fetchAntrianPerHari(); + fetchAntrianPerSpesialis(); + fetchAntrianPerSubspesialis(); }); definePageMeta({ @@ -552,7 +641,10 @@ definePageMeta({ - +
+ +
+ - + + +