31 changed files with 1642 additions and 253 deletions

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ nuxtApp.hook('page:finish', () => {
// Naikkan versi ini setiap kali ada perubahan struktur state (schema migration). // Naikkan versi ini setiap kali ada perubahan struktur state (schema migration).
// Saat versi tidak cocok, semua cache Pinia akan dibersihkan otomatis. // Saat versi tidak cocok, semua cache Pinia akan dibersihkan otomatis.
const STORE_SCHEMA_VERSION = 3; const STORE_SCHEMA_VERSION = 4;
const VERSION_KEY = 'app-store-version'; const VERSION_KEY = 'app-store-version';
// Daftar semua localStorage key yang dikelola oleh Pinia stores // Daftar semua localStorage key yang dikelola oleh Pinia stores
+95 -31
View File
@@ -4,29 +4,76 @@
<thead> <thead>
<tr> <tr>
<th class="text-left font-weight-bold">Halaman</th> <th class="text-left font-weight-bold">Halaman</th>
<th class="text-center font-weight-bold" style="width: 120px;">Akses</th> <th class="text-center font-weight-bold" style="width: 80px;">Access</th>
<th class="text-center font-weight-bold" style="width: 80px;">View</th>
<th class="text-center font-weight-bold" style="width: 80px;">Add</th>
<th class="text-center font-weight-bold" style="width: 80px;">Edit</th>
<th class="text-center font-weight-bold" style="width: 80px;">Delete</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="item in flatNavItems" :key="item.path || item.name"> <template v-for="item in flatNavItems" :key="item.menuKey || item.name">
<tr :class="{ 'bg-grey-lighten-4': !item.path }"> <tr :class="{ 'bg-grey-lighten-4': !item.menuKey }">
<td :style="{ paddingLeft: item.level * 24 + 'px' }"> <td :style="{ paddingLeft: item.level * 24 + 'px' }">
<div class="d-flex align-center"> <div class="d-flex align-center">
<v-icon :icon="item.icon" size="small" class="mr-2" color="grey"></v-icon> <v-icon :icon="item.icon" size="small" class="mr-2" color="grey"></v-icon>
<span :class="{ 'font-weight-bold': !item.path }">{{ item.name }}</span> <span :class="{ 'font-weight-bold': !item.menuKey }">{{ item.name }}</span>
</div> </div>
</td> </td>
<td class="text-center"> <td class="text-center">
<v-checkbox <v-checkbox
v-if="item.path" v-if="item.menuKey"
:model-value="isAllowed(item.path)" :model-value="getPermission(item.menuKey, 'canAccess')"
@update:model-value="toggleAccess(item, !!$event)" @update:model-value="toggleAccess(item, 'canAccess', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canView')"
@update:model-value="toggleAccess(item, 'canView', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canAdd')"
@update:model-value="toggleAccess(item, 'canAdd', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canEdit')"
@update:model-value="toggleAccess(item, 'canEdit', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canDelete')"
@update:model-value="toggleAccess(item, 'canDelete', !!$event)"
hide-details hide-details
density="compact" density="compact"
color="primary" color="primary"
class="d-inline-flex" class="d-inline-flex"
></v-checkbox> ></v-checkbox>
<v-icon v-else icon="mdi-folder-open" size="small" color="grey-lighten-1"></v-icon>
</td> </td>
</tr> </tr>
</template> </template>
@@ -37,24 +84,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useNavItemsStore } from '~/stores/navItems1'; import { defaultNavItems } from '~/stores/navItems1';
import type { HakAksesMenu } from '~/server/utils/schemas/permissionSchema';
const props = defineProps({ const props = defineProps({
pages: { hakAksesMenu: {
type: Array as () => any[], // Be flexible with legacy data type: Array as () => HakAksesMenu[],
default: () => [] default: () => []
} }
}); });
const emit = defineEmits(['update:pages']); const emit = defineEmits(['update:hakAksesMenu']);
const navItemsStore = useNavItemsStore();
interface FlatNavItem { interface FlatNavItem {
name: string; name: string;
path: string; path: string;
icon: string; icon: string;
level: number; level: number;
menuKey?: string;
} }
const flatNavItems = computed(() => { const flatNavItems = computed(() => {
@@ -66,7 +113,8 @@ const flatNavItems = computed(() => {
name: item.name, name: item.name,
path: item.path, path: item.path,
icon: item.icon, icon: item.icon,
level level,
menuKey: item.menuKey
}); });
if (item.children && item.children.length > 0) { if (item.children && item.children.length > 0) {
walk(item.children, level + 1); walk(item.children, level + 1);
@@ -74,32 +122,48 @@ const flatNavItems = computed(() => {
}); });
}; };
walk(navItemsStore.getNavItems); walk(defaultNavItems);
return result; return result;
}); });
const isAllowed = (path: string) => { const getPermission = (menuKey: string, action: keyof HakAksesMenu): boolean => {
if (!props.pages || !Array.isArray(props.pages)) return false; if (!props.hakAksesMenu || !Array.isArray(props.hakAksesMenu)) return false;
const menuConfig = props.hakAksesMenu.find((p: HakAksesMenu) => p.menuKey === menuKey);
return props.pages.some(p => { if (!menuConfig) return false;
if (typeof p === 'string') return p === path; return !!menuConfig[action];
if (p && typeof p === 'object' && p.path) return p.path === path;
return false;
});
}; };
const toggleAccess = (item: FlatNavItem, allowed: boolean) => { const toggleAccess = (item: FlatNavItem, action: keyof HakAksesMenu, value: boolean) => {
let newPages = [...props.pages]; if (!item.menuKey) return;
if (allowed) { // Clone current array
if (!newPages.includes(item.path)) { let newMenus = JSON.parse(JSON.stringify(props.hakAksesMenu || []));
newPages.push(item.path);
let menuIndex = newMenus.findIndex((m: HakAksesMenu) => m.menuKey === item.menuKey);
if (menuIndex === -1) {
if (value) {
newMenus.push({
menuKey: item.menuKey,
name: item.name,
canAccess: action === 'canAccess' ? true : false,
canView: action === 'canView' ? true : false,
canAdd: action === 'canAdd' ? true : false,
canEdit: action === 'canEdit' ? true : false,
canDelete: action === 'canDelete' ? true : false
});
} }
} else { } else {
newPages = newPages.filter(p => p !== item.path); newMenus[menuIndex][action] = value;
// Auto-grant canAccess and canView if Add/Edit/Delete is checked
if (value && (action === 'canAdd' || action === 'canEdit' || action === 'canDelete')) {
newMenus[menuIndex].canAccess = true;
newMenus[menuIndex].canView = true;
}
// If all are false, maybe remove it, but keeping it is fine too
} }
emit('update:pages', newPages); emit('update:hakAksesMenu', newMenus);
}; };
</script> </script>
@@ -3,17 +3,31 @@
<v-card-text class="pa-4"> <v-card-text class="pa-4">
<div class="section-header mb-3"> <div class="section-header mb-3">
<div class="section-label">SEDANG DIPROSES</div> <div class="section-label">SEDANG DIPROSES</div>
<v-btn <div class="d-flex align-center">
v-if="patient" <v-btn
class="call-button text-white" v-if="patient"
color="primary-500" variant="outlined"
variant="flat" height="36"
size="small" rounded="lg"
@click="$emit('call')" class="data-pasien-btn bg-white mr-3 text-none font-weight-bold"
> @click="showPatientDataDialog = true"
<v-icon start size="18">mdi-bullhorn</v-icon> >
Panggil <v-icon start size="18">mdi-account-cog-outline</v-icon>
</v-btn> Data Pasien
</v-btn>
<v-btn
v-if="patient"
class="call-button text-white text-none font-weight-bold"
color="primary-500"
variant="flat"
height="36"
rounded="lg"
@click="$emit('call')"
>
<v-icon start size="18">mdi-microphone</v-icon>
Panggil
</v-btn>
</div>
</div> </div>
<div v-if="patient" class="patient-details" :class="`patient-details-${theme}`"> <div v-if="patient" class="patient-details" :class="`patient-details-${theme}`">
@@ -138,11 +152,30 @@
</div> </div>
</div> </div>
</v-card-text> </v-card-text>
<!-- Patient Data Dialog -->
<PatientDataDialog
v-if="patient"
v-model="showPatientDataDialog"
:patient="patient"
@linked="handlePatientLinked"
/>
</v-card> </v-card>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref } from 'vue';
import PatientDataDialog from './PatientDataDialog.vue';
const showPatientDataDialog = ref(false);
/**
* Menangani event ketika antrean berhasil dihubungkan dengan data pasien di SIMRS
* Memunculkan notifikasi (snackbar) bahwa proses berhasil.
*/
const handlePatientLinked = () => {
emit('linked', 'Data pasien berhasil dihubungkan');
};
const props = defineProps({ const props = defineProps({
patient: { patient: {
@@ -178,6 +211,11 @@ defineEmits(['action', 'change-klinik', 'process-next', 'call', 'open-klinik-rua
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.data-pasien-btn {
color: var(--color-primary-500) !important;
border-color: var(--color-primary-500) !important;
}
.current-patient-card { .current-patient-card {
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--color-neutral-500); border: 1px solid var(--color-neutral-500);
@@ -0,0 +1,318 @@
<template>
<v-dialog
v-model="isOpen"
max-width="600px"
persistent
scrollable
>
<v-card class="patient-data-dialog" rounded="xl">
<!-- Header -->
<v-card-title class="dialog-header d-flex justify-space-between align-center px-4 py-3 text-white">
<span class="text-subtitle-1 font-weight-medium">Data Pasien</span>
<v-btn icon="mdi-close" variant="text" size="small" color="white" @click="closeDialog" />
</v-card-title>
<v-card-text class="pa-6">
<!-- Antrean Number Info -->
<div class="text-center mb-6">
<div class="text-subtitle-1 text-grey-darken-1 mb-1">Nomor Antrian</div>
<div class="text-h3 font-weight-bold mb-1">{{ antrianNumber }}</div>
<div class="text-caption text-grey-darken-1 mb-2">
Barcode: <span class="font-weight-bold">{{ patient?.barcode || '-' }}</span>
</div>
<div v-if="alreadyLinked || isLinked" class="text-success mt-3 text-body-1 font-weight-medium d-flex align-center justify-center">
<v-icon start size="20" color="success">mdi-check-circle</v-icon>
{{ isLinked ? 'Berhasil dihubungkan ke RM baru' : `Sudah terhubung dengan RM: ${patient?.noRM}` }}
</div>
<div v-else-if="searchError" class="text-error mt-3 text-body-1 font-weight-medium d-flex align-center justify-center">
<v-icon start size="20">mdi-close-circle</v-icon>
{{ searchError }}
</div>
<div v-else-if="!patientData" class="text-error mt-3 text-body-1 font-weight-medium d-flex align-center justify-center">
<v-icon start size="20">mdi-alert-circle</v-icon>
Nomor Antrean belum terhubung ke pasien
</div>
</div>
<!-- Search Form -->
<div class="form-section pa-5 border rounded-xl mb-6">
<div class="section-title text-caption font-weight-bold mb-3 text-uppercase text-navy-custom" style="letter-spacing: 0.5px;">
Hubungkan Antrean
</div>
<v-text-field
v-model="searchQuery"
placeholder="Cari Nomor BPJS atau Nomor RM atau NIK.."
variant="outlined"
density="comfortable"
hide-details
prepend-inner-icon="mdi-magnify"
bg-color="white"
class="search-input"
@keyup.enter="searchPatient"
>
<template #append-inner>
<div class="d-flex align-center h-100 pr-1">
<v-btn
variant="flat"
class="search-btn bg-navy-custom text-white text-none font-weight-bold"
height="36"
min-width="80"
rounded="lg"
:loading="isLoading"
@click="searchPatient"
>
Cari
</v-btn>
</div>
</template>
</v-text-field>
</div>
<!-- Patient Info Display -->
<v-expand-transition>
<div v-if="patientData" class="info-section">
<div class="section-title text-caption font-weight-bold text-primary mb-3 text-uppercase">
Informasi Data Diri
</div>
<div class="info-grid">
<!-- Nama Lengkap -->
<div class="info-field">
<div class="text-caption text-grey-darken-1 mb-1">Nama Lengkap</div>
<div class="info-box">{{ patientData.nama }}</div>
</div>
<!-- TTL -->
<div class="info-field">
<div class="text-caption text-grey-darken-1 mb-1">Tempat / Tanggal Lahir</div>
<div class="info-box">{{ patientData.ttl }}</div>
</div>
<!-- Jenis Kelamin -->
<div class="info-field">
<div class="text-caption text-grey-darken-1 mb-1">Jenis Kelamin</div>
<div class="info-box">{{ patientData.jenisKelamin }}</div>
</div>
<!-- No Telepon -->
<div class="info-field">
<div class="text-caption text-grey-darken-1 mb-1">Nomor Telepon</div>
<div class="info-box">{{ patientData.noTelepon }}</div>
</div>
<!-- Alamat -->
<div class="info-field full-width">
<div class="text-caption text-grey-darken-1 mb-1">Alamat</div>
<div class="info-box">{{ patientData.alamat }}</div>
</div>
</div>
</div>
</v-expand-transition>
</v-card-text>
<!-- Footer Actions -->
<v-card-actions v-if="patientData" class="pa-4 bg-grey-lighten-4 justify-end">
<v-btn
variant="flat"
class="bg-navy-custom text-white px-6 text-none"
prepend-icon="mdi-sync"
:loading="isLinking"
@click="linkPatient"
>
Hubungkan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
const props = defineProps<{
modelValue: boolean;
patient: any;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'linked'): void;
}>();
// State
const isOpen = ref(props.modelValue);
const searchQuery = ref('');
const isLoading = ref(false);
const isLinking = ref(false);
const isLinked = ref(false);
const searchError = ref('');
const antrianNumber = computed(() => {
return props.patient?.noAntrian ? props.patient.noAntrian.split(' |')[0] : '';
});
const alreadyLinked = computed(() => {
return !!props.patient?.noRM;
});
// Tipe data mock
interface MockPatient {
nama: string;
ttl: string;
jenisKelamin: string;
noTelepon: string;
alamat: string;
}
const patientData = ref<MockPatient | null>(null);
// Watchers
watch(() => props.modelValue, (newVal) => {
isOpen.value = newVal;
if (newVal) {
// Reset state saat modal dibuka
searchQuery.value = '';
patientData.value = null;
isLinked.value = false;
searchError.value = '';
}
});
watch(isOpen, (newVal) => {
emit('update:modelValue', newVal);
});
// Methods
const resetState = () => {
searchQuery.value = '';
patientData.value = null;
isLinked.value = false;
isLoading.value = false;
isLinking.value = false;
};
const closeDialog = () => {
isOpen.value = false;
};
/**
* Simulasi pencarian data pasien ke SIMRS.
* NANTI BISA DIGANTI DENGAN $fetch KE SERVICE API ASLI.
*/
const searchPatient = async () => {
if (!searchQuery.value.trim()) return;
isLoading.value = true;
patientData.value = null;
try {
// Reset state error sebelum pencarian
searchError.value = '';
// TODO: Ganti URL '/api/simrs/patient/' di bawah ini dengan URL API Service SIMRS yang sebenarnya
const response = await $fetch(`/api/simrs/patient/${searchQuery.value}`);
// @ts-ignore
patientData.value = response.data;
} catch (error: any) {
console.error('Gagal mencari data pasien:', error);
searchError.value = error.data?.statusMessage || `Pasien dengan nomor ${searchQuery.value} tidak ditemukan.`;
} finally {
isLoading.value = false;
}
};
/**
* Simulasi menghubungkan nomor antrean dengan data pasien di SIMRS.
*/
const linkPatient = async () => {
isLinking.value = true;
try {
// TODO: Ganti dengan API call asli untuk menghubungkan antrean
// await $fetch('/api/antrean/link', { method: 'POST', body: { ... } });
// Simulasi delay
await new Promise(resolve => setTimeout(resolve, 600));
isLinked.value = true;
emit('linked');
closeDialog();
} catch (error) {
console.error('Gagal menghubungkan data pasien:', error);
} finally {
isLinking.value = false;
}
};
</script>
<style scoped lang="scss">
.bg-navy-custom {
background-color: #003482 !important;
color: #ffffff !important;
}
.text-navy-custom {
color: #003482 !important;
}
.patient-data-dialog {
border-radius: 12px;
overflow: hidden;
.dialog-header {
background-color: #003482 !important;
}
.text-error {
color: #E53935;
}
.search-input {
:deep(.v-field) {
border-radius: 8px;
}
:deep(.v-field__append-inner) {
padding-top: 0;
padding-bottom: 0;
align-items: center;
}
}
.search-btn {
border-radius: 6px;
text-transform: none;
letter-spacing: normal;
font-weight: 500;
}
.info-section {
background-color: #F8F9FA;
border-radius: 12px;
padding: 20px;
border: 1px solid #E0E0E0;
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
.info-field {
&.full-width {
grid-column: 1 / -1;
}
.info-box {
background-color: #ECEFF1;
border-radius: 6px;
padding: 10px 14px;
font-size: 14px;
color: #333;
min-height: 40px;
}
}
}
}
}
</style>
+5
View File
@@ -170,6 +170,11 @@ export const useQueueSync = (deps: QueueSyncDeps) => {
let _autoSyncInterval: any = null; let _autoSyncInterval: any = null;
/**
* Memulai interval auto-sync (fallback) untuk memastikan data tetap segar
* meskipun koneksi WebSocket terputus atau gagal menerima event.
* Berjalan setiap 30 detik berdasarkan active interest.
*/
const startAutoSync = () => { const startAutoSync = () => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
if (_autoSyncInterval) return; if (_autoSyncInterval) return;
+46
View File
@@ -63,6 +63,36 @@ Format output:
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas --> <!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
## 2026-07-10 — Fitur Hubungkan Data Pasien ke SIMRS
**Sprint/Phase:** Phase 7 — UI/UX Feature
**Durasi:** ~1.5 jam
**Status:** ✅ Done
### Yang Dikerjakan
- Membuat UI Pop-up Modal baru `PatientDataDialog.vue` untuk mengaitkan nomor antrean yang berjalan dengan data dari SIMRS.
- Menambahkan tombol **Data Pasien** (ikon *user-cog*) di komponen `CurrentPatientCard.vue` berdampingan dengan tombol **Panggil**.
- Mengimplementasikan state management lokal untuk pop-up, termasuk simulasi proses API (*loading bar*, *mock data*, dan *success notification*).
- Menyesuaikan warna komponen secara presisi menggunakan hex code `#003482` dengan *SCSS override* untuk menembus batasan tema *default*.
- Meningkatkan *border-radius* modal ke `xl` untuk sudut yang lebih melengkung (*rounded*) sesuai desain UI/UX.
- Mengubah logika *mock data* pada *method* `searchPatient` menjadi `$fetch` (*API call*) asinkron yang menembak internal API Nuxt (`/api/simrs/patient/[id]`).
- Mengimplementasikan endpoint API backend (menggunakan *Nitro/H3*) di folder `server/api/simrs/patient/[id].get.ts` sebagai *Dummy JSON Provider* untuk di-*replace* dengan layanan asli nanti.
- Melengkapi fungsi-fungsi yang baru dibuat dengan komentar JSDoc.
### Keputusan Teknis
> **Dummy API via Nitro**: Dengan memindahkan *mock data* dari dalam komponen Vue ke `server/api/`, arsitektur kode simulasi jadi lebih mirip dengan *production*. Saat pindah ke *Service API* sungguhan nanti, front-end developer hanya perlu mengganti URL string `$fetch` tanpa harus membongkar ulang struktur logika _async_ `try-catch`-nya.
> **Pemisahan Komponen**: Daripada menggabungkan form SIMRS ke dalam `CurrentPatientCard`, diputuskan untuk membuat file baru `PatientDataDialog.vue`. Ini menjaga agar file loket tidak terlalu membengkak (modular) dan mudah dikelola atau diganti endpoint API-nya nanti.
### Masalah & Solusi
| Masalah | Solusi | Referensi |
|---------|--------|-----------|
| Warna tombol garis luar (*outlined*) selalu kembali ke warna hitam (*fallback*) dan tidak menyerap properti `color="primary"`. | Menambahkan *style SCSS* kustom secara paksa (`!important`) untuk mengisi `border-color` dan `color` sesuai `hex code` `#003482` dari desain. | `CurrentPatientCard.vue`, `PatientDataDialog.vue` |
### Besok
- [ ] Mengganti endpoint `/api/simrs/patient/` internal ini dengan URL *Service API* SIMRS yang sebenarnya saat endpoint backend sudah di-*deploy*.
---
## 2026-07-10 — Migrasi API Endpoint & Project Knowledge Base ## 2026-07-10 — Migrasi API Endpoint & Project Knowledge Base
**Sprint/Phase:** Phase 7 — Infrastruktur & Dokumentasi **Sprint/Phase:** Phase 7 — Infrastruktur & Dokumentasi
@@ -720,6 +750,22 @@ Format output:
--- ---
## 2026-07-13 — Konsolidasi Loket Eksekutif
**Sprint/Phase:** Phase 7
**Status:** ✅ Done
### Yang Dikerjakan
- Mengubah `localLoketData` pada `loketStore.js` dari 14 loket menjadi 1 loket tunggal (`LOKET EKS`) untuk melayani semua klinik eksekutif (ID 1000 hingga 1022).
- Menaikkan `STORE_SCHEMA_VERSION` pada `app.vue` (menjadi v4) untuk memicu pembersihan *cache localStorage* pada pengguna agar konfigurasi loket tunggal segera diterapkan.
### Keputusan Teknis
- Mengubah default pembuatan array untuk `localLoketData` menjadi 1 objek.
- Properti `pelayanan` (hak akses layanan) diberikan semua ID layanan dari 1000 - 1022.
- Tidak ada penambahan/perubahan logika UI karena antrean dan grid UI otomatis menyesuaikan jumlah `loketStore`.
---
<!-- Tambahkan entry baru di atas baris ini, urutan terbaru di atas --> <!-- Tambahkan entry baru di atas baris ini, urutan terbaru di atas -->
--- ---
+1
View File
@@ -258,5 +258,6 @@ docs: update DEVPLAN
| Versi | Tanggal | Author | Perubahan | | Versi | Tanggal | Author | Perubahan |
|-------|---------|--------|-----------| |-------|---------|--------|-----------|
| 1.1.1 | 2026-07-13 | Agent | Refactor `loketStore.js` untuk menggabungkan loket eksekutif menjadi 1 loket tunggal (melayani semua) |
| 1.1.0 | 2026-07-10 | Akbar | Phase 7: migrasi API, knowledge base, update docs | | 1.1.0 | 2026-07-10 | Akbar | Phase 7: migrasi API, knowledge base, update docs |
| 1.0.0 | 2026-05-25 | Akbar | Initial plan — direkonstruksi berdasarkan timeline aktual | | 1.0.0 | 2026-05-25 | Akbar | Initial plan — direkonstruksi berdasarkan timeline aktual |
+181
View File
@@ -0,0 +1,181 @@
# Integrasi Hak Akses (Permissions) di UI beserta Konfigurasi CRUD
## ✅ Hasil Pengecekan Kesiapan Project
1. **Navigasi (*Menu/Routing*)**: Sudah tersedia di `stores/navItems1.ts` (`defaultNavItems`). Array ini akan menjadi sumber daftar halaman di dialog "Edit Hak Akses" — dengan catatan penting di bagian struktur data (lihat poin 2 di bawah).
2. **Library Komponen**: Vuetify 3 sudah tersedia, tinggal memasang `<v-checkbox>` / `<v-switch>` di tabel dialog Edit Hak Akses.
3. **Mock Backend API**: `server/api/hak-akses/index.ts` sudah bisa menahan format data CRUD yang akan dikirim dari UI.
Karena API backend sesungguhnya belum siap, kita pakai **Nuxt Local API (Mock Backend)** sebagai *Backend-for-Frontend* sementara, dengan arsitektur *toggling* API sejak awal agar migrasi ke backend asli mulus.
> ⚠️ **Catatan penting**: Toggle mock/real API ini menyelesaikan masalah *sumber data*, bukan masalah *keamanan*. Semua pengecekan `v-permission` dan middleware di bawah ini berjalan di client — lihat bagian **Keamanan** sebelum dianggap selesai.
---
## Bagan Alur Sistem Hak Akses (Permission Flow)
```mermaid
flowchart TD
User([Pengguna]) --> Login(Berhasil Login via Keycloak)
Login --> Auth[useAuth.ts: Dapat Data Roles & Groups]
Auth --> PermStore[permissionStore.ts memuat Permissions]
PermStore --> CheckFlag{USE_MOCK_API ?}
CheckFlag -- TRUE --> MockAPI[Nuxt Local API: /api/hak-akses]
CheckFlag -- FALSE --> RealAPI[Real Backend: /api/v1/permission]
MockAPI --> FetchFail{Fetch gagal?}
RealAPI --> FetchFail
FetchFail -- Ya --> DenyDefault[Deny-by-default: anggap tanpa izin]
FetchFail -- Tidak --> PermState(Permission State Disimpan)
PermState --> Router[Vue Router Middleware - client]
PermState --> ServerCheck[Server Middleware/Plugin - SSR guard]
PermState --> Sidebar[Sidebar Navigation]
Router -- canAccess: False --> Deny[Redirect ke Error/403]
Router -- canAccess: True --> Page[Buka Halaman]
ServerCheck -- canAccess: False --> Deny
Sidebar -- canView: False --> HideMenu[Sembunyikan Menu]
Page --> Directive[v-permission directive pada Komponen]
Directive -- canDelete: False --> HideBtn[Tombol Hapus Hilang/Disabled]
Directive -- canAdd: True --> ShowBtn[Tombol Tambah Tampil]
Page -.-> BackendGuard[[Backend API tetap validasi ulang izin]]
```
---
## Proposed Changes
### 1. Seamless Backend Migration Strategy (Persiapan API Asli)
- `stores/permissionStore.ts` dan composable pemanggil membaca konfigurasi *runtime* (`useRuntimeConfig()`).
- Tambahkan flag `USE_MOCK_PERMISSION_API: true`.
- Saat backend siap, cukup ubah `NUXT_PUBLIC_USE_MOCK_PERMISSION_API=false` di `.env`.
- **Tambahan — contract test**: buat satu skema/interface TypeScript (idealnya divalidasi dengan `zod`) yang dipakai bersama oleh mock API dan dipakai untuk memvalidasi response real API nanti. Ini memastikan klaim "tinggal ganti flag" benar-benar teruji, bukan asumsi.
### 2. Upgrade Local API Mock (`server/api/hak-akses/index.ts`)
- Perbarui `data/mock/hakAkses.json` agar field `hakAksesMenu` berisi boolean: `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`.
- **Perubahan struktur — pakai key stabil, bukan label**: setiap entri `hakAksesMenu` menyimpan `menuKey` (mengacu ke `key`/`routeName` unik di `navItems1.ts`), bukan `name` (label tampilan). Label bisa berubah/di-rename tanpa memutus mapping izin.
```json
{
"menuKey": "master-klinik-ruang",
"name": "Master Klinik Ruang",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
```
- Validasi payload masuk dengan skema (zod) di endpoint mock, supaya struktur tidak diam-diam berubah antara UI dan backend.
### 3. Memperbarui `Setting/HakAkses.vue` (UI Konfigurasi CRUD)
- Dialog Edit melooping `navItems1.ts`, menampilkan matriks checkbox untuk `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`, dikunci ke `menuKey` masing-masing.
- Payload POST mengarah ke API Mock/Real sesuai flag.
- **Aturan precedence eksplisit** (harus didefinisikan sebelum coding, karena ada `role`, `group`, dan `isGroupBased` sekaligus):
1. Jika user punya override individual (role-based) → pakai itu.
2. Jika tidak ada override individual dan `isGroupBased: true` → pakai izin dari group.
3. Jika keduanya tidak ada → deny-by-default.
- Tuliskan aturan ini sebagai komentar di `permissionStore.ts`, bukan hanya di dokumen, supaya tidak jadi sumber bug tersembunyi saat logic berkembang.
### 4. Vue Custom Directive `v-permission`
- File baru `plugins/permission.ts` untuk registrasi directive.
- Dukungan penggunaan:
- Single: `<v-btn v-permission="'canEdit'">Edit</v-btn>`
- Multiple (AND): `<v-btn v-permission="['canEdit', 'canDelete']">...</v-btn>`
- **Mode hide vs disable**: tambahkan modifier, misal `v-permission:disable="'canDelete'"`, agar tombol bisa di-disable dengan tooltip ("Anda tidak punya izin") alih-alih hilang total tanpa penjelasan — pilih sesuai konteks UX per halaman.
### 5. `stores/permissionStore.ts` (State Management)
- Mengambil data izin dari Local/Real API saat login.
- Menyimpan state global.
- **Deny-by-default**: jika fetch permission gagal (network error, token expired), state dianggap "tanpa izin sama sekali", bukan default terbuka.
- **Refresh strategy**: tentukan apakah perubahan hak akses oleh admin berlaku langsung (polling/refetch berkala) atau baru berlaku setelah re-login. Pilih salah satu secara eksplisit dan dokumentasikan, jangan dibiarkan implisit.
### 6. Middleware & Dynamic Sidebar
- **Client middleware (`middleware/permissions.ts`)**: mencegat rute jika `canAccess` false.
- **Server-side guard**: karena project ini SSR (Nuxt), tambahkan pengecekan di server middleware/plugin juga — bukan hanya client — untuk mencegah *flash of unauthorized content* (halaman sempat ter-render sebelum redirect).
- **Sidebar (`stores/navItems1.ts`)**: filter menu berdasarkan `canView`, dikunci ke `menuKey`.
### 7. Keamanan Backend (wajib, non-negotiable)
- `v-permission` dan middleware di atas adalah **UX**, bukan kontrol akses sesungguhnya — keduanya berjalan di client dan bisa dilewati siapa saja yang memanggil API langsung.
- Backend API asli **wajib** memvalidasi ulang setiap permission di server berdasarkan identitas user dari token, tidak pernah mempercayai payload/izin yang dikirim dari frontend.
---
## Verification Plan
### 1. Verifikasi API Lokal via cURL
Pastikan API Lokal mampu membaca dan menyimpan JSON berformat CRUD dengan `menuKey`:
```bash
curl -X POST "http://localhost:3000/api/hak-akses" \
-H "Content-Type: application/json" \
-d '{
"role": "admin",
"group": "LOKET",
"namaTipeUser": "Admin Loket",
"isGroupBased": true,
"hakAksesMenu": [
{
"menuKey": "master-klinik-ruang",
"name": "Master Klinik Ruang",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
]
}'
```
### 2. Manual UI Verification
1. Buka **Setting > Hak Akses**, atur hak untuk Role tertentu (centang `canView`, matikan `canDelete`).
2. Login sebagai akun dengan Role tersebut.
3. Buka halaman target.
4. **Validasi**: Halaman terbuka, tombol "Delete" tersembunyi/disabled sesuai mode `v-permission`.
5. **Validasi fetch gagal**: Simulasikan permission API error (mis. matikan endpoint sementara) → pastikan sistem deny-by-default, bukan default terbuka.
6. **Validasi kesiapan API asli**: Cek logika precedence dan toggle flag di `permissionStore.ts`.
### 3. Automated Testing (baru)
- **Unit test** untuk `permissionStore.ts`: precedence role vs group, deny-by-default saat fetch gagal, evaluasi `canAccess`/`canView`/dst.
- **Contract test**: bandingkan skema response Mock API vs Real API (setelah Real API tersedia) menggunakan skema TypeScript/zod yang sama, untuk memastikan switch flag benar-benar tanpa perubahan kode lain.
- **Directive test**: pastikan `v-permission` menyembunyikan/disable elemen dengan benar untuk kombinasi single dan multiple permission.
---
## Strategi Selama Backend Asli Belum Tersedia
Karena tim belum bisa mengimplementasikan validasi izin di server sungguhan, mock API diperlakukan sebagai **kontrak (contract-first)**, bukan sekadar penyimpanan data sementara. Tujuannya: frontend sudah teruji terhadap semua skenario yang nanti jadi tanggung jawab backend asli, dan tidak perlu dirombak saat migrasi.
### 1. Mock API mengikuti skema, bukan menerima apa saja
- Definisikan interface TypeScript / skema `zod` untuk request dan response `hak-akses` **sekarang**, bukan menunggu backend asli.
- Mock API menolak (400) payload yang tidak sesuai skema.
- Skema ini menjadi kontrak yang wajib dipatuhi backend asli nanti — perbedaan struktur akan ketahuan lewat contract test, bukan saat production.
### 2. Simulasikan tanggung jawab yang nanti dipegang backend
- Tambahkan endpoint mock `/api/hak-akses/check` yang bisa mensimulasikan penolakan server (403) karena user tidak punya izin — supaya UI dan middleware sudah teruji menangani penolakan dari server, bukan hanya dari state client.
- Simulasikan juga kegagalan fetch (delay/error 500) untuk memverifikasi deny-by-default benar-benar berjalan.
### 3. Tandai eksplisit bagian yang "sementara tidak aman"
Beri komentar `TODO(security)` di titik-titik yang wajib diperkuat saat backend asli terpasang, contoh:
```ts
// TODO(security): saat backend asli terpasang, endpoint ini WAJIB
// memvalidasi ulang permission dari token JWT/session di server,
// jangan percaya payload role/group yang dikirim dari client.
```
Ini mencegah asumsi keliru saat handoff bahwa "karena UI sudah mengatur tampilan sesuai izin, backend tidak perlu memvalidasi ulang".
### 4. Definition of Done — Migrasi ke Backend Asli
Checklist ini harus tercentang semua **sebelum** flag `USE_MOCK_PERMISSION_API` dimatikan (`false`) di production:
- [ ] Endpoint real API memvalidasi permission berdasarkan identitas dari token (JWT/session), bukan dari body request yang dikirim client.
- [ ] Response real API lolos contract test terhadap skema yang sama dengan mock API (field, tipe data, struktur `hakAksesMenu` identik).
- [ ] Skenario penolakan server (403) dan fetch gagal (500/timeout) sudah diuji terhadap real API, tidak hanya terhadap mock.
- [ ] Ada audit log untuk setiap perubahan hak akses (siapa mengubah, kapan, dari-ke apa) — direkomendasikan mengingat ini fitur kontrol akses.
- [ ] Rate limiting pada endpoint pengubahan hak akses, untuk mencegah penyalahgunaan.
- [ ] Semua komentar `TODO(security)` di kode sudah diselesaikan atau dipindahkan menjadi tiket tersendiri yang dilacak.
+181
View File
@@ -0,0 +1,181 @@
# Integrasi Hak Akses (Permissions) di UI beserta Konfigurasi CRUD
## ✅ Hasil Pengecekan Kesiapan Project
1. **Navigasi (*Menu/Routing*)**: Sudah tersedia di `stores/navItems1.ts` (`defaultNavItems`). Array ini akan menjadi sumber daftar halaman di dialog "Edit Hak Akses" — dengan catatan penting di bagian struktur data (lihat poin 2 di bawah).
2. **Library Komponen**: Vuetify 3 sudah tersedia, tinggal memasang `<v-checkbox>` / `<v-switch>` di tabel dialog Edit Hak Akses.
3. **Mock Backend API**: `server/api/hak-akses/index.ts` sudah bisa menahan format data CRUD yang akan dikirim dari UI.
Karena API backend sesungguhnya belum siap, kita pakai **Nuxt Local API (Mock Backend)** sebagai *Backend-for-Frontend* sementara, dengan arsitektur *toggling* API sejak awal agar migrasi ke backend asli mulus.
> ⚠️ **Catatan penting**: Toggle mock/real API ini menyelesaikan masalah *sumber data*, bukan masalah *keamanan*. Semua pengecekan `v-permission` dan middleware di bawah ini berjalan di client — lihat bagian **Keamanan** sebelum dianggap selesai.
---
## Bagan Alur Sistem Hak Akses (Permission Flow)
```mermaid
flowchart TD
User([Pengguna]) --> Login(Berhasil Login via Keycloak)
Login --> Auth[useAuth.ts: Dapat Data Roles & Groups]
Auth --> PermStore[permissionStore.ts memuat Permissions]
PermStore --> CheckFlag{USE_MOCK_API ?}
CheckFlag -- TRUE --> MockAPI[Nuxt Local API: /api/hak-akses]
CheckFlag -- FALSE --> RealAPI[Real Backend: /api/v1/permission]
MockAPI --> FetchFail{Fetch gagal?}
RealAPI --> FetchFail
FetchFail -- Ya --> DenyDefault[Deny-by-default: anggap tanpa izin]
FetchFail -- Tidak --> PermState(Permission State Disimpan)
PermState --> Router[Vue Router Middleware - client]
PermState --> ServerCheck[Server Middleware/Plugin - SSR guard]
PermState --> Sidebar[Sidebar Navigation]
Router -- canAccess: False --> Deny[Redirect ke Error/403]
Router -- canAccess: True --> Page[Buka Halaman]
ServerCheck -- canAccess: False --> Deny
Sidebar -- canView: False --> HideMenu[Sembunyikan Menu]
Page --> Directive[v-permission directive pada Komponen]
Directive -- canDelete: False --> HideBtn[Tombol Hapus Hilang/Disabled]
Directive -- canAdd: True --> ShowBtn[Tombol Tambah Tampil]
Page -.-> BackendGuard[[Backend API tetap validasi ulang izin]]
```
---
## Proposed Changes
### 1. Seamless Backend Migration Strategy (Persiapan API Asli)
- `stores/permissionStore.ts` dan composable pemanggil membaca konfigurasi *runtime* (`useRuntimeConfig()`).
- Tambahkan flag `USE_MOCK_PERMISSION_API: true`.
- Saat backend siap, cukup ubah `NUXT_PUBLIC_USE_MOCK_PERMISSION_API=false` di `.env`.
- **Tambahan — contract test**: buat satu skema/interface TypeScript (idealnya divalidasi dengan `zod`) yang dipakai bersama oleh mock API dan dipakai untuk memvalidasi response real API nanti. Ini memastikan klaim "tinggal ganti flag" benar-benar teruji, bukan asumsi.
### 2. Upgrade Local API Mock (`server/api/hak-akses/index.ts`)
- Perbarui `data/mock/hakAkses.json` agar field `hakAksesMenu` berisi boolean: `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`.
- **Perubahan struktur — pakai key stabil, bukan label**: setiap entri `hakAksesMenu` menyimpan `menuKey` (mengacu ke `key`/`routeName` unik di `navItems1.ts`), bukan `name` (label tampilan). Label bisa berubah/di-rename tanpa memutus mapping izin.
```json
{
"menuKey": "master-klinik-ruang",
"name": "Master Klinik Ruang",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
```
- Validasi payload masuk dengan skema (zod) di endpoint mock, supaya struktur tidak diam-diam berubah antara UI dan backend.
### 3. Memperbarui `Setting/HakAkses.vue` (UI Konfigurasi CRUD)
- Dialog Edit melooping `navItems1.ts`, menampilkan matriks checkbox untuk `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`, dikunci ke `menuKey` masing-masing.
- Payload POST mengarah ke API Mock/Real sesuai flag.
- **Aturan precedence eksplisit** (harus didefinisikan sebelum coding, karena ada `role`, `group`, dan `isGroupBased` sekaligus):
1. Jika user punya override individual (role-based) → pakai itu.
2. Jika tidak ada override individual dan `isGroupBased: true` → pakai izin dari group.
3. Jika keduanya tidak ada → deny-by-default.
- Tuliskan aturan ini sebagai komentar di `permissionStore.ts`, bukan hanya di dokumen, supaya tidak jadi sumber bug tersembunyi saat logic berkembang.
### 4. Vue Custom Directive `v-permission`
- File baru `plugins/permission.ts` untuk registrasi directive.
- Dukungan penggunaan:
- Single: `<v-btn v-permission="'canEdit'">Edit</v-btn>`
- Multiple (AND): `<v-btn v-permission="['canEdit', 'canDelete']">...</v-btn>`
- **Mode hide vs disable**: tambahkan modifier, misal `v-permission:disable="'canDelete'"`, agar tombol bisa di-disable dengan tooltip ("Anda tidak punya izin") alih-alih hilang total tanpa penjelasan — pilih sesuai konteks UX per halaman.
### 5. `stores/permissionStore.ts` (State Management)
- Mengambil data izin dari Local/Real API saat login.
- Menyimpan state global.
- **Deny-by-default**: jika fetch permission gagal (network error, token expired), state dianggap "tanpa izin sama sekali", bukan default terbuka.
- **Refresh strategy**: tentukan apakah perubahan hak akses oleh admin berlaku langsung (polling/refetch berkala) atau baru berlaku setelah re-login. Pilih salah satu secara eksplisit dan dokumentasikan, jangan dibiarkan implisit.
### 6. Middleware & Dynamic Sidebar
- **Client middleware (`middleware/permissions.ts`)**: mencegat rute jika `canAccess` false.
- **Server-side guard**: karena project ini SSR (Nuxt), tambahkan pengecekan di server middleware/plugin juga — bukan hanya client — untuk mencegah *flash of unauthorized content* (halaman sempat ter-render sebelum redirect).
- **Sidebar (`stores/navItems1.ts`)**: filter menu berdasarkan `canView`, dikunci ke `menuKey`.
### 7. Keamanan Backend (wajib, non-negotiable)
- `v-permission` dan middleware di atas adalah **UX**, bukan kontrol akses sesungguhnya — keduanya berjalan di client dan bisa dilewati siapa saja yang memanggil API langsung.
- Backend API asli **wajib** memvalidasi ulang setiap permission di server berdasarkan identitas user dari token, tidak pernah mempercayai payload/izin yang dikirim dari frontend.
---
## Verification Plan
### 1. Verifikasi API Lokal via cURL
Pastikan API Lokal mampu membaca dan menyimpan JSON berformat CRUD dengan `menuKey`:
```bash
curl -X POST "http://localhost:3000/api/hak-akses" \
-H "Content-Type: application/json" \
-d '{
"role": "admin",
"group": "LOKET",
"namaTipeUser": "Admin Loket",
"isGroupBased": true,
"hakAksesMenu": [
{
"menuKey": "master-klinik-ruang",
"name": "Master Klinik Ruang",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
]
}'
```
### 2. Manual UI Verification
1. Buka **Setting > Hak Akses**, atur hak untuk Role tertentu (centang `canView`, matikan `canDelete`).
2. Login sebagai akun dengan Role tersebut.
3. Buka halaman target.
4. **Validasi**: Halaman terbuka, tombol "Delete" tersembunyi/disabled sesuai mode `v-permission`.
5. **Validasi fetch gagal**: Simulasikan permission API error (mis. matikan endpoint sementara) → pastikan sistem deny-by-default, bukan default terbuka.
6. **Validasi kesiapan API asli**: Cek logika precedence dan toggle flag di `permissionStore.ts`.
### 3. Automated Testing (baru)
- **Unit test** untuk `permissionStore.ts`: precedence role vs group, deny-by-default saat fetch gagal, evaluasi `canAccess`/`canView`/dst.
- **Contract test**: bandingkan skema response Mock API vs Real API (setelah Real API tersedia) menggunakan skema TypeScript/zod yang sama, untuk memastikan switch flag benar-benar tanpa perubahan kode lain.
- **Directive test**: pastikan `v-permission` menyembunyikan/disable elemen dengan benar untuk kombinasi single dan multiple permission.
---
## Strategi Selama Backend Asli Belum Tersedia
Karena tim belum bisa mengimplementasikan validasi izin di server sungguhan, mock API diperlakukan sebagai **kontrak (contract-first)**, bukan sekadar penyimpanan data sementara. Tujuannya: frontend sudah teruji terhadap semua skenario yang nanti jadi tanggung jawab backend asli, dan tidak perlu dirombak saat migrasi.
### 1. Mock API mengikuti skema, bukan menerima apa saja
- Definisikan interface TypeScript / skema `zod` untuk request dan response `hak-akses` **sekarang**, bukan menunggu backend asli.
- Mock API menolak (400) payload yang tidak sesuai skema.
- Skema ini menjadi kontrak yang wajib dipatuhi backend asli nanti — perbedaan struktur akan ketahuan lewat contract test, bukan saat production.
### 2. Simulasikan tanggung jawab yang nanti dipegang backend
- Tambahkan endpoint mock `/api/hak-akses/check` yang bisa mensimulasikan penolakan server (403) karena user tidak punya izin — supaya UI dan middleware sudah teruji menangani penolakan dari server, bukan hanya dari state client.
- Simulasikan juga kegagalan fetch (delay/error 500) untuk memverifikasi deny-by-default benar-benar berjalan.
### 3. Tandai eksplisit bagian yang "sementara tidak aman"
Beri komentar `TODO(security)` di titik-titik yang wajib diperkuat saat backend asli terpasang, contoh:
```ts
// TODO(security): saat backend asli terpasang, endpoint ini WAJIB
// memvalidasi ulang permission dari token JWT/session di server,
// jangan percaya payload role/group yang dikirim dari client.
```
Ini mencegah asumsi keliru saat handoff bahwa "karena UI sudah mengatur tampilan sesuai izin, backend tidak perlu memvalidasi ulang".
### 4. Definition of Done — Migrasi ke Backend Asli
Checklist ini harus tercentang semua **sebelum** flag `USE_MOCK_PERMISSION_API` dimatikan (`false`) di production:
- [ ] Endpoint real API memvalidasi permission berdasarkan identitas dari token (JWT/session), bukan dari body request yang dikirim client.
- [ ] Response real API lolos contract test terhadap skema yang sama dengan mock API (field, tipe data, struktur `hakAksesMenu` identik).
- [ ] Skenario penolakan server (403) dan fetch gagal (500/timeout) sudah diuji terhadap real API, tidak hanya terhadap mock.
- [ ] Ada audit log untuk setiap perubahan hak akses (siapa mengubah, kapan, dari-ke apa) — direkomendasikan mengingat ini fitur kontrol akses.
- [ ] Rate limiting pada endpoint pengubahan hak akses, untuk mencegah penyalahgunaan.
- [ ] Semua komentar `TODO(security)` di kode sudah diselesaikan atau dipindahkan menjadi tiket tersendiri yang dilacak.
+36 -7
View File
@@ -12,22 +12,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useLocalStorage } from "@vueuse/core"; import { useLocalStorage } from "@vueuse/core";
import SideBar from "../components/layout/SideBar.vue"; import SideBar from "../components/layout/SideBar.vue";
// Ensure this path matches your store location // Ensure this path matches your store location
import { useNavItemsStore } from '~/stores/navItems1'; import { useNavItemsStore } from '~/stores/navItems1';
import { useAuth } from "~/composables/useAuth"; import { useAuth } from "~/composables/useAuth";
import { usePermissionStore } from '~/stores/permissionStore';
definePageMeta({
middleware: ['auth', 'checkPageAccess']
})
// State for controlling the sidebar // State for controlling the sidebar
const drawer = ref(true); const drawer = ref(true);
const rail = ref(true); const rail = ref(true);
const navItemsStore = useNavItemsStore(); const navItemsStore = useNavItemsStore();
const permissionStore = usePermissionStore();
const { user, checkAuth } = useAuth(); const { user, checkAuth } = useAuth();
// Navigation will be filtered via navItemsStore using the new hakAkses system // Navigation will be filtered via navItemsStore using the new hakAkses system
@@ -36,16 +34,47 @@ const filteredNavItems = computed(() => navItemsStore.filteredNavItems);
onMounted(async () => { onMounted(async () => {
await checkAuth(); await checkAuth();
if (user.value) { if (user.value) {
if (!permissionStore.isLoaded) {
const roles = [
...(user.value.realm_access?.roles || []),
...(user.value.roles || [])
];
const groups: string[] = [];
const rawGroups = (user.value as any).groups || [];
rawGroups.forEach((g: string) => {
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groups.push(parts[1]);
} else if (parts.length === 1) {
groups.push(parts[0]);
}
});
const primaryRole = roles[0] || '';
const primaryGroup = groups[0] || '';
const username = user.value.preferred_username || user.value.email || user.value.name || '';
await permissionStore.load(primaryRole, primaryGroup, username);
}
await navItemsStore.refreshNavItems(); await navItemsStore.refreshNavItems();
} }
}); });
// Watch for permissions loaded state to refresh navItems (fixes empty sidebar on F5)
watch(() => permissionStore.isLoaded, async (isLoaded) => {
if (isLoaded && user.value) {
await navItemsStore.refreshNavItems();
}
}, { immediate: true });
// Watch for user changes to refresh navItems // Watch for user changes to refresh navItems
watch(() => user.value, async (newUser) => { watch(() => user.value, async (newUser) => {
if (newUser) { if (newUser && permissionStore.isLoaded) {
await navItemsStore.refreshNavItems(); await navItemsStore.refreshNavItems();
} else { } else if (!newUser) {
// Optionally reset navItems when logged out // Optionally reset navItems when logged out
navItemsStore.filteredNavItems = []; navItemsStore.filteredNavItems = [];
} }
+55 -41
View File
@@ -1,70 +1,84 @@
// middleware/checkPageAccess.ts // middleware/checkPageAccess.ts
// Middleware to check if user has access to the page based on hakAkses import { defineNuxtRouteMiddleware, navigateTo } from '#app';
import { useAuth } from '~/composables/useAuth';
import { usePermissionStore } from '~/stores/permissionStore';
export default defineNuxtRouteMiddleware(async (to, from) => { export default defineNuxtRouteMiddleware(async (to, from) => {
// Skip check for public pages
const publicPaths = ['/LoginPage', '/auth/login', '/index-legacy']; const publicPaths = ['/LoginPage', '/auth/login', '/index-legacy'];
// index.vue is the debug dashboard, let's keep it accessible for now as requested
if (to.path === '/' || publicPaths.includes(to.path)) { if (to.path === '/' || publicPaths.includes(to.path)) {
return; return;
} }
// On server-side, skip access check - let client handle it
// This matches auth.ts behavior and prevents SSR failures when cookie context is missing
if (process.server) { if (process.server) {
console.log('⏭️ Server-side: Skipping page access check (will verify on client)');
return; return;
} }
// Import useAuth and useHakAkses
const { user, checkAuth } = useAuth(); const { user, checkAuth } = useAuth();
const { getAllowedPages } = useHakAkses();
// If user not loaded, try to load
if (!user.value) { if (!user.value) {
await checkAuth(); await checkAuth();
} }
// If still not authenticated, redirect to login
if (!user.value) { if (!user.value) {
return navigateTo('/LoginPage'); return navigateTo('/LoginPage');
} }
try { const permissionStore = usePermissionStore();
const allowedPages = await getAllowedPages();
const targetPath = to.path.endsWith('/') && to.path.length > 1 ? to.path.slice(0, -1) : to.path; // Ensure permissions are loaded
const targetPathLower = targetPath.toLowerCase(); if (!permissionStore.isLoaded) {
const toPathLower = to.path.toLowerCase(); const roles = [
...(user.value.realm_access?.roles || []),
...(user.value.roles || [])
];
// Check if user has access to this page const groups: string[] = [];
// We also check against the raw path just in case, case-insensitive const rawGroups = (user.value as any).groups || [];
const isAllowed = allowedPages.some(path => { rawGroups.forEach((g: string) => {
const normalizedAllowed = path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path; const parts = g.split('/').filter(Boolean);
const normalizedAllowedLower = normalizedAllowed.toLowerCase(); if (parts.length > 1) {
const pathLower = path.toLowerCase(); groups.push(parts[1]);
return normalizedAllowedLower === targetPathLower || pathLower === toPathLower; } else if (parts.length === 1) {
groups.push(parts[0]);
}
}); });
if (!isAllowed) { const primaryRole = roles[0] || '';
console.warn(`Access denied to ${to.path}. User allowed pages:`, allowedPages); const primaryGroup = groups[0] || '';
const username = user.value.preferred_username || user.value.email || user.value.name || '';
// Redirect to first allowed page if available, else stay/error await permissionStore.load(primaryRole, primaryGroup, username);
if (allowedPages.length > 0) { }
// If dashboard is allowed, go there, else go to the first allowed one
const dashboardPath = allowedPages.find(p => p === '/' || p === '/dashboard'); let menuKey = "";
return navigateTo(dashboardPath || allowedPages[0]); if (to.name) {
} else { menuKey = to.name.toString().toLowerCase().replace(/_|-/g, '-');
// No access to any page - technically this shouldn't happen if user has roles }
console.error('User has roles but no allowed pages found in configuration.');
// For now, allow root as fallback since index.vue is kept // If no explicit route name, allow it for now.
if (to.path === '/') return; if (!menuKey) return;
}
// The previous implementation allowed some pages implicitly.
// If it's not configured, our can() method returns false.
// We should allow access if it's explicitly allowed.
let hasAccess = permissionStore.can(menuKey, 'canAccess');
// Default allow Dashboard for all authenticated users
if (menuKey === 'dashboard') {
hasAccess = true;
}
if (!hasAccess) {
console.warn(`Access denied to ${to.path}. User lacks 'canAccess' for menuKey: ${menuKey}`);
// Find a fallback page that the user CAN access
const fallbackMenu = permissionStore.permissions.find(p => p.canAccess);
if (fallbackMenu && fallbackMenu.menuKey) {
// Note: menuKey might not be a valid path, but if we map routeName to menuKey,
// we can try to navigate to the route name instead.
return navigateTo({ name: fallbackMenu.menuKey });
} }
} catch (error) {
console.error('Error checking page access:', error); // Final fallback
// On error, we might want to allow or block. Let's allow but log. return navigateTo('/');
return;
} }
}); });
+1
View File
@@ -88,6 +88,7 @@ export default defineNuxtConfig({
wsBaseUrl: process.env.WS_API_URL || process.env.WS_BASE_URL || 'ws://10.10.123.135:8084/api/v1/ws', wsBaseUrl: process.env.WS_API_URL || process.env.WS_BASE_URL || 'ws://10.10.123.135:8084/api/v1/ws',
verificationApiBaseUrl: process.env.ANTRIAN_API_URL || process.env.VERIFICATION_API_BASE_URL || 'http://10.10.123.140:8089/api/v1', verificationApiBaseUrl: process.env.ANTRIAN_API_URL || process.env.VERIFICATION_API_BASE_URL || 'http://10.10.123.140:8089/api/v1',
externalApiBaseUrl: process.env.VISIT_API_URL || (process.env.EXTERNAL_API_BASE_URL ? `${process.env.EXTERNAL_API_BASE_URL}/api/v1` : 'http://10.10.123.135:8084/api/v1'), externalApiBaseUrl: process.env.VISIT_API_URL || (process.env.EXTERNAL_API_BASE_URL ? `${process.env.EXTERNAL_API_BASE_URL}/api/v1` : 'http://10.10.123.135:8084/api/v1'),
useMockPermissionApi: process.env.NUXT_PUBLIC_USE_MOCK_PERMISSION_API !== 'false', // Default true until backend is ready
}, },
}, },
+11 -1
View File
@@ -37,7 +37,8 @@
"vue-chartjs": "^5.3.2", "vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0", "vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue3-carousel": "^0.17.0" "vue3-carousel": "^0.17.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0", "@nuxtjs/google-fonts": "^3.2.0",
@@ -23601,6 +23602,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zwitch": { "node_modules/zwitch": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+2 -1
View File
@@ -47,7 +47,8 @@
"vue-chartjs": "^5.3.2", "vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0", "vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue3-carousel": "^0.17.0" "vue3-carousel": "^0.17.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0", "@nuxtjs/google-fonts": "^3.2.0",
+40 -1
View File
@@ -103,11 +103,47 @@ const ruangStore = useRuangStore();
const loading = ref(false); const loading = ref(false);
/**
* Computed property untuk mendapatkan daftar klinik dengan menggabungkan
* semua poli Eksekutif menjadi satu entitas "GRAND PAVILIUN".
* @returns {Array} Daftar klinik yang sudah digabung dan diurutkan.
*/
const klinikRuangList = computed(() => { const klinikRuangList = computed(() => {
const list = masterStore.ruangData || []; const list = masterStore.ruangData || [];
return [...list].sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik)); const regulerList = list.filter(k => k.jenisLayanan !== 'Eksekutif');
const eksekutifList = list.filter(k => k.jenisLayanan === 'Eksekutif');
const result = [...regulerList];
if (eksekutifList.length > 0) {
// Combine all Eksekutif rooms for the preview
const allRooms = eksekutifList.flatMap(k => k.ruangList || []);
// Filter unique room names
const uniqueRoomNames = new Set();
const uniqueRooms = [];
allRooms.forEach(r => {
if (!uniqueRoomNames.has(r.namaRuang)) {
uniqueRoomNames.add(r.namaRuang);
uniqueRooms.push(r);
}
});
result.push({
kodeKlinik: 'EKS',
namaKlinik: 'GRAND PAVILIUN',
jenisLayanan: 'Eksekutif',
ruangList: uniqueRooms
});
}
return result.sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
}); });
/**
* Navigasi ke halaman detail antrean klinik ruang tertentu.
* @param {string} kodeKlinik - Kode unik klinik (contoh: 'AN', 'EKS').
* @param {string} jenisLayanan - Jenis layanan ('Reguler' atau 'Eksekutif').
*/
const navigateToKlinik = (kodeKlinik, jenisLayanan) => { const navigateToKlinik = (kodeKlinik, jenisLayanan) => {
// Include jenisLayanan as query parameter to differentiate same clinic code with different service types // Include jenisLayanan as query parameter to differentiate same clinic code with different service types
router.push({ router.push({
@@ -116,6 +152,9 @@ const navigateToKlinik = (kodeKlinik, jenisLayanan) => {
}); });
}; };
/**
* Navigasi ke halaman master setting klinik ruang.
*/
const navigateToSettings = () => { const navigateToSettings = () => {
router.push('/setting/masterklinikruang'); router.push('/setting/masterklinikruang');
}; };
+9
View File
@@ -37,6 +37,7 @@
@call="handleCallPatient" @call="handleCallPatient"
@open-klinik-ruang="openKlinikRuangDialog" @open-klinik-ruang="openKlinikRuangDialog"
@open-penunjang="openPenunjangDialog" @open-penunjang="openPenunjangDialog"
@linked="handlePatientLinked"
/> />
<QueueActionsCard <QueueActionsCard
@@ -1451,12 +1452,20 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
snackbarColor.value = result.success ? "success" : "error"; snackbarColor.value = result.success ? "success" : "error";
snackbar.value = true; snackbar.value = true;
if (result.success) { if (result.success) {
broadcastUpdate(); broadcastUpdate();
} }
closeKlinikRuangDialog(); closeKlinikRuangDialog();
}; };
// --- SIMRS Integration Handlers ---
const handlePatientLinked = (message) => {
snackbarText.value = message;
snackbarColor.value = "success";
snackbar.value = true;
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -209,8 +209,14 @@ const klinikData = computed(() => {
} }
// Filter by kodeKlinik AND jenisLayanan if provided // Filter by kodeKlinik AND jenisLayanan if provided
let filteredRuang let filteredRuang = []
if (jenisLayanan.value) { if (jenisLayanan.value === 'Eksekutif' && kode === 'EKS') {
return {
kodeKlinik: 'EKS',
namaKlinik: 'GRAND PAVILIUN',
jenisLayanan: 'Eksekutif'
}
} else if (jenisLayanan.value) {
filteredRuang = allRuang.filter(r => filteredRuang = allRuang.filter(r =>
r.kodeKlinik === kode && r.jenisLayanan === jenisLayanan.value r.kodeKlinik === kode && r.jenisLayanan === jenisLayanan.value
) )
@@ -280,7 +286,15 @@ watch(() => queueStore.lastKlinikCall, (newCall) => {
const callKode = String(newCall.kodeKlinik || '').toUpperCase(); const callKode = String(newCall.kodeKlinik || '').toUpperCase();
const myKode = String(kodeKlinik.value || '').toUpperCase(); const myKode = String(kodeKlinik.value || '').toUpperCase();
const isMatch = callKode === myKode;
let isMatch = false;
if (myKode === 'EKS' && jenisLayanan.value === 'Eksekutif') {
// For consolidated view, check if the called clinic is an Eksekutif clinic
const targetClinic = (masterStore.ruangData || []).find(r => r.kodeKlinik === callKode || r.kodeKlinik === newCall.kodeKlinik);
isMatch = targetClinic && targetClinic.jenisLayanan === 'Eksekutif';
} else {
isMatch = callKode === myKode;
}
console.log('🔍 [Anjungan] Klinik Code Comparison:', { console.log('🔍 [Anjungan] Klinik Code Comparison:', {
received: callKode, received: callKode,
@@ -332,13 +346,19 @@ const klinikPatients = computed(() => {
// Get patients from klinik-ruang stage (created from AdminKlinikRuang) // Get patients from klinik-ruang stage (created from AdminKlinikRuang)
// Filter out finished patients (status: 'processed' or 'selesai') // Filter out finished patients (status: 'processed' or 'selesai')
// AND filter by room number to separate patients by jenisLayanan // AND filter by room number to separate patients by jenisLayanan
return queueStore.allPatients.filter(p => return queueStore.allPatients.filter(p => {
p.processStage === 'klinik-ruang' && if (p.processStage !== 'klinik-ruang' || p.status === 'processed' || p.status === 'selesai') {
p.kodeKlinik === kodeKlinik.value && return false;
p.status !== 'processed' && }
p.status !== 'selesai' &&
validRoomNumbers.includes(p.nomorRuang) // Only show patients in rooms belonging to this jenisLayanan // For consolidated Eksekutif view, any valid Eksekutif room is a match, regardless of patient's original kodeKlinik
) if (kodeKlinik.value === 'EKS' && jenisLayanan.value === 'Eksekutif') {
return validRoomNumbers.includes(p.nomorRuang);
}
// For regular clinics, enforce exact kodeKlinik match
return p.kodeKlinik === kodeKlinik.value && validRoomNumbers.includes(p.nomorRuang);
})
}) })
// Get ruang list for this specific klinik // Get ruang list for this specific klinik
@@ -364,8 +384,11 @@ const ruangListForKlinik = computed(() => {
} }
// Filter by kodeKlinik AND jenisLayanan if provided // Filter by kodeKlinik AND jenisLayanan if provided
let filtered let filtered = []
if (jenisLayanan.value) { if (jenisLayanan.value === 'Eksekutif' && kodeKlinik.value === 'EKS') {
// If it's the consolidated Eksekutif view, we want ALL Eksekutif rooms
filtered = allRuang.filter(r => r.jenisLayanan === 'Eksekutif')
} else if (jenisLayanan.value) {
filtered = allRuang.filter(r => filtered = allRuang.filter(r =>
r.kodeKlinik === kodeKlinik.value && r.jenisLayanan === jenisLayanan.value r.kodeKlinik === kodeKlinik.value && r.jenisLayanan === jenisLayanan.value
) )
+40 -2
View File
@@ -126,15 +126,50 @@ const clinicStore = useClinicStore();
const ruangStore = useRuangStore(); const ruangStore = useRuangStore();
const route = useRoute(); const route = useRoute();
// Simplified computed to use masterStore.ruangData (which is already grouped/processed by ruangStore) /**
* Computed property untuk mendapatkan daftar klinik dengan menggabungkan
* semua poli Eksekutif menjadi satu entitas "GRAND PAVILIUN".
* @returns {Array} Daftar klinik yang sudah digabung dan diurutkan berdasarkan nama.
*/
const kliniksWithRuang = computed(() => { const kliniksWithRuang = computed(() => {
const list = masterStore.ruangData || []; const list = masterStore.ruangData || [];
return [...list].sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik)); const regulerList = list.filter(k => k.jenisLayanan !== 'Eksekutif');
const eksekutifList = list.filter(k => k.jenisLayanan === 'Eksekutif');
const result = [...regulerList];
if (eksekutifList.length > 0) {
// Combine all Eksekutif rooms for the preview
const allRooms = eksekutifList.flatMap(k => k.ruangList || []);
// Filter unique room names
const uniqueRoomNames = new Set();
const uniqueRooms = [];
allRooms.forEach(r => {
if (!uniqueRoomNames.has(r.namaRuang)) {
uniqueRoomNames.add(r.namaRuang);
uniqueRooms.push(r);
}
});
result.push({
id: 'EKS',
kodeKlinik: 'EKS',
namaKlinik: 'GRAND PAVILIUN',
jenisLayanan: 'Eksekutif',
ruangList: uniqueRooms
});
}
return result.sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
}); });
// Deprecated: Pagination logic removed // Deprecated: Pagination logic removed
/**
* Navigasi ke halaman anjungan antrean klinik ruang tertentu.
* @param {Object} klinik - Objek data klinik yang dipilih.
*/
const navigateToKlinik = (klinik) => { const navigateToKlinik = (klinik) => {
// Build URL with jenisLayanan query parameter to differentiate same clinic codes // Build URL with jenisLayanan query parameter to differentiate same clinic codes
const url = `/anjungan/antrianklinikruang/${klinik.kodeKlinik}`; const url = `/anjungan/antrianklinikruang/${klinik.kodeKlinik}`;
@@ -142,6 +177,9 @@ const navigateToKlinik = (klinik) => {
navigateTo({ path: url, query }); navigateTo({ path: url, query });
}; };
/**
* Navigasi ke halaman master setting klinik ruang.
*/
const navigateToSettings = () => { const navigateToSettings = () => {
navigateTo('/setting/masterklinikruang'); navigateTo('/setting/masterklinikruang');
}; };
+27 -21
View File
@@ -129,6 +129,7 @@
prepend-icon="mdi-shield-edit-outline" prepend-icon="mdi-shield-edit-outline"
class="text-capitalize rounded-lg mr-2" class="text-capitalize rounded-lg mr-2"
@click="editPermissions(item)" @click="editPermissions(item)"
v-permission="'canEdit'"
> >
Atur Akses Atur Akses
</v-btn> </v-btn>
@@ -165,8 +166,8 @@
<div class="pa-6 pt-0"> <div class="pa-6 pt-0">
<EditHakAkses <EditHakAkses
v-if="editedEntity" v-if="editedEntity"
:pages="editedEntity.pages" :hakAksesMenu="editedEntity.hakAksesMenu || []"
@update:pages="editedEntity.pages = $event" @update:hakAksesMenu="editedEntity.hakAksesMenu = $event"
/> />
</div> </div>
</v-card-text> </v-card-text>
@@ -240,7 +241,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue'; import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
import { useNavItemsStore } from '~/stores/navItems1'; import { useNavItemsStore, defaultNavItems } from '~/stores/navItems1';
import type { HakAkses } from '~/types/setting'; import type { HakAkses } from '~/types/setting';
definePageMeta({ definePageMeta({
@@ -321,6 +322,8 @@ const loadData = async () => {
} }
}; };
// Computed list based on active tab // Computed list based on active tab
const displayItems = computed(() => { const displayItems = computed(() => {
let list = []; let list = [];
@@ -328,15 +331,15 @@ const displayItems = computed(() => {
else if (activeTab.value === 'groups') list = keycloakEntities.value.groups; else if (activeTab.value === 'groups') list = keycloakEntities.value.groups;
else list = keycloakEntities.value.users; else list = keycloakEntities.value.users;
// Get all valid paths from current navigation store // Get all valid menuKeys from current navigation store
const allNavPaths = new Set<string>(); const allNavKeys = new Set<string>();
const extractPaths = (items: any[]) => { const extractKeys = (items: any[]) => {
items.forEach((item: any) => { items.forEach((item: any) => {
if (item.path) allNavPaths.add(item.path); if (item.menuKey) allNavKeys.add(item.menuKey);
if (item.children) extractPaths(item.children); if (item.children) extractKeys(item.children);
}); });
}; };
extractPaths(navItemsStore.getNavItems); extractKeys(defaultNavItems);
return list.map((entity: any) => { return list.map((entity: any) => {
// Find existing permission mapping // Find existing permission mapping
@@ -344,18 +347,15 @@ const displayItems = computed(() => {
const lookupKey = entity.type === 'user' ? entity.username : entity.name; const lookupKey = entity.type === 'user' ? entity.username : entity.name;
const mapping = hakAksesList.value.find(h => h.namaHakAkses === lookupKey); const mapping = hakAksesList.value.find(h => h.namaHakAkses === lookupKey);
if (!mapping) return { ...entity, pages: [], validPagesCount: 0, status: 'tidak aktif', id_mapping: null }; if (!mapping) return { ...entity, hakAksesMenu: [], validPagesCount: 0, status: 'tidak aktif', id_mapping: null };
// Count only valid pages that exist in navigation // Count only valid pages that exist in navigation and have canAccess
const validPages = (mapping.pages || []).filter(p => { const validMenus = (mapping.hakAksesMenu || []).filter((m: any) => m.menuKey && allNavKeys.has(m.menuKey) && m.canAccess);
const path = typeof p === 'string' ? p : (p as any)?.path;
return path && allNavPaths.has(path);
});
return { return {
...entity, ...entity,
pages: mapping.pages, // Keep original for editing hakAksesMenu: mapping.hakAksesMenu, // Keep original for editing
validPagesCount: validPages.length, validPagesCount: validMenus.length,
status: mapping.status, status: mapping.status,
id_mapping: mapping.id id_mapping: mapping.id
}; };
@@ -401,10 +401,14 @@ const savePermissions = async () => {
loading.value = true; loading.value = true;
try { try {
const payload = { const payload = {
id: editedEntity.value.id_mapping, // If null, backend create new id: editedEntity.value.id_mapping || undefined, // Zod optional() expects undefined, not null
namaHakAkses: editedEntity.value.name, namaHakAkses: editedEntity.value.type === 'user' ? editedEntity.value.username : editedEntity.value.name,
role: editedEntity.value.type === 'role' ? editedEntity.value.name : undefined,
group: editedEntity.value.type === 'group' ? editedEntity.value.name : undefined,
namaTipeUser: editedEntity.value.type === 'user' ? editedEntity.value.username : undefined,
isGroupBased: editedEntity.value.type === 'group',
status: 'aktif', status: 'aktif',
pages: editedEntity.value.pages hakAksesMenu: editedEntity.value.hakAksesMenu || []
}; };
const response = await $fetch<{ success: boolean, message: string }>('/api/hak-akses', { const response = await $fetch<{ success: boolean, message: string }>('/api/hak-akses', {
@@ -417,10 +421,12 @@ const savePermissions = async () => {
await loadData(); await loadData();
await navItemsStore.refreshNavItems(); await navItemsStore.refreshNavItems();
showEditDialog.value = false; showEditDialog.value = false;
} else {
showSnackbar(response.message || 'Gagal menyimpan perubahan', 'error');
} }
} catch (error) { } catch (error) {
console.error('Error saving:', error); console.error('Error saving:', error);
showSnackbar('Gagal menyimpan perubahan', 'error'); showSnackbar('Terjadi kesalahan pada server saat menyimpan perubahan', 'error');
} finally { } finally {
loading.value = false; loading.value = false;
} }
+76 -21
View File
@@ -76,7 +76,7 @@
<v-data-table <v-data-table
v-model:page="page" v-model:page="page"
:headers="headers" :headers="headers"
:items="masterStore.ruangData" :items="consolidatedRuangData"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
:search="search" :search="search"
item-value="id" item-value="id"
@@ -120,6 +120,7 @@
<template v-slot:item.layarInformasi="{ item }"> <template v-slot:item.layarInformasi="{ item }">
<v-btn <v-btn
v-if="item.id !== 'EKS'"
size="small" size="small"
variant="outlined" variant="outlined"
color="primary-600" color="primary-600"
@@ -129,6 +130,17 @@
<v-icon size="16" left>mdi-eye</v-icon> <v-icon size="16" left>mdi-eye</v-icon>
Preview Preview
</v-btn> </v-btn>
<v-btn
v-else
size="small"
variant="outlined"
color="primary-600"
@click="previewUrl = '/anjungan/antrianklinikruang/EKS?jenisLayanan=Eksekutif'; previewDialog = true;"
class="btn-preview mr-2"
>
<v-icon size="16" left>mdi-eye</v-icon>
Preview
</v-btn>
</template> </template>
<template v-slot:item.jenisLayanan="{ item }"> <template v-slot:item.jenisLayanan="{ item }">
@@ -158,26 +170,29 @@
</template> </template>
<template v-slot:item.aksi="{ item }"> <template v-slot:item.aksi="{ item }">
<v-btn <template v-if="item.id !== 'EKS'">
size="small" <v-btn
color=" warning-600" size="small"
@click="openEditDialog(item)" color=" warning-600"
class="btn-edit mr-2" @click="openEditDialog(item)"
variant="flat" class="btn-edit mr-2"
> variant="flat"
<v-icon size="16" left>mdi-pencil</v-icon> >
Edit <v-icon size="16" left>mdi-pencil</v-icon>
</v-btn> Edit
<v-btn </v-btn>
size="small" <v-btn
color="error-600" size="small"
@click="handleDelete(item)" color="error-600"
class="btn-delete" @click="handleDelete(item)"
variant="flat" class="btn-delete"
> variant="flat"
<v-icon size="16" left>mdi-delete</v-icon> >
Delete <v-icon size="16" left>mdi-delete</v-icon>
</v-btn> Delete
</v-btn>
</template>
<span v-else class="text-caption text-grey">Data Otomatis</span>
</template> </template>
</v-data-table> </v-data-table>
</v-card-text> </v-card-text>
@@ -391,6 +406,46 @@ const itemsPerPage = ref(10);
const search = ref(''); const search = ref('');
const filteredTotal = ref(masterStore.ruangData.length); const filteredTotal = ref(masterStore.ruangData.length);
/**
* Menggabungkan data ruang klinik agar poli Eksekutif tampil sebagai satu kesatuan 'GRAND PAVILIUN'.
* Memisahkan poli reguler dan mengekstrak semua ruangan dari poli eksekutif menjadi satu set ruangan unik.
* @returns {Array} Array objek data ruang klinik yang sudah dikonsolidasi.
*/
const consolidatedRuangData = computed(() => {
const list = masterStore.ruangData || [];
const regulerList = list.filter(k => k.jenisLayanan !== 'Eksekutif');
const eksekutifList = list.filter(k => k.jenisLayanan === 'Eksekutif');
const result = [...regulerList];
if (eksekutifList.length > 0) {
// Combine all Eksekutif rooms
const allRooms = eksekutifList.flatMap(k => k.ruangList || []);
const uniqueRoomNames = new Set();
const uniqueRooms = [];
allRooms.forEach(r => {
if (!uniqueRoomNames.has(r.namaRuang)) {
uniqueRoomNames.add(r.namaRuang);
uniqueRooms.push(r);
}
});
result.push({
id: 'EKS',
kodeKlinik: 'EKS',
namaKlinik: 'GRAND PAVILIUN',
jenisLayanan: 'Eksekutif',
ruangList: uniqueRooms,
_originalClinics: eksekutifList
});
}
return result;
});
/**
* Watcher untuk memperbarui total entri yang difilter ketika jumlah data utama berubah (dan tidak sedang mencari).
*/
watch(() => masterStore.ruangData.length, (newLen) => { watch(() => masterStore.ruangData.length, (newLen) => {
if (!search.value) filteredTotal.value = newLen; if (!search.value) filteredTotal.value = newLen;
}, { immediate: true }); }, { immediate: true });
+95
View File
@@ -0,0 +1,95 @@
import { defineNuxtPlugin } from '#app';
import { usePermissionStore } from '~/stores/permissionStore';
import type { HakAksesMenu } from '~/server/utils/schemas/permissionSchema';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('permission', {
mounted(el, binding) {
const permissionStore = usePermissionStore();
// Determine what action to check based on modifiers or value.
// If it's an array: v-permission="['canEdit', 'canDelete']"
// If it's a string: v-permission="'canEdit'"
const permissionsRequired = Array.isArray(binding.value) ? binding.value : [binding.value];
// Determine the menuKey context.
// In a real app, you might inject this context from the page component.
// For now, we extract it from the current route name.
const currentRoute = useRoute();
let menuKey = "";
if (currentRoute && currentRoute.name) {
// Convert vue-router route name to our menuKey format
menuKey = currentRoute.name.toString().toLowerCase().replace(/_|-/g, '-');
}
let hasPermission = true;
for (const action of permissionsRequired) {
if (!permissionStore.can(menuKey, action as keyof HakAksesMenu)) {
hasPermission = false;
break;
}
}
if (!hasPermission) {
if (binding.arg === 'disable') {
el.disabled = true;
el.classList.add('v-btn--disabled'); // If it's vuetify button
el.setAttribute('title', 'Anda tidak memiliki izin untuk aksi ini');
el.style.opacity = '0.5';
el.style.pointerEvents = 'none';
} else {
// Default: hide
el.style.display = 'none';
// Better hide by removing from DOM if possible,
// but display: none is safer for custom directives that don't want to break layout engines
// To truly remove: el.parentNode?.removeChild(el) (but breaks if Vue tries to update it)
}
}
},
updated(el, binding) {
// Handle reactivity if permissions change or route changes
const permissionStore = usePermissionStore();
const permissionsRequired = Array.isArray(binding.value) ? binding.value : [binding.value];
const currentRoute = useRoute();
let menuKey = "";
if (currentRoute && currentRoute.name) {
menuKey = currentRoute.name.toString().toLowerCase().replace(/_|-/g, '-');
}
let hasPermission = true;
for (const action of permissionsRequired) {
if (!permissionStore.can(menuKey, action as keyof HakAksesMenu)) {
hasPermission = false;
break;
}
}
if (!hasPermission) {
if (binding.arg === 'disable') {
el.disabled = true;
el.classList.add('v-btn--disabled');
el.setAttribute('title', 'Anda tidak memiliki izin untuk aksi ini');
el.style.opacity = '0.5';
el.style.pointerEvents = 'none';
} else {
el.style.display = 'none';
}
} else {
// Restore if permission was granted
if (binding.arg === 'disable') {
el.disabled = false;
el.classList.remove('v-btn--disabled');
el.removeAttribute('title');
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
} else {
el.style.display = ''; // Revert to original display
}
}
}
});
});
+23
View File
@@ -0,0 +1,23 @@
// server/api/hak-akses/check.ts
export default defineEventHandler(async (event) => {
// This is a mock endpoint to simulate backend checking permission.
// In production, the backend will validate the user's token directly.
const method = event.method;
if (method === 'POST') {
const body = await readBody(event);
const { menuKey, action } = body;
// Simulating some random failure or checking logic
if (!menuKey || !action) {
return createError({ statusCode: 400, statusMessage: 'Bad Request: menuKey and action required' });
}
// Just simulating success for now.
return {
success: true,
message: `Mock check: User has ${action} permission for ${menuKey}`,
allowed: true
}
}
return createError({ statusCode: 405, statusMessage: 'Method Not Allowed' });
});
+30 -14
View File
@@ -1,12 +1,12 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import type { HakAkses } from '~/types/setting';
import { randomUUID } from 'node:crypto'; // Use standard node crypto import { randomUUID } from 'node:crypto'; // Use standard node crypto
import { hakAksesPayloadSchema, type HakAksesPayload } from '~/server/utils/schemas/permissionSchema';
const filePath = path.resolve('data/mock/hakAkses.json'); const filePath = path.resolve('data/mock/hakAkses.json');
// Helper to read JSON file // Helper to read JSON file
const readData = (): HakAkses[] => { const readData = (): HakAksesPayload[] => {
try { try {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
// Ensure directory exists // Ensure directory exists
@@ -26,7 +26,7 @@ const readData = (): HakAkses[] => {
}; };
// Helper to write JSON file // Helper to write JSON file
const writeData = (data: HakAkses[]): boolean => { const writeData = (data: HakAksesPayload[]): boolean => {
try { try {
fs.writeFileSync(filePath, JSON.stringify(data, null, 4), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(data, null, 4), 'utf-8');
return true; return true;
@@ -51,7 +51,20 @@ export default defineEventHandler(async (event) => {
// POST - Create or Update hak akses // POST - Create or Update hak akses
if (method === 'POST') { if (method === 'POST') {
try { try {
const body = await readBody(event); const rawBody = await readBody(event);
// Validate payload with Zod Schema (Contract testing)
const validationResult = hakAksesPayloadSchema.safeParse(rawBody);
if (!validationResult.success) {
return {
success: false,
message: 'Payload invalid: Gagal menyimpan hak akses',
error: validationResult.error.format()
};
}
const body = validationResult.data;
const data = readData(); const data = readData();
if (body.id) { if (body.id) {
@@ -63,18 +76,21 @@ export default defineEventHandler(async (event) => {
...body ...body
}; };
} else { } else {
data.push(body); data.push(body as HakAksesPayload);
} }
} else { } else {
// Create new // Create new
const newId = randomUUID(); body.id = randomUUID();
const newHakAkses: HakAkses = {
id: newId, // For backward compatibility with HakAkses.vue UI that expects 'namaHakAkses'
namaHakAkses: body.namaHakAkses, if (!body.namaHakAkses) {
status: body.status || 'aktif', body.namaHakAkses = body.role || body.group || body.namaTipeUser || "Unknown";
pages: body.pages || [] }
}; if (!body.status) {
data.push(newHakAkses); body.status = 'aktif';
}
data.push(body as HakAksesPayload);
} }
const success = writeData(data); const success = writeData(data);
@@ -91,7 +107,7 @@ export default defineEventHandler(async (event) => {
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: 'Gagal menyimpan hak akses', message: 'Gagal memproses request',
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : 'Unknown error'
}; };
} }
+35
View File
@@ -0,0 +1,35 @@
/**
* Dummy API Endpoint untuk mencari data pasien dari SIMRS
*
* Endpoint ini merespons request GET dengan ID pasien.
* Digunakan sebagai simulasi (mock) sebelum API Service SIMRS yang asli tersedia.
*
* @param event - Objek event request dari Nitro H3
* @returns Object data pasien jika ID cocok, atau throw 404 Error jika tidak ditemukan
*/
export default defineEventHandler(async (event) => {
const id = event.context.params?.id;
// Simulasi delay jaringan (biar ada efek loading di UI)
await new Promise((resolve) => setTimeout(resolve, 800));
// Data dummy (nanti ini akan didapatkan dari database SIMRS asli)
if (id === '123456789') {
return {
success: true,
data: {
nama: 'Andi Pratama',
ttl: 'Surabaya, 15 Agustus 1990',
jenisKelamin: 'Laki-laki',
noTelepon: '081234567890',
alamat: 'Jl. Merdeka No. 45, Klojen, Malang'
}
};
}
// Jika pasien tidak ditemukan
throw createError({
statusCode: 404,
statusMessage: `Pasien dengan nomor ${id} tidak ditemukan.`,
});
});
+1
View File
@@ -29,6 +29,7 @@ const SEED_ANJUNGAN = [
const SEED_ANTREAN_MASUK_SCREENS = [ const SEED_ANTREAN_MASUK_SCREENS = [
{ namaScreen: 'Layar Antrean Masuk 1', nomorScreen: 'AM-001', loket: JSON.stringify([1, 2, 12, 14]) }, { namaScreen: 'Layar Antrean Masuk 1', nomorScreen: 'AM-001', loket: JSON.stringify([1, 2, 12, 14]) },
{ namaScreen: 'Layar Antrean Masuk 2', nomorScreen: 'AM-002', loket: JSON.stringify([3, 4]) }, { namaScreen: 'Layar Antrean Masuk 2', nomorScreen: 'AM-002', loket: JSON.stringify([3, 4]) },
{ namaScreen: 'Layar Antrean Masuk Eksekutif', nomorScreen: 'AM-EKS', loket: JSON.stringify([1000]) },
]; ];
const SEED_KLINIK_RUANG = [ const SEED_KLINIK_RUANG = [
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod';
export const hakAksesMenuSchema = z.object({
menuKey: z.string().min(1, "menuKey is required"),
name: z.string().min(1, "name is required"),
canAccess: z.boolean().default(false),
canView: z.boolean().default(false),
canAdd: z.boolean().default(false),
canEdit: z.boolean().default(false),
canDelete: z.boolean().default(false),
});
export const hakAksesPayloadSchema = z.object({
id: z.string().optional(),
role: z.string().optional(),
group: z.string().optional(),
namaTipeUser: z.string().optional(),
isGroupBased: z.boolean().default(true),
status: z.string().optional(), // Tambahan dari mock lama
namaHakAkses: z.string().optional(), // Tambahan dari mock lama
hakAksesMenu: z.array(hakAksesMenuSchema).default([]),
});
export type HakAksesMenu = z.infer<typeof hakAksesMenuSchema>;
export type HakAksesPayload = z.infer<typeof hakAksesPayloadSchema>;
+15 -29
View File
@@ -79,35 +79,21 @@ export const useLoketStore = defineStore('loket', () => {
const apiLoketData = ref([]) const apiLoketData = ref([])
// Local Data (EKSEKUTIF) - ID 1000+ manual input // Local Data (EKSEKUTIF) - ID 1000+ manual input
// DI-persist ke localStorage. Default 14 loket hardcoded. // DI-persist ke localStorage. Default 1 loket melayani semua EKS.
const localLoketData = ref(Array.from({ length: 14 }, (_, i) => { const localLoketData = ref([{
// Distribute services securely id: 1000,
// Loket 1-5: Single service no: 1,
// Loket 6-10: Dual services namaLoket: `LOKET EKS`,
// Loket 11+: Single service kuota: 500,
let services = []; // Provide all EKS clinic IDs (1000-1022) so it serves all
if (i < 5) { pelayanan: Array.from({ length: 23 }, (_, i) => 1000 + i),
services = [1000 + i]; pembayaran: ['EKSEKUTIF'],
} else if (i < 10) { tipeLoket: 'EKSEKUTIF',
services = [1000 + i, 1005 + i]; keterangan: 'ONLINE',
} else { statusPelayanan: 'RAWAT JALAN',
services = [1000 + i]; source: 'local',
} loketAktif: true
}])
return {
id: 1000 + i,
no: i + 1,
namaLoket: `LOKET ${i + 1} EKS`,
kuota: 500,
pelayanan: services,
pembayaran: ['EKSEKUTIF'], // Changed to array for consistency
tipeLoket: 'EKSEKUTIF', // Add tipeLoket field for consistency
keterangan: 'ONLINE',
statusPelayanan: 'RAWAT JALAN',
source: 'local',
loketAktif: true
};
}))
// Merged data (computed) - Gabungan API + Local // Merged data (computed) - Gabungan API + Local
const loketData = computed(() => { const loketData = computed(() => {
+38 -31
View File
@@ -2,33 +2,35 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core'; import { useLocalStorage } from '@vueuse/core';
import { computed, ref } from 'vue'; // Import computed dari Vue import { computed, ref } from 'vue'; // Import computed dari Vue
import { useHakAkses } from '~/composables/useHakAkses'; import { usePermissionStore } from '~/stores/permissionStore';
interface NavItem { interface NavItem {
id: number; id: number;
name: string; // Menggantikan 'title' name: string; // Menggantikan 'title'
path: string; // Menggantikan 'to' path: string; // Menggantikan 'to'
icon: string; icon: string;
menuKey?: string; // New field for permission checking
children?: NavItem[]; children?: NavItem[];
badge?: string; // Tambahkan properti badge badge?: string; // Tambahkan properti badge
} }
// Initial default navigation items // Initial default navigation items
const defaultNavItems: NavItem[] = [ export const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" }, { id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard", menuKey: "dashboard" },
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path:"/verifikasiAkun/VerifikasiAkun" }, { id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path:"/verifikasiAkun/VerifikasiAkun", menuKey: "verifikasiakun-verifikasiakun" },
{ {
id: 3, id: 3,
name: "Check In", name: "Check In",
icon: "mdi-file-document-edit-outline", icon: "mdi-file-document-edit-outline",
path: "/CheckInPasien/checkIn" path: "/CheckInPasien/checkIn",
menuKey: "checkinpasien-checkin"
// badge: "3", // badge: "3",
}, },
{ id: 4, name: "Admin Loket", icon: "mdi-account-supervisor-outline", path: "/AdminLoket" }, { id: 4, name: "Admin Loket", icon: "mdi-account-supervisor-outline", path: "/AdminLoket", menuKey: "adminloket" },
{ id: 6, name: "Admin Klinik Ruang", icon: "mdi-door-open", path: "/AdminKlinikRuang" }, { id: 6, name: "Admin Klinik Ruang", icon: "mdi-door-open", path: "/AdminKlinikRuang", menuKey: "adminklinikruang" },
// { id: 7, name: "Admin Penunjang", icon: "mdi-plus-box-outline", path: "/AdminPenunjang" }, // { id: 7, name: "Admin Penunjang", icon: "mdi-plus-box-outline", path: "/AdminPenunjang" },
// { id: 8, name: "Buat Antrean", icon: "mdi-account-multiple-plus-outline", path: "/BuatAntrean" }, // { id: 8, name: "Buat Antrean", icon: "mdi-account-multiple-plus-outline", path: "/BuatAntrean" },
{ id: 9, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien" }, { id: 9, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien", menuKey: "monitoringpasien-monitoringpasien" },
{ {
id: 10, id: 10,
name: "Layar Informasi", name: "Layar Informasi",
@@ -36,13 +38,12 @@ const defaultNavItems: NavItem[] = [
path: "", path: "",
children: [ children: [
// { id: 10, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-circle-small" }, // { id: 10, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-circle-small" },
{ id: 11, name: "Anjungan", path: "/anjungan/anjungancopy", icon: "mdi-circle-small" }, { id: 11, name: "Anjungan", path: "/anjungan/anjungancopy", icon: "mdi-circle-small", menuKey: "anjungan-anjungancopy" },
// { id: 11, name: "Klinik", path: "/Anjungan/AntrianKlinik", icon: "mdi-circle-small" }, // { id: 11, name: "Klinik", path: "/Anjungan/AntrianKlinik", icon: "mdi-circle-small" },
{ id: 12, name: "Klinik Ruang", path: "/Anjungan/AntrianKlinikRuang", icon: "mdi-circle-small"}, { id: 12, name: "Klinik Ruang", path: "/Anjungan/AntrianKlinikRuang", icon: "mdi-circle-small", menuKey: "anjungan-antrianklinikruang"},
// { id: 13, name: "Penunjang", path: "/Anjungan/AntrianPenunjang", icon: "mdi-circle-small"}, // { id: 13, name: "Penunjang", path: "/Anjungan/AntrianPenunjang", icon: "mdi-circle-small"},
{id: 14, name: "Loket", path: "/Anjungan/AntrianLoket", icon: "mdi-circle-small"}, {id: 14, name: "Loket", path: "/Anjungan/AntrianLoket", icon: "mdi-circle-small", menuKey: "anjungan-antrianloket"},
{id: 15, name: "Antrean Masuk", path: "/Anjungan/AntreanMasuk", icon: "mdi-circle-small"}, {id: 15, name: "Antrean Masuk", path: "/Anjungan/AntreanMasuk", icon: "mdi-circle-small", menuKey: "anjungan-antreanmasuk"},
], ],
}, },
{ {
@@ -51,20 +52,20 @@ const defaultNavItems: NavItem[] = [
icon: "mdi-cog-outline", icon: "mdi-cog-outline",
path: "", path: "",
children: [ children: [
{ id: 16, name: "Hak Akses", path: "/Setting/HakAkses", icon: "mdi-circle-small" }, { id: 16, name: "Hak Akses", path: "/Setting/HakAkses", icon: "mdi-circle-small", menuKey: "setting-hakakses" },
{ id: 17, name: "User Login", path: "/Setting/UserLogin", icon: "mdi-circle-small" }, { id: 17, name: "User Login", path: "/Setting/UserLogin", icon: "mdi-circle-small", menuKey: "setting-userlogin" },
{ id: 18, name: "Master Anjungan", path: "/Setting/MasterAnjungan", icon: "mdi-circle-small" }, { id: 18, name: "Master Anjungan", path: "/Setting/MasterAnjungan", icon: "mdi-circle-small", menuKey: "setting-masteranjungan" },
{ id: 19, name: "Master Loket", path: "/Setting/MasterLoket", icon: "mdi-circle-small" }, { id: 19, name: "Master Loket", path: "/Setting/MasterLoket", icon: "mdi-circle-small", menuKey: "setting-masterloket" },
{ id: 20, name: "Master Klinik", path: "/Setting/MasterKlinik", icon: "mdi-circle-small" }, { id: 20, name: "Master Klinik", path: "/Setting/MasterKlinik", icon: "mdi-circle-small", menuKey: "setting-masterklinik" },
{ id: 21, name: "Master Klinik Ruang", path: "/Setting/MasterKlinikRuang", icon: "mdi-circle-small" }, { id: 21, name: "Master Klinik Ruang", path: "/Setting/MasterKlinikRuang", icon: "mdi-circle-small", menuKey: "setting-masterklinikruang" },
// { id: 22, name: "Master Penunjang", path: "/Setting/MasterPenunjang", icon: "mdi-circle-small" }, // { id: 22, name: "Master Penunjang", path: "/Setting/MasterPenunjang", icon: "mdi-circle-small" },
// { id: 23, name: "Screen", path: "/Setting/Screen", icon: "mdi-circle-small" }, // { id: 23, name: "Screen", path: "/Setting/Screen", icon: "mdi-circle-small" },
{ id: 24, name: "Screen Antrean Masuk", path: "/Setting/ScreenAntreanMasuk", icon: "mdi-circle-small" }, { id: 24, name: "Screen Antrean Masuk", path: "/Setting/ScreenAntreanMasuk", icon: "mdi-circle-small", menuKey: "setting-screenantreanmasuk" },
], ],
}, },
]; ];
const STORAGE_VERSION = '1.3'; // Increment this if you change structure or default paths const STORAGE_VERSION = '1.5'; // Increment this if you change structure or default paths
export const useNavItemsStore = defineStore('navItems', () => { export const useNavItemsStore = defineStore('navItems', () => {
const storedVersion = useLocalStorage('navItems_version', '0'); const storedVersion = useLocalStorage('navItems_version', '0');
@@ -101,17 +102,16 @@ export const useNavItemsStore = defineStore('navItems', () => {
} }
// Filtered navigation items based on hakAkses // Filtered navigation items based on hakAkses
const filteredNavItems = ref<NavItem[]>(defaultNavItems); const filteredNavItems = ref<NavItem[]>([]);
async function refreshNavItems() { async function refreshNavItems() {
const { getAllowedPages } = useHakAkses(); const permissionStore = usePermissionStore();
const allowedPages = await getAllowedPages();
if (allowedPages.length === 0) { // Wait for permissions to be loaded if they aren't
// If no hak akses defined (maybe new system not setup yet), if (!permissionStore.isLoaded) {
// keep default or clear? Let's keep for now for safety during transition // We assume middleware already triggered load, if not, wait
filteredNavItems.value = defaultNavItems; // Typically, refreshNavItems is called after checkAuth, which doesn't auto-load now
return; // But checkPageAccess does. Let's just use what's in store.
} }
const filterItems = (items: NavItem[]): NavItem[] => { const filterItems = (items: NavItem[]): NavItem[] => {
@@ -126,8 +126,15 @@ export const useNavItemsStore = defineStore('navItems', () => {
return false; return false;
} }
// If it's a child or leaf, check if path is in allowedPages // If it's a child or leaf, check permissionStore
return allowedPages.includes(item.path); if (item.menuKey) {
if (item.menuKey === 'dashboard') return true;
return permissionStore.can(item.menuKey, 'canAccess');
}
// If no menuKey configured, fallback to previous allow/deny logic or true
// Safe bet is to hide if not mapped, but we mapped everything in defaultNavItems.
return false;
}); });
}; };
+112 -20
View File
@@ -1,24 +1,116 @@
// /stores/permissionStore.ts
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type { HakAksesPayload, HakAksesMenu } from "~/server/utils/schemas/permissionSchema";
export const usePermissionStore = defineStore("permission", { export const usePermissionStore = defineStore("permission", () => {
state: () => ({ const permissions = ref<HakAksesMenu[]>([]);
data: null as any, const isLoaded = ref(false);
}), const isError = ref(false);
actions: {
async load(path: string) { /**
const parts = path.split("/").filter(Boolean); * Load permissions for the current user's role and group
const group = parts[1] || ""; */
const role = parts.at(-1)?.toLowerCase() || ""; const load = async (role: string, group: string = "", username: string = "") => {
try {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1'; const useMock = config.public.useMockPermissionApi;
const origin = new URL(apiBase).origin;
const url = `${origin}/api/permission?roles=${role}&groups=${group}`; let url = "";
const { data } = await useFetch(url); if (useMock) {
this.data = data.value; url = `/api/hak-akses`; // Mock endpoint (returns all config)
}, } else {
can(action: string) { const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
return this.data?.[action] === true; // Ensure valid origin parsing
}, const origin = apiBase.startsWith('http') ? new URL(apiBase).origin : 'http://10.10.123.140:8089';
}, // In a real API, passing username might be needed if they support user-level overrides
url = `${origin}/api/v1/permission?roles=${role}&groups=${group}&username=${username}`;
}
const { data, error } = await useFetch<any>(url);
if (error.value) {
throw new Error(error.value.message || "Failed to fetch permissions");
}
isError.value = false;
// Handle Mock vs Real API response differences
if (useMock && data.value?.success) {
// Mock returns all permission configs. Find the matching one based on precedence:
const allConfigs = data.value.data as HakAksesPayload[];
// Precedence 0: Exact User Match (Specific Override)
let matchedConfig = allConfigs.find(c => c.namaTipeUser?.toLowerCase() === username.toLowerCase() || c.namaHakAkses?.toLowerCase() === username.toLowerCase());
// Precedence 1: Exact Role Match
if (!matchedConfig && role) {
matchedConfig = allConfigs.find(c => c.role?.toLowerCase() === role.toLowerCase() && !c.isGroupBased);
}
// Precedence 2: Group Match
if (!matchedConfig && group) {
matchedConfig = allConfigs.find(c => c.isGroupBased && c.group?.toLowerCase() === group.toLowerCase());
}
// If found, use it, else empty (Deny-by-default)
permissions.value = matchedConfig?.hakAksesMenu || [];
} else if (!useMock && data.value?.data) {
// Real API returns { message: "...", data: [ { id, create, read, update, delete, pagename } ] }
// We need to map Real API schema back to HakAksesMenu schema (contract mapping)
const realApiData = Array.isArray(data.value.data) ? data.value.data : [];
permissions.value = realApiData.map((item: any) => ({
menuKey: item.pagename?.toLowerCase().replace(/\s+/g, '-') || "", // Best effort mapping if API doesn't send menuKey
name: item.pagename || "Unknown",
canAccess: item.read === true,
canView: item.read === true,
canAdd: item.create === true,
canEdit: item.update === true,
canDelete: item.delete === true
}));
} else {
// Unrecognized format -> Deny-by-default
permissions.value = [];
}
isLoaded.value = true;
} catch (err) {
console.error("[permissionStore] Failed to load permissions:", err);
// Deny-by-default on fetch error
permissions.value = [];
isError.value = true;
isLoaded.value = true;
}
};
/**
* Check if a specific menuKey has a specific permission action.
* If the fetch failed or config is missing, it returns false (Deny-by-default).
*/
const can = (menuKey: string, action: keyof HakAksesMenu = 'canAccess'): boolean => {
// If we haven't loaded yet or there was an error, deny everything
if (!isLoaded.value || isError.value) return false;
// // TODO(security): This check is purely for UX.
// // True access control must be validated on the backend!
// Find the menu configuration
const menuConfig = permissions.value.find(
(m) => m.menuKey.toLowerCase() === menuKey.toLowerCase()
);
if (!menuConfig) return false; // Not configured -> deny
return menuConfig[action] === true;
};
/**
* Reset the store (e.g. on logout)
*/
const clear = () => {
permissions.value = [];
isLoaded.value = false;
isError.value = false;
};
return { permissions, isLoaded, isError, load, can, clear };
}); });
+15 -4
View File
@@ -2782,6 +2782,7 @@ const fetchPatientsForLoket = async (loketId: string | number, force: boolean =
shift: shift, shift: shift,
klinik: clinic.name || clinic, klinik: clinic.name || clinic,
kodeKlinik: clinic.kode || null, // Essential for filtering kodeKlinik: clinic.kode || null, // Essential for filtering
idKlinik: clinic.id || null, // Essential for Eksekutif loket matching
fastTrack: isFastTrack ? "YA" : "TIDAK", fastTrack: isFastTrack ? "YA" : "TIDAK",
pembayaran: paymentType, pembayaran: paymentType,
noRM: `RM-${barcode.slice(-6)}`, noRM: `RM-${barcode.slice(-6)}`,
@@ -2804,12 +2805,17 @@ const fetchPatientsForLoket = async (loketId: string | number, force: boolean =
// Auto-assign Loket ID if not provided, based on Clinic Mapping // Auto-assign Loket ID if not provided, based on Clinic Mapping
// fulfill requirement: "adjust based on loket id depending on creation" // fulfill requirement: "adjust based on loket id depending on creation"
if (!newPatient.loketId && newPatient.kodeKlinik) { if (!newPatient.loketId && (newPatient.kodeKlinik || newPatient.idKlinik)) {
const allLokets = loketStore.lokets || []; const allLokets = loketStore.lokets || [];
if (isEksekutif) { if (isEksekutif) {
const targetLoket = allLokets.find(l => const targetLoket = allLokets.find(l =>
l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan && Array.isArray(l.pelayanan) &&
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0])) && (
l.pelayanan.includes(newPatient.kodeKlinik) ||
l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]) ||
l.pelayanan.includes(newPatient.idKlinik) ||
l.pelayanan.includes(String(newPatient.idKlinik))
) &&
(l.id >= 1000 || l.tipeLoket === 'EKSEKUTIF') (l.id >= 1000 || l.tipeLoket === 'EKSEKUTIF')
); );
@@ -2818,12 +2824,17 @@ const fetchPatientsForLoket = async (loketId: string | number, force: boolean =
if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket; if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket;
console.log(`✅ Auto-assigned Eksekutif Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`); console.log(`✅ Auto-assigned Eksekutif Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`);
} else { } else {
console.warn(`⚠️ Warning: No Eksekutif Loket found for clinic ${newPatient.kodeKlinik}. Ticket will not be routed to any loket.`); console.warn(`⚠️ Warning: No Eksekutif Loket found for clinic ID ${newPatient.idKlinik} or Code ${newPatient.kodeKlinik}. Ticket will not be routed to any loket.`);
} }
} else { } else {
const targetLoket = allLokets.find(l => const targetLoket = allLokets.find(l =>
l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan && Array.isArray(l.pelayanan) &&
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0])) (
l.pelayanan.includes(newPatient.kodeKlinik) ||
l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]) ||
l.pelayanan.includes(newPatient.idKlinik) ||
l.pelayanan.includes(String(newPatient.idKlinik))
)
); );
if (targetLoket) { if (targetLoket) {
+39
View File
@@ -0,0 +1,39 @@
const fs = require('fs');
const navContent = fs.readFileSync('stores/navItems1.ts', 'utf-8');
const keys = [...navContent.matchAll(/menuKey:\s*['"]([^'"]+)['"]/g)].map(m => m[1]);
console.log('Extracted keys:', keys);
const data = JSON.parse(fs.readFileSync('data/mock/hakAkses.json', 'utf-8'));
const menus = keys.map(k => ({
menuKey: k,
name: k,
canAccess: true,
canView: true,
canAdd: true,
canEdit: true,
canDelete: true
}));
const usernameOverrides = ['[email protected]', 'akbar44', 'Akbar Attallah'];
for (const user of usernameOverrides) {
const existing = data.find(d => d.namaTipeUser === user || d.namaHakAkses === user);
if (existing) {
existing.hakAksesMenu = menus;
existing.namaTipeUser = user;
} else {
data.push({
id: require('crypto').randomUUID(),
namaHakAkses: user,
namaTipeUser: user,
status: 'aktif',
isGroupBased: false,
hakAksesMenu: menus
});
}
}
fs.writeFileSync('data/mock/hakAkses.json', JSON.stringify(data, null, 4));
console.log('Granted full access to ' + usernameOverrides.join(', '));