Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc314f463 | ||
|
|
49e0ee2bc4 | ||
|
|
a5d7338e58 | ||
|
|
d1b6629ac8 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
/**
|
|
||||||
* DialogDetailEvent — Dialog untuk menampilkan detail jadwal dokter
|
|
||||||
* yang diklik di kalender. Mendukung tampilan Rutin dan Adhoc.
|
|
||||||
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** Kontrol buka/tutup dialog (v-model) */
|
|
||||||
modelValue: { type: Boolean, default: false },
|
|
||||||
/** Data event yang sedang ditampilkan */
|
|
||||||
event: { type: Object, default: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'delete']);
|
|
||||||
|
|
||||||
/** Computed v-model proxy untuk dialog visibility */
|
|
||||||
const dialogOpen = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit event delete ke parent untuk menghapus jadwal.
|
|
||||||
*/
|
|
||||||
const handleDelete = () => {
|
|
||||||
emit('delete', props.event?.id);
|
|
||||||
dialogOpen.value = false;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-dialog v-model="dialogOpen" max-width="420px">
|
|
||||||
<v-card rounded="lg" class="pa-0">
|
|
||||||
<v-card-title class="pa-5" :class="event?.tipe === 'Adhoc' ? 'bg-secondary' : 'bg-primary'"
|
|
||||||
style="color:white;">
|
|
||||||
<div class="d-flex justify-space-between align-center w-100">
|
|
||||||
<div class="d-flex align-center gap-2">
|
|
||||||
<v-icon>{{ event?.tipe === 'Adhoc' ? 'mdi-calendar-clock' : 'mdi-calendar-sync'
|
|
||||||
}}</v-icon>
|
|
||||||
<span class="ml-2 text-h6 font-weight-bold">Jadwal {{ event?.tipe }}</span>
|
|
||||||
</div>
|
|
||||||
<v-btn icon="mdi-close" variant="text" size="small" style="color:white;"
|
|
||||||
@click="dialogOpen = false" />
|
|
||||||
</div>
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-card-text class="pa-6" v-if="event">
|
|
||||||
<v-list density="compact" class="pa-0">
|
|
||||||
<v-list-item class="px-0">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon color="primary" size="small" class="mr-3">mdi-hospital-building</v-icon>
|
|
||||||
</template>
|
|
||||||
<div class="text-caption text-grey-darken-1">Poli / Klinik</div>
|
|
||||||
<div class="text-body-2 font-weight-bold">{{ event.poli }}</div>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-divider class="my-2" />
|
|
||||||
|
|
||||||
<v-list-item class="px-0">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon color="primary" size="small" class="mr-3">mdi-doctor</v-icon>
|
|
||||||
</template>
|
|
||||||
<div class="text-caption text-grey-darken-1">Dokter</div>
|
|
||||||
<div class="text-body-2 font-weight-bold">{{ event.dokter }}</div>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-divider class="my-2" />
|
|
||||||
|
|
||||||
<v-list-item class="px-0">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon color="primary" size="small" class="mr-3">mdi-calendar</v-icon>
|
|
||||||
</template>
|
|
||||||
<div class="text-caption text-grey-darken-1">Tanggal</div>
|
|
||||||
<div class="text-body-2 font-weight-bold">{{ event.tanggal }}</div>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<!-- Rutin: tampilkan hari -->
|
|
||||||
<template v-if="event.tipe === 'Rutin' && event.hari">
|
|
||||||
<v-divider class="my-2" />
|
|
||||||
<v-list-item class="px-0">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon color="primary" size="small" class="mr-3">mdi-calendar-week</v-icon>
|
|
||||||
</template>
|
|
||||||
<div class="text-caption text-grey-darken-1">Hari Praktek</div>
|
|
||||||
<div class="text-body-2 font-weight-bold">{{ event.hari }}</div>
|
|
||||||
</v-list-item>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Adhoc: tampilkan shift -->
|
|
||||||
<template v-if="event.tipe === 'Adhoc' && event.shift">
|
|
||||||
<v-divider class="my-2" />
|
|
||||||
<v-list-item class="px-0">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon color="secondary" size="small" class="mr-3">mdi-clock-outline</v-icon>
|
|
||||||
</template>
|
|
||||||
<div class="text-caption text-grey-darken-1">Waktu</div>
|
|
||||||
<div class="text-body-2 font-weight-bold">{{ event.shift }}</div>
|
|
||||||
</v-list-item>
|
|
||||||
</template>
|
|
||||||
</v-list>
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-card-actions class="pa-5 justify-space-between">
|
|
||||||
<v-btn color="error" variant="text" prepend-icon="mdi-trash-can-outline" @click="handleDelete">
|
|
||||||
Hapus
|
|
||||||
</v-btn>
|
|
||||||
<v-btn color="primary" variant="flat" @click="dialogOpen = false">Tutup</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
</template>
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
/**
|
|
||||||
* DialogTambahJadwal — Dialog form untuk menambahkan jadwal dokter baru.
|
|
||||||
* Mendukung dua tipe jadwal: Rutin (mingguan berulang) dan Adhoc (satu kali).
|
|
||||||
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DAYS_OF_WEEK = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** Kontrol buka/tutup dialog (v-model) */
|
|
||||||
modelValue: { type: Boolean, default: false },
|
|
||||||
/** Daftar klinik untuk referensi nama poli */
|
|
||||||
clinicList: { type: Array, default: () => [] },
|
|
||||||
/** ID poli yang sedang dipilih */
|
|
||||||
selectedPoli: { type: [String, Number, null], default: null },
|
|
||||||
/** Nama dokter yang sedang dipilih */
|
|
||||||
selectedDoctor: { type: [String, null], default: null },
|
|
||||||
/** Tanggal yang diklik di kalender (opsional, sebagai default adhoc) */
|
|
||||||
clickedDate: { type: [Date, null], default: null },
|
|
||||||
/** Mode tampilan saat ini ('list' atau 'calendar') */
|
|
||||||
viewMode: { type: String, default: 'list' },
|
|
||||||
/** Daftar jadwal saat ini (untuk preview kalender) */
|
|
||||||
jadwalList: { type: Array, default: () => [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'save']);
|
|
||||||
|
|
||||||
/** Computed v-model proxy untuk dialog visibility */
|
|
||||||
const dialogOpen = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** State form tambah jadwal */
|
|
||||||
const form = reactive({
|
|
||||||
poliId: null,
|
|
||||||
dokter: null,
|
|
||||||
tipe: 'Rutin',
|
|
||||||
hari: '',
|
|
||||||
shifts: [{ jamMulai: '', jamSelesai: '' }],
|
|
||||||
adhocTanggal: '',
|
|
||||||
adhocShifts: [{ jamMulai: '', jamSelesai: '' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Computed daftar dokter berdasarkan poli yang dipilih */
|
|
||||||
const availableDoctors = computed(() => {
|
|
||||||
if (!form.poliId) return [];
|
|
||||||
const clinic = props.clinicList.find(c => c.id === form.poliId);
|
|
||||||
return clinic?.doctors || [];
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Error validasi form */
|
|
||||||
const formErrors = ref({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format tanggal ke string YYYY-MM-DD.
|
|
||||||
* @param {Date} date
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const toDateStr = (date) => {
|
|
||||||
const d = new Date(date);
|
|
||||||
const tzOffset = d.getTimezoneOffset() * 60000;
|
|
||||||
return new Date(d.getTime() - tzOffset).toISOString().slice(0, 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Reset form saat dialog dibuka */
|
|
||||||
watch(() => props.modelValue, (open) => {
|
|
||||||
if (open) {
|
|
||||||
if (props.viewMode === 'calendar') {
|
|
||||||
form.poliId = props.selectedPoli || null;
|
|
||||||
form.dokter = props.selectedDoctor || null;
|
|
||||||
} else {
|
|
||||||
form.poliId = null;
|
|
||||||
form.dokter = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.tipe = 'Rutin';
|
|
||||||
form.hari = '';
|
|
||||||
form.shifts = [{ jamMulai: '', jamSelesai: '' }];
|
|
||||||
formErrors.value = {};
|
|
||||||
|
|
||||||
if (props.clickedDate) {
|
|
||||||
const dateStr = toDateStr(props.clickedDate);
|
|
||||||
form.adhocTanggal = dateStr;
|
|
||||||
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
|
|
||||||
} else {
|
|
||||||
const dateStr = toDateStr(new Date());
|
|
||||||
form.adhocTanggal = dateStr;
|
|
||||||
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validasi form sebelum simpan.
|
|
||||||
* @returns {boolean} true jika valid
|
|
||||||
*/
|
|
||||||
const validateForm = () => {
|
|
||||||
const errors = {};
|
|
||||||
if (!form.poliId) errors.poli = 'Pilih poli terlebih dahulu';
|
|
||||||
if (!form.dokter) errors.dokter = 'Pilih dokter terlebih dahulu';
|
|
||||||
|
|
||||||
if (form.tipe === 'Rutin') {
|
|
||||||
if (!form.hari) errors.hari = 'Pilih hari kerja';
|
|
||||||
form.shifts.forEach((shift, idx) => {
|
|
||||||
if (!shift.jamMulai) errors[`shift_${idx}_jamMulai`] = 'Pilih jam mulai';
|
|
||||||
if (!shift.jamSelesai) errors[`shift_${idx}_jamSelesai`] = 'Pilih jam selesai';
|
|
||||||
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
|
|
||||||
errors[`shift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (!form.adhocTanggal) errors.adhocTanggal = 'Pilih tanggal';
|
|
||||||
form.adhocShifts.forEach((shift, idx) => {
|
|
||||||
if (!shift.jamMulai) errors[`adhocShift_${idx}_jamMulai`] = 'Pilih jam mulai';
|
|
||||||
if (!shift.jamSelesai) errors[`adhocShift_${idx}_jamSelesai`] = 'Pilih jam selesai';
|
|
||||||
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
|
|
||||||
errors[`adhocShift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
formErrors.value = errors;
|
|
||||||
return Object.keys(errors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menangani submit form — validasi lalu emit data ke parent.
|
|
||||||
*/
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!validateForm()) return;
|
|
||||||
|
|
||||||
const poliName = props.clinicList.find(c => c.id === form.poliId)?.title ?? '';
|
|
||||||
const emittedData = [];
|
|
||||||
|
|
||||||
if (form.tipe === 'Rutin') {
|
|
||||||
form.shifts.forEach((shift, index) => {
|
|
||||||
emittedData.push({
|
|
||||||
id: Date.now() + index,
|
|
||||||
poliId: form.poliId,
|
|
||||||
poliName,
|
|
||||||
dokter: form.dokter,
|
|
||||||
tipe: 'Rutin',
|
|
||||||
hari: [form.hari],
|
|
||||||
jamMulai: shift.jamMulai || null,
|
|
||||||
jamSelesai: shift.jamSelesai || null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
form.adhocShifts.forEach((shift, index) => {
|
|
||||||
emittedData.push({
|
|
||||||
id: Date.now() + index,
|
|
||||||
poliId: form.poliId,
|
|
||||||
poliName,
|
|
||||||
dokter: form.dokter,
|
|
||||||
tipe: 'Adhoc',
|
|
||||||
mulai: `${form.adhocTanggal}T${shift.jamMulai}:00`,
|
|
||||||
selesai: `${form.adhocTanggal}T${shift.jamSelesai}:00`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('save', emittedData);
|
|
||||||
dialogOpen.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
import MiniCalendarPreview from './MiniCalendarPreview.vue';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-dialog v-model="dialogOpen" max-width="520px" persistent>
|
|
||||||
<v-card rounded="lg" class="pa-0">
|
|
||||||
<!-- Header -->
|
|
||||||
<v-card-title class="bg-primary text-white pa-5">
|
|
||||||
<div class="d-flex justify-space-between align-center w-100">
|
|
||||||
<div class="d-flex align-center gap-2">
|
|
||||||
<v-icon>mdi-calendar-plus</v-icon>
|
|
||||||
<span class="text-h6 font-weight-bold">Tambah Jadwal Dokter</span>
|
|
||||||
</div>
|
|
||||||
<v-btn icon="mdi-close" variant="text" size="small" class="text-white"
|
|
||||||
@click="dialogOpen = false" />
|
|
||||||
</div>
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-card-text class="pa-6">
|
|
||||||
|
|
||||||
<!-- Tipe Jadwal -->
|
|
||||||
<div class="text-caption text-grey-darken-1 mb-2 font-weight-medium">Tipe Jadwal</div>
|
|
||||||
<v-btn-toggle v-model="form.tipe" mandatory color="primary" variant="outlined" class="mb-5 w-100"
|
|
||||||
divided>
|
|
||||||
<v-btn value="Rutin" class="flex-grow-1">
|
|
||||||
<v-icon start>mdi-calendar-sync</v-icon>
|
|
||||||
Rutin
|
|
||||||
</v-btn>
|
|
||||||
<v-btn value="Adhoc" class="flex-grow-1">
|
|
||||||
<v-icon start>mdi-calendar-clock</v-icon>
|
|
||||||
Adhoc
|
|
||||||
</v-btn>
|
|
||||||
</v-btn-toggle>
|
|
||||||
|
|
||||||
<!-- Info poli & dokter -->
|
|
||||||
<v-row class="mb-4">
|
|
||||||
<v-col cols="12" sm="12">
|
|
||||||
<div class="text-caption text-grey mb-1">Poli / Klinik <span class="text-error">*</span></div>
|
|
||||||
<v-select v-model="form.poliId" :items="clinicList" item-title="title" item-value="id"
|
|
||||||
density="compact" variant="outlined" hide-details="auto" placeholder="Pilih Poli"
|
|
||||||
prepend-inner-icon="mdi-hospital-building" @update:model-value="form.dokter = null"
|
|
||||||
:error-messages="formErrors.poli" />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" sm="12">
|
|
||||||
<div class="text-caption text-grey mb-1">Dokter <span class="text-error">*</span></div>
|
|
||||||
<v-select v-model="form.dokter" :items="availableDoctors" density="compact" variant="outlined"
|
|
||||||
hide-details="auto" placeholder="Pilih Dokter" prepend-inner-icon="mdi-doctor"
|
|
||||||
:disabled="!form.poliId" :error-messages="formErrors.dokter" />
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<!-- ── Form Rutin ──────────────────────────────── -->
|
|
||||||
<template v-if="form.tipe === 'Rutin'">
|
|
||||||
<div class="text-caption text-grey-darken-1 mb-3 font-weight-medium">Pilih Hari Kerja</div>
|
|
||||||
<div class="d-flex flex-wrap gap-2 mb-1">
|
|
||||||
<v-chip class="mr-1" v-for="day in DAYS_OF_WEEK" :key="day"
|
|
||||||
:color="form.hari === day ? 'primary' : 'primary'"
|
|
||||||
:variant="form.hari === day ? 'flat' : 'outlined'" size="small" style="cursor:pointer;"
|
|
||||||
@click="form.hari = day">
|
|
||||||
{{ day }}
|
|
||||||
</v-chip>
|
|
||||||
</div>
|
|
||||||
<div v-if="formErrors.hari" class="text-caption text-error mb-4">{{ formErrors.hari }}</div>
|
|
||||||
|
|
||||||
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
|
|
||||||
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
|
|
||||||
:dokter="form.dokter" />
|
|
||||||
|
|
||||||
<!-- Jam required -->
|
|
||||||
<div v-for="(shift, index) in form.shifts" :key="index" class="d-flex align-center gap-4 mt-4">
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="6">
|
|
||||||
<div class="text-caption text-grey mb-1">Jam Mulai</div>
|
|
||||||
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
|
|
||||||
density="compact" hide-details required
|
|
||||||
:error-messages="formErrors[`shift_${index}_jamMulai`]" />
|
|
||||||
<div v-if="formErrors[`shift_${index}_jamMulai`]"
|
|
||||||
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamMulai`] }}
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="6">
|
|
||||||
<div class="text-caption text-grey mb-1">Jam Selesai</div>
|
|
||||||
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
|
|
||||||
density="compact" hide-details required
|
|
||||||
:error-messages="formErrors[`shift_${index}_jamSelesai`]" />
|
|
||||||
<div v-if="formErrors[`shift_${index}_jamSelesai`]"
|
|
||||||
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamSelesai`]
|
|
||||||
}}</div>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</div>
|
|
||||||
<div class="mt-5" v-if="form.shifts.length > 1">
|
|
||||||
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
|
|
||||||
@click="form.shifts.splice(index, 1)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<v-btn variant="outlined" color="primary" size="small" class="text-none"
|
|
||||||
@click="form.shifts.push({ jamMulai: '', jamSelesai: '' })">
|
|
||||||
+ Tambah Jadwal {{ form.hari ? `Hari ${form.hari}` : '' }}
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── Form Adhoc ──────────────────────────────── -->
|
|
||||||
<template v-else>
|
|
||||||
<v-row class="mb-2">
|
|
||||||
<v-col cols="12">
|
|
||||||
<v-text-field v-model="form.adhocTanggal" label="Tanggal" type="date" variant="outlined"
|
|
||||||
density="compact" prepend-inner-icon="mdi-calendar" hide-details="auto"
|
|
||||||
:error-messages="formErrors.adhocTanggal" />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12">
|
|
||||||
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
|
|
||||||
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
|
|
||||||
:dokter="form.dokter" />
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
<div v-for="(shift, index) in form.adhocShifts" :key="index" class="d-flex align-center gap-4 mt-2">
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="6">
|
|
||||||
<div class="text-caption text-grey mb-1">Jam Mulai</div>
|
|
||||||
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
|
|
||||||
density="compact" hide-details required
|
|
||||||
:error-messages="formErrors[`adhocShift_${index}_jamMulai`]" />
|
|
||||||
<div v-if="formErrors[`adhocShift_${index}_jamMulai`]"
|
|
||||||
class="text-caption text-error mt-1">{{
|
|
||||||
formErrors[`adhocShift_${index}_jamMulai`] }}</div>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="6">
|
|
||||||
<div class="text-caption text-grey mb-1">Jam Selesai</div>
|
|
||||||
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
|
|
||||||
density="compact" hide-details required
|
|
||||||
:error-messages="formErrors[`adhocShift_${index}_jamSelesai`]" />
|
|
||||||
<div v-if="formErrors[`adhocShift_${index}_jamSelesai`]"
|
|
||||||
class="text-caption text-error mt-1">{{
|
|
||||||
formErrors[`adhocShift_${index}_jamSelesai`] }}</div>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</div>
|
|
||||||
<div class="mt-5" v-if="form.adhocShifts.length > 1">
|
|
||||||
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
|
|
||||||
@click="form.adhocShifts.splice(index, 1)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<v-btn variant="outlined" color="primary" size="small" class="text-none"
|
|
||||||
@click="form.adhocShifts.push({ jamMulai: '', jamSelesai: '' })">
|
|
||||||
+ Tambah Shift Adhoc
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-card-actions class="pa-5 justify-end gap-2">
|
|
||||||
<v-btn variant="text" color="grey" @click="dialogOpen = false">Batal</v-btn>
|
|
||||||
<v-btn color="primary" variant="flat" prepend-icon="mdi-content-save" @click="handleSave">
|
|
||||||
Simpan Jadwal
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
</template>
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
/**
|
|
||||||
* JadwalDokterCalendar — Mode tampilan kalender untuk jadwal dokter.
|
|
||||||
* Menampilkan filter poli/dokter dan v-calendar Vuetify dengan event
|
|
||||||
* jadwal rutin (primary) dan adhoc (secondary).
|
|
||||||
* Menerima data dari parent dan emit interaksi kembali ke parent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
|
||||||
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
|
||||||
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
|
|
||||||
];
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** Daftar klinik/poli untuk filter */
|
|
||||||
clinicList: { type: Array, default: () => [] },
|
|
||||||
/** Daftar event kalender yang sudah di-generate oleh parent */
|
|
||||||
calendarEvents: { type: Array, default: () => [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits([
|
|
||||||
'update:selectedPoli',
|
|
||||||
'update:selectedDoctor',
|
|
||||||
'date-click',
|
|
||||||
'event-click',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Filter State (local, synced ke parent via emit) ──────────────────────
|
|
||||||
|
|
||||||
/** Poli yang dipilih di mode calendar */
|
|
||||||
const selectedPoli = defineModel('selectedPoli', { type: [String, Number, null], default: null });
|
|
||||||
|
|
||||||
/** Dokter yang dipilih di mode calendar */
|
|
||||||
const selectedDoctor = defineModel('selectedDoctor', { type: [String, null], default: null });
|
|
||||||
|
|
||||||
|
|
||||||
const selectedElement = ref(null)
|
|
||||||
const selectedOpen = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Daftar dokter berdasarkan poli yang dipilih.
|
|
||||||
* @returns {string[]} Kosong jika poli belum dipilih
|
|
||||||
*/
|
|
||||||
const doctorList = computed(() => {
|
|
||||||
if (!selectedPoli.value) return [];
|
|
||||||
const clinic = props.clinicList.find(c => c.id === selectedPoli.value);
|
|
||||||
return clinic?.doctors ?? [];
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Reset dokter saat poli berubah */
|
|
||||||
watch(selectedPoli, () => { selectedDoctor.value = null; });
|
|
||||||
|
|
||||||
// ── Navigasi Kalender ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const isDailyEventsOpen = ref(false);
|
|
||||||
const dailyEvents = ref([]);
|
|
||||||
const selectedDateFormatted = ref('');
|
|
||||||
|
|
||||||
|
|
||||||
/** Tanggal referensi untuk navigasi bulan */
|
|
||||||
const viewDate = ref(new Date());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Key dinamis untuk v-calendar. Vuetify v-calendar punya internal navigation
|
|
||||||
* state sendiri — mengubah :model-value saja tidak cukup. Dengan mengganti :key,
|
|
||||||
* kita paksa komponen re-mount sehingga bulan yang tampil ikut berubah.
|
|
||||||
*/
|
|
||||||
const calendarKey = computed(() =>
|
|
||||||
`cal-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Judul bulan & tahun aktif */
|
|
||||||
const calendarTitle = computed(() =>
|
|
||||||
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Navigasi ke bulan sebelumnya */
|
|
||||||
const prevMonth = () => {
|
|
||||||
const d = new Date(viewDate.value);
|
|
||||||
d.setMonth(d.getMonth() - 1);
|
|
||||||
viewDate.value = new Date(d);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Navigasi ke bulan berikutnya */
|
|
||||||
const nextMonth = () => {
|
|
||||||
const d = new Date(viewDate.value);
|
|
||||||
d.setMonth(d.getMonth() + 1);
|
|
||||||
viewDate.value = new Date(d);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menangani klik pada tanggal di kalender.
|
|
||||||
* @param {Date|string|object} info - Payload dari @click:date
|
|
||||||
*/
|
|
||||||
const handleDateClick = (info, { date }) => {
|
|
||||||
emit('date-click', date);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menangani klik pada event di kalender.
|
|
||||||
* @param {object} payload - Payload dari @click:event v-calendar
|
|
||||||
*/
|
|
||||||
const handleEventClick = (nativeEvent, { event }) => {
|
|
||||||
const ev = event;
|
|
||||||
if (!ev?.extendedProps) return;
|
|
||||||
const d = ev.start instanceof Date ? ev.start : new Date(ev.start);
|
|
||||||
emit('event-click', {
|
|
||||||
...ev.extendedProps,
|
|
||||||
tanggal: d.toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Menangani klik "+X lainnya" pada kalender.
|
|
||||||
* @param {Date|string|object} info - Payload dari @click:more
|
|
||||||
*/
|
|
||||||
const handleMoreClick = (nativeEvent, { date }) => {
|
|
||||||
selectedDateFormatted.value = date;
|
|
||||||
console.log("date", date)
|
|
||||||
console.log("calendarEvents.value", props.calendarEvents)
|
|
||||||
dailyEvents.value = props.calendarEvents.filter(e =>
|
|
||||||
e.start.toISOString().split("T")[0] === selectedDateFormatted.value
|
|
||||||
);
|
|
||||||
|
|
||||||
const open = () => {
|
|
||||||
// selectedEvent.value = event
|
|
||||||
selectedElement.value = nativeEvent.target
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => selectedOpen.value = true))
|
|
||||||
}
|
|
||||||
if (selectedOpen.value) {
|
|
||||||
selectedOpen.value = false
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => open()))
|
|
||||||
} else {
|
|
||||||
open()
|
|
||||||
}
|
|
||||||
nativeEvent.stopPropagation()
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menangani klik pada event di dalam dialog harian.
|
|
||||||
*/
|
|
||||||
const handleDailyEventClick = (ev) => {
|
|
||||||
isDailyEventsOpen.value = false;
|
|
||||||
const payload = { event: ev };
|
|
||||||
handleEventClick(payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<!-- ── Filter Card ─────────────────────────────────────── -->
|
|
||||||
<v-row class="mb-2">
|
|
||||||
<v-col cols="12">
|
|
||||||
<v-card class="pa-6" color="white">
|
|
||||||
<div class="d-flex align-center mb-4">
|
|
||||||
<v-icon color="primary" class="mr-2">mdi-filter-variant</v-icon>
|
|
||||||
<h3 class="text-subtitle-1 font-weight-bold text-primary">FILTER JADWAL</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-select v-model="selectedPoli" :items="clinicList" item-title="title" item-value="id"
|
|
||||||
label="Pilih Poli / Klinik" variant="outlined" density="compact" clearable
|
|
||||||
prepend-inner-icon="mdi-hospital-building" hide-details />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-select v-model="selectedDoctor" :items="doctorList" label="Pilih Dokter"
|
|
||||||
variant="outlined" density="compact" clearable prepend-inner-icon="mdi-doctor"
|
|
||||||
:disabled="!selectedPoli" hide-details
|
|
||||||
:placeholder="selectedPoli ? 'Pilih dokter...' : 'Pilih poli terlebih dahulu'" />
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<div v-if="selectedPoli || selectedDoctor" class="mt-4 d-flex align-center gap-2 flex-wrap">
|
|
||||||
<span class="text-caption text-grey-darken-1">Filter aktif:</span>
|
|
||||||
<v-chip v-if="selectedPoli" size="small" color="primary" variant="tonal" closable
|
|
||||||
@click:close="selectedPoli = null">
|
|
||||||
<v-icon start size="x-small">mdi-hospital-building</v-icon>
|
|
||||||
{{clinicList.find(c => c.id === selectedPoli)?.title}}
|
|
||||||
</v-chip>
|
|
||||||
<v-chip v-if="selectedDoctor" size="small" color="secondary" variant="tonal" closable
|
|
||||||
@click:close="selectedDoctor = null">
|
|
||||||
<v-icon start size="x-small">mdi-doctor</v-icon>
|
|
||||||
{{ selectedDoctor }}
|
|
||||||
</v-chip>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<!-- ── Calendar Card ───────────────────────────────────── -->
|
|
||||||
<v-row v-if="selectedDoctor && selectedPoli">
|
|
||||||
<v-col cols="12">
|
|
||||||
<v-card elevation="2" rounded="lg" color="white">
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<div class="d-flex align-center justify-space-between px-6 pt-5 pb-4">
|
|
||||||
|
|
||||||
<div class="d-flex align-center">
|
|
||||||
<v-btn icon="mdi-chevron-left" variant="text" size="small" @click="prevMonth" />
|
|
||||||
<v-btn icon="mdi-chevron-right" variant="text" size="small" @click="nextMonth" />
|
|
||||||
<span class="text-body-1 font-weight-bold" style="min-width:150px;text-align:center;">
|
|
||||||
{{ calendarTitle }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Legend -->
|
|
||||||
<div class="d-flex align-center gap-3">
|
|
||||||
<div class="d-flex align-center gap-1">
|
|
||||||
<span class="legend-dot bg-primary"></span>
|
|
||||||
<span class="text-caption ml-1">Rutin</span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-center gap-1 ml-1">
|
|
||||||
<span class="legend-dot bg-secondary"></span>
|
|
||||||
<span class="text-caption ml-1">Adhoc</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<!-- v-calendar Vuetify -->
|
|
||||||
<v-calendar :interval-height="100" locale="id" :key="calendarKey" :model-value="viewDate"
|
|
||||||
:events="calendarEvents" view-mode="month" event-more-text="{0} lainnya" class="jadwal-calendar"
|
|
||||||
@click:date="handleDateClick" @click:event="handleEventClick" @click:more="handleMoreClick" />
|
|
||||||
<v-menu v-model="selectedOpen" :activator="selectedElement" :close-on-content-click="false"
|
|
||||||
location="end">
|
|
||||||
<v-card color="grey-lighten-4" min-width="350px" flat>
|
|
||||||
<v-toolbar color="primary" dark>
|
|
||||||
<v-btn icon>
|
|
||||||
<v-icon>mdi-calendar</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-toolbar-title>Jadwal Lainnya</v-toolbar-title>
|
|
||||||
</v-toolbar>
|
|
||||||
<v-card-text style="max-height: 300px; overflow-y: auto;">
|
|
||||||
<v-list v-if="dailyEvents.length > 0" lines="two" bg-color="transparent">
|
|
||||||
<v-list-item v-for="(ev, idx) in dailyEvents" :key="idx" class="mb-3 rounded border"
|
|
||||||
:class="ev.color === 'primary' ? 'border-primary' : 'border-secondary'"
|
|
||||||
@click="handleDailyEventClick(ev)" hover>
|
|
||||||
<v-list-item-title class="font-weight-bold">
|
|
||||||
<v-chip :color="ev.color" size="small" class="mr-2">{{
|
|
||||||
ev.extendedProps?.tipe }}</v-chip>
|
|
||||||
{{ ev.name }}
|
|
||||||
</v-list-item-title>
|
|
||||||
<v-list-item-subtitle class="mt-1">
|
|
||||||
<v-icon size="x-small" class="mr-1">mdi-doctor</v-icon>{{
|
|
||||||
ev.extendedProps?.dokter }}
|
|
||||||
</v-list-item-subtitle>
|
|
||||||
<v-list-item-subtitle>
|
|
||||||
<v-icon size="x-small" class="mr-1">mdi-hospital-building</v-icon>{{
|
|
||||||
ev.extendedProps?.poli }}
|
|
||||||
</v-list-item-subtitle>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
<div v-else class="text-center pa-6 text-grey">
|
|
||||||
Tidak ada jadwal tambahan.
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</v-menu>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<v-card min-height="400px">
|
|
||||||
<v-card-text>
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12">
|
|
||||||
<div class="text-center pa-12 d-flex align-center justify-center" style="height: 400px;">
|
|
||||||
<div>
|
|
||||||
<v-icon size="48" color="grey-lighten-1"
|
|
||||||
class="mb-2">mdi-calendar-blank-outline</v-icon>
|
|
||||||
<div class="text-body-2 text-grey-lighten-1">Silahkan Pilih Poli dan Dokter terlebih
|
|
||||||
dahulu</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.v-calendar-weekly__day) {
|
|
||||||
height: 125px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jadwal-calendar {
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header hari */
|
|
||||||
:deep(.v-calendar-month__weekday) {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: #283593;
|
|
||||||
background-color: #e8eaf6;
|
|
||||||
padding: 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nomor tanggal */
|
|
||||||
:deep(.v-calendar-month__day-label) {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #424242;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.v-calendar-month__day-label:hover) {
|
|
||||||
background-color: #e8eaf6;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hari ini */
|
|
||||||
:deep(.v-calendar-month__day--today .v-calendar-month__day-label) {
|
|
||||||
background-color: #1a237e;
|
|
||||||
color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Event */
|
|
||||||
:deep(.v-calendar-month__event) {
|
|
||||||
font-size: 0.68rem !important;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.v-calendar-month__event:hover) {
|
|
||||||
opacity: 0.82;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
/**
|
|
||||||
* JadwalDokterList — Mode tampilan daftar (tabel) untuk jadwal dokter.
|
|
||||||
* Menampilkan filter (poli klinik, tanggal, nama dokter) dan tabel
|
|
||||||
* dengan kolom NO, POLI, NAMA DOKTER, JADWAL RUTIN, AKSI.
|
|
||||||
* Mode ini menjadi tampilan default halaman Jadwal Dokter.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** Daftar klinik/poli dari store */
|
|
||||||
clinicList: { type: Array, default: () => [] },
|
|
||||||
/** Daftar jadwal yang sudah ditambahkan */
|
|
||||||
jadwalList: { type: Array, default: () => [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['edit', 'add']);
|
|
||||||
|
|
||||||
// ── Filter State ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Poli yang dipilih untuk filter */
|
|
||||||
const filterPoli = ref(null);
|
|
||||||
|
|
||||||
/** Tanggal yang dipilih untuk filter */
|
|
||||||
const filterDate = ref(null);
|
|
||||||
|
|
||||||
/** Keyword pencarian nama dokter */
|
|
||||||
const filterDokter = ref('');
|
|
||||||
|
|
||||||
// ── Pagination ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Halaman aktif saat ini */
|
|
||||||
const currentPage = ref(1);
|
|
||||||
|
|
||||||
/** Jumlah baris per halaman */
|
|
||||||
const itemsPerPage = ref(10);
|
|
||||||
|
|
||||||
/** Opsi jumlah baris per halaman */
|
|
||||||
const perPageOptions = [5, 10, 25, 50];
|
|
||||||
|
|
||||||
// ── Mapping Hari ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DAY_MAP = {
|
|
||||||
0: 'Minggu', 1: 'Senin', 2: 'Selasa', 3: 'Rabu',
|
|
||||||
4: 'Kamis', 5: 'Jumat', 6: 'Sabtu'
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Computed: Data Tabel ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Membangun data tabel dari jadwalList, dikelompokkan per dokter + poli.
|
|
||||||
* Setiap baris menampilkan 1 dokter dengan ringkasan jadwal rutinnya.
|
|
||||||
* @returns {Array<{ id, poliName, poliId, dokter, jadwalRutin, raw }>}
|
|
||||||
*/
|
|
||||||
const tableData = computed(() => {
|
|
||||||
/** Gabungkan jadwal berdasarkan key unik dokter+poli */
|
|
||||||
const grouped = {};
|
|
||||||
|
|
||||||
props.jadwalList.forEach(j => {
|
|
||||||
const key = `${j.poliId}-${j.dokter}`;
|
|
||||||
if (!grouped[key]) {
|
|
||||||
grouped[key] = {
|
|
||||||
id: j.id,
|
|
||||||
poliName: j.poliName,
|
|
||||||
poliId: j.poliId,
|
|
||||||
dokter: j.dokter,
|
|
||||||
hariSet: new Set(),
|
|
||||||
jamMulai: null,
|
|
||||||
jamSelesai: null,
|
|
||||||
raw: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
grouped[key].raw.push(j);
|
|
||||||
|
|
||||||
if (j.tipe === 'Rutin' && j.hari) {
|
|
||||||
j.hari.forEach(h => grouped[key].hariSet.add(h));
|
|
||||||
if (j.jamMulai) grouped[key].jamMulai = j.jamMulai;
|
|
||||||
if (j.jamSelesai) grouped[key].jamSelesai = j.jamSelesai;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(grouped).map(g => {
|
|
||||||
const hariArr = Array.from(g.hariSet);
|
|
||||||
/** Format jadwal rutin: "Senin - Kamis\n08:00 - 14:00 WIB" */
|
|
||||||
let jadwalRutin = '';
|
|
||||||
if (hariArr.length > 0) {
|
|
||||||
jadwalRutin = hariArr.join(', ');
|
|
||||||
if (g.jamMulai && g.jamSelesai) {
|
|
||||||
jadwalRutin += `\n${g.jamMulai} - ${g.jamSelesai} WIB`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
jadwalRutin = '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: g.id,
|
|
||||||
poliName: g.poliName,
|
|
||||||
poliId: g.poliId,
|
|
||||||
dokter: g.dokter,
|
|
||||||
jadwalRutin,
|
|
||||||
raw: g.raw,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data tabel yang sudah difilter berdasarkan poli, tanggal, dan keyword nama dokter.
|
|
||||||
* @returns {Array}
|
|
||||||
*/
|
|
||||||
const filteredData = computed(() => {
|
|
||||||
let data = tableData.value;
|
|
||||||
|
|
||||||
// Filter poli
|
|
||||||
if (filterPoli.value) {
|
|
||||||
data = data.filter(d => d.poliId === filterPoli.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter tanggal — cocokkan dengan hari dari jadwal rutin
|
|
||||||
if (filterDate.value) {
|
|
||||||
const date = new Date(filterDate.value);
|
|
||||||
const dayName = DAY_MAP[date.getDay()];
|
|
||||||
data = data.filter(d =>
|
|
||||||
d.raw.some(j => j.tipe === 'Rutin' && j.hari?.includes(dayName))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter nama dokter
|
|
||||||
if (filterDokter.value) {
|
|
||||||
const keyword = filterDokter.value.toLowerCase();
|
|
||||||
data = data.filter(d => d.dokter.toLowerCase().includes(keyword));
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Total data setelah filter */
|
|
||||||
const totalItems = computed(() => filteredData.value.length);
|
|
||||||
|
|
||||||
/** Total halaman */
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value)));
|
|
||||||
|
|
||||||
/** Data untuk halaman aktif (sliced) */
|
|
||||||
const paginatedData = computed(() => {
|
|
||||||
const start = (currentPage.value - 1) * itemsPerPage.value;
|
|
||||||
return filteredData.value.slice(start, start + itemsPerPage.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Label info pagination: "Menampilkan 1-10 dari 124 dokter spesialis" */
|
|
||||||
const paginationLabel = computed(() => {
|
|
||||||
if (totalItems.value === 0) return 'Tidak ada data';
|
|
||||||
const start = (currentPage.value - 1) * itemsPerPage.value + 1;
|
|
||||||
const end = Math.min(currentPage.value * itemsPerPage.value, totalItems.value);
|
|
||||||
return `Menampilkan ${start}-${end} dari ${totalItems.value} dokter spesialis`;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Reset halaman saat filter berubah */
|
|
||||||
watch([filterPoli, filterDate, filterDokter], () => {
|
|
||||||
currentPage.value = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigasi ke halaman tertentu.
|
|
||||||
* @param {number} page
|
|
||||||
*/
|
|
||||||
const goToPage = (page) => {
|
|
||||||
if (page >= 1 && page <= totalPages.value) {
|
|
||||||
currentPage.value = page;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<!-- ── Filter Card ─────────────────────────────────────── -->
|
|
||||||
<v-card class="pa-5 mb-6" elevation="2" rounded="lg" color="white">
|
|
||||||
<v-row align="center">
|
|
||||||
<v-col cols="12" sm="4">
|
|
||||||
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Poli Klinik</div>
|
|
||||||
<v-select v-model="filterPoli" :items="clinicList" item-title="title" item-value="id"
|
|
||||||
placeholder="Semua Poli" variant="outlined" density="compact" clearable
|
|
||||||
prepend-inner-icon="mdi-hospital-building" hide-details />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" sm="4">
|
|
||||||
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Tanggal</div>
|
|
||||||
<v-text-field v-model="filterDate" type="date" placeholder="Pilih Tanggal" variant="outlined"
|
|
||||||
density="compact" clearable prepend-inner-icon="mdi-calendar" hide-details />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" sm="4">
|
|
||||||
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Nama Dokter</div>
|
|
||||||
<v-text-field v-model="filterDokter" placeholder="Cari Nama Dokter..." variant="outlined"
|
|
||||||
density="compact" clearable prepend-inner-icon="mdi-account-search" hide-details />
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<!-- ── Tabel Jadwal ────────────────────────────────────── -->
|
|
||||||
<v-card elevation="2" rounded="lg" color="white">
|
|
||||||
<v-table class="jadwal-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="text-left" style="width: 60px;">NO</th>
|
|
||||||
<th class="text-left" style="width: 140px;">POLI</th>
|
|
||||||
<th class="text-left">NAMA DOKTER</th>
|
|
||||||
<th class="text-left" style="width: 200px;">JADWAL RUTIN</th>
|
|
||||||
<th class="text-center" style="width: 100px;">AKSI</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-if="paginatedData.length === 0">
|
|
||||||
<td colspan="5" class="text-center pa-8 text-grey">
|
|
||||||
<v-icon size="48" color="grey-lighten-1" class="mb-2">mdi-calendar-blank-outline</v-icon>
|
|
||||||
<div class="text-body-2">Belum ada data jadwal dokter</div>
|
|
||||||
<div class="text-caption text-grey-lighten-1">Tambahkan jadwal dokter melalui mode
|
|
||||||
kalender</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="(item, index) in paginatedData" :key="item.id" class="jadwal-row">
|
|
||||||
<td class="text-body-2 font-weight-medium">
|
|
||||||
{{ (currentPage - 1) * itemsPerPage + index + 1 }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<v-chip size="small" color="primary" variant="tonal">
|
|
||||||
{{ item.poliName }}
|
|
||||||
</v-chip>
|
|
||||||
</td>
|
|
||||||
<td class="text-body-2 font-weight-bold">
|
|
||||||
{{ item.dokter }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="text-body-2" style="white-space: pre-line; line-height: 1.5;">
|
|
||||||
{{ item.jadwalRutin }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-center">
|
|
||||||
<v-btn variant="outlined" size="small" color="primary" rounded="lg"
|
|
||||||
prepend-icon="mdi-pencil" @click="emit('edit', item)">
|
|
||||||
Edit
|
|
||||||
</v-btn>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</v-table>
|
|
||||||
|
|
||||||
<!-- ── Pagination Footer ──────────────────────────── -->
|
|
||||||
<v-divider />
|
|
||||||
<div class="d-flex align-center justify-space-between px-4 py-3 pagination-footer">
|
|
||||||
<span class="text-caption text-grey-darken-1">{{ paginationLabel }}</span>
|
|
||||||
|
|
||||||
<div class="d-flex align-center gap-3">
|
|
||||||
<div class="d-flex align-center gap-2">
|
|
||||||
<span class="text-caption text-grey-darken-1 mr-2">Baris per halaman:</span>
|
|
||||||
<v-select v-model="itemsPerPage" :items="perPageOptions" variant="plain" density="compact"
|
|
||||||
hide-details style="max-width: 70px;" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex align-center gap-1">
|
|
||||||
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1" @click="goToPage(1)">
|
|
||||||
<v-icon size="16">mdi-page-first</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1"
|
|
||||||
@click="goToPage(currentPage - 1)">
|
|
||||||
<v-icon size="16">mdi-chevron-left</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
|
|
||||||
@click="goToPage(currentPage + 1)">
|
|
||||||
<v-icon size="16">mdi-chevron-right</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
|
|
||||||
@click="goToPage(totalPages)">
|
|
||||||
<v-icon size="16">mdi-page-last</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.jadwal-table {
|
|
||||||
border-radius: 12px 12px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jadwal-table thead th {
|
|
||||||
background-color: #f5f6fa !important;
|
|
||||||
color: #5a607f !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
font-size: 0.75rem !important;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
border-bottom: 2px solid #e8eaf0 !important;
|
|
||||||
padding: 14px 16px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jadwal-row {
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jadwal-row:hover {
|
|
||||||
background-color: #f8f9fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jadwal-row td {
|
|
||||||
padding: 16px !important;
|
|
||||||
border-bottom: 1px solid #f0f1f5 !important;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.pagination-footer {
|
|
||||||
background-color: #fafbfc;
|
|
||||||
border-radius: 0 0 12px 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
tipe: { type: String, default: 'Rutin' },
|
|
||||||
hari: { type: String, default: '' },
|
|
||||||
adhocTanggal: { type: String, default: '' },
|
|
||||||
jadwalList: { type: Array, default: () => [] },
|
|
||||||
poliId: { type: [String, Number, null], default: null },
|
|
||||||
dokter: { type: String, default: null }
|
|
||||||
});
|
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
|
||||||
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
|
||||||
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
|
|
||||||
];
|
|
||||||
|
|
||||||
const DAY_MAP = {
|
|
||||||
'Minggu': 0, 'Senin': 1, 'Selasa': 2, 'Rabu': 3,
|
|
||||||
'Kamis': 4, 'Jumat': 5, 'Sabtu': 6
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewDate = ref(new Date());
|
|
||||||
|
|
||||||
const calendarKey = computed(() =>
|
|
||||||
`cal-mini-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const calendarTitle = computed(() =>
|
|
||||||
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const prevMonth = () => {
|
|
||||||
const d = new Date(viewDate.value);
|
|
||||||
d.setMonth(d.getMonth() - 1);
|
|
||||||
viewDate.value = new Date(d);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextMonth = () => {
|
|
||||||
const d = new Date(viewDate.value);
|
|
||||||
d.setMonth(d.getMonth() + 1);
|
|
||||||
viewDate.value = new Date(d);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toDateStr = (date) => {
|
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calendarEvents = computed(() => {
|
|
||||||
const events = [];
|
|
||||||
const year = viewDate.value.getFullYear();
|
|
||||||
const month = viewDate.value.getMonth();
|
|
||||||
|
|
||||||
// 1. Generate existing events for the selected doctor
|
|
||||||
const filtered = props.jadwalList.filter(j => {
|
|
||||||
if (props.poliId && j.poliId !== props.poliId) return false;
|
|
||||||
if (props.dokter && j.dokter !== props.dokter) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
filtered.forEach(jadwal => {
|
|
||||||
if (jadwal.tipe === 'Rutin') {
|
|
||||||
for (let mOffset = -1; mOffset <= 1; mOffset++) {
|
|
||||||
const tYear = month + mOffset < 0 ? year - 1 : month + mOffset > 11 ? year + 1 : year;
|
|
||||||
const tMonth = (month + mOffset + 12) % 12;
|
|
||||||
const daysInMonth = new Date(tYear, tMonth + 1, 0).getDate();
|
|
||||||
|
|
||||||
for (let d = 1; d <= daysInMonth; d++) {
|
|
||||||
const date = new Date(tYear, tMonth, d);
|
|
||||||
const dayName = Object.keys(DAY_MAP).find(k => DAY_MAP[k] === date.getDay());
|
|
||||||
|
|
||||||
let jadwalHariArray = [];
|
|
||||||
if (Array.isArray(jadwal.hari)) {
|
|
||||||
jadwalHariArray = jadwal.hari;
|
|
||||||
} else if (typeof jadwal.hari === 'string') {
|
|
||||||
jadwalHariArray = [jadwal.hari];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!jadwalHariArray.includes(dayName)) continue;
|
|
||||||
|
|
||||||
const dateStr = toDateStr(date);
|
|
||||||
const jamLabel = jadwal.jamMulai && jadwal.jamSelesai
|
|
||||||
? ` ${jadwal.jamMulai} - ${jadwal.jamSelesai}`
|
|
||||||
: '';
|
|
||||||
events.push({
|
|
||||||
name: `${jamLabel}`,
|
|
||||||
start: jadwal.jamMulai ? new Date(`${dateStr}T${jadwal.jamMulai}:00`) : date,
|
|
||||||
end: jadwal.jamSelesai ? new Date(`${dateStr}T${jadwal.jamSelesai}:00`) : date,
|
|
||||||
color: 'primary',
|
|
||||||
allDay: !jadwal.jamMulai,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const adhocMulaiTime = jadwal.mulai ? jadwal.mulai.split('T')[1]?.slice(0, 5) : '';
|
|
||||||
const adhocSelesaiTime = jadwal.selesai ? jadwal.selesai.split('T')[1]?.slice(0, 5) : '';
|
|
||||||
const adhocJamLabel = adhocMulaiTime && adhocSelesaiTime
|
|
||||||
? ` ${adhocMulaiTime} - ${adhocSelesaiTime}`
|
|
||||||
: '';
|
|
||||||
events.push({
|
|
||||||
name: `${adhocJamLabel}`,
|
|
||||||
start: new Date(jadwal.mulai),
|
|
||||||
end: new Date(jadwal.selesai),
|
|
||||||
color: 'secondary',
|
|
||||||
allDay: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return events;
|
|
||||||
});
|
|
||||||
|
|
||||||
const calendarRef = ref(null);
|
|
||||||
|
|
||||||
const updateHighlight = () => {
|
|
||||||
// Wait for calendar to fully render internally
|
|
||||||
setTimeout(() => {
|
|
||||||
const calendarEl = calendarRef.value?.$el || document.querySelector('.mini-calendar');
|
|
||||||
if (!calendarEl) return;
|
|
||||||
|
|
||||||
// Remove previous highlights
|
|
||||||
const previous = calendarEl.querySelectorAll('.adhoc-highlight, .rutin-highlight');
|
|
||||||
previous.forEach(el => {
|
|
||||||
el.classList.remove('adhoc-highlight', 'rutin-highlight');
|
|
||||||
el.style.border = '';
|
|
||||||
el.style.borderRadius = '';
|
|
||||||
el.style.color = '';
|
|
||||||
el.style.fontWeight = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const allDays = calendarEl.querySelectorAll('.v-calendar-weekly__day');
|
|
||||||
if (allDays.length === 0) return;
|
|
||||||
|
|
||||||
if (props.tipe === 'Rutin' && props.hari) {
|
|
||||||
const targetCol = DAY_MAP[props.hari];
|
|
||||||
allDays.forEach((dayEl, index) => {
|
|
||||||
// Check if index matches column and it's not from adjacent month
|
|
||||||
if (index % 7 === targetCol && !dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
|
|
||||||
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
|
|
||||||
if (labelBtn) {
|
|
||||||
labelBtn.classList.add('rutin-highlight');
|
|
||||||
// Fallback inline styles in case CSS scoped issue occurs
|
|
||||||
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
|
|
||||||
labelBtn.style.borderRadius = '50%';
|
|
||||||
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
|
|
||||||
labelBtn.style.fontWeight = 'bold';
|
|
||||||
labelBtn.style.width = '28px';
|
|
||||||
labelBtn.style.height = '28px';
|
|
||||||
labelBtn.style.display = 'flex';
|
|
||||||
labelBtn.style.alignItems = 'center';
|
|
||||||
labelBtn.style.justifyContent = 'center';
|
|
||||||
labelBtn.style.margin = '2px auto';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (props.tipe === 'Adhoc' && props.adhocTanggal) {
|
|
||||||
const adhocDate = new Date(props.adhocTanggal);
|
|
||||||
if (adhocDate.getMonth() !== viewDate.value.getMonth() || adhocDate.getFullYear() !== viewDate.value.getFullYear()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayToHighlight = adhocDate.getDate().toString();
|
|
||||||
allDays.forEach(dayEl => {
|
|
||||||
if (!dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
|
|
||||||
const labelTextEl = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn__content, .v-calendar-weekly__day-label');
|
|
||||||
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
|
|
||||||
if (labelTextEl && labelTextEl.textContent.trim() === dayToHighlight) {
|
|
||||||
labelBtn.classList.add('adhoc-highlight');
|
|
||||||
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
|
|
||||||
labelBtn.style.borderRadius = '50%';
|
|
||||||
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
|
|
||||||
labelBtn.style.fontWeight = 'bold';
|
|
||||||
labelBtn.style.width = '28px';
|
|
||||||
labelBtn.style.height = '28px';
|
|
||||||
labelBtn.style.display = 'flex';
|
|
||||||
labelBtn.style.alignItems = 'center';
|
|
||||||
labelBtn.style.justifyContent = 'center';
|
|
||||||
labelBtn.style.margin = '2px auto';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
};
|
|
||||||
|
|
||||||
import { watch, nextTick } from 'vue';
|
|
||||||
watch([() => props.adhocTanggal, () => props.hari, () => props.tipe, viewDate], () => {
|
|
||||||
nextTick(() => updateHighlight());
|
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-card class="mt-4 border border-grey-lighten-3 rounded-lg" elevation="0">
|
|
||||||
<div class="d-flex align-center justify-space-between px-4 pt-3 pb-2">
|
|
||||||
<div class="d-flex align-center">
|
|
||||||
<v-btn icon="mdi-chevron-left" variant="text" size="x-small" @click="prevMonth" />
|
|
||||||
<v-btn icon="mdi-chevron-right" variant="text" size="x-small" @click="nextMonth" />
|
|
||||||
<span class="text-caption font-weight-bold mx-2" style="min-width:100px;text-align:center;">
|
|
||||||
{{ calendarTitle }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- We use a custom class to inject CSS for highlighted days if they have preview event -->
|
|
||||||
<v-calendar ref="calendarRef" :interval-height="40" locale="id" :key="calendarKey" :model-value="viewDate"
|
|
||||||
:events="calendarEvents" view-mode="month"
|
|
||||||
:class="['mini-calendar', props.tipe === 'Rutin' && props.hari ? 'preview-hari-' + props.hari : '']" />
|
|
||||||
</v-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.v-calendar-weekly__day) {
|
|
||||||
height: 70px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-calendar {
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
background-color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header hari */
|
|
||||||
:deep(.v-calendar-weekly__weekday) {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #555;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nomor tanggal */
|
|
||||||
:deep(.v-calendar-weekly__day-label) {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #424242;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lingkaran Preview via JS Class */
|
|
||||||
:deep(.rutin-highlight),
|
|
||||||
:deep(.adhoc-highlight) {
|
|
||||||
border: 2px solid rgb(var(--v-theme-primary)) !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
color: rgb(var(--v-theme-primary)) !important;
|
|
||||||
font-weight: bold !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Event styles */
|
|
||||||
:deep(.v-calendar-weekly__event) {
|
|
||||||
font-size: 0.6rem !important;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
padding: 0 4px !important;
|
|
||||||
margin-bottom: 2px !important;
|
|
||||||
min-height: 16px !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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 = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Generated
+11
-1
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
/**
|
|
||||||
* Halaman Jadwal Dokter — Orchestrator utama.
|
|
||||||
* Menyediakan 2 mode tampilan:
|
|
||||||
* 1. List (default) — tabel dengan filter poli, tanggal, nama dokter
|
|
||||||
* 2. Calendar — kalender bulanan dengan event jadwal
|
|
||||||
*
|
|
||||||
* State management (jadwalList, selectedPoli, selectedDoctor) dikelola di sini
|
|
||||||
* dan diteruskan ke child component via props/defineModel.
|
|
||||||
*/
|
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
|
||||||
import JadwalDokterList from '@/components/features/master/jadwalDokter/JadwalDokterList.vue';
|
|
||||||
import JadwalDokterCalendar from '@/components/features/master/jadwalDokter/JadwalDokterCalendar.vue';
|
|
||||||
import DialogTambahJadwal from '@/components/features/master/jadwalDokter/DialogTambahJadwal.vue';
|
|
||||||
import DialogDetailEvent from '@/components/features/master/jadwalDokter/DialogDetailEvent.vue';
|
|
||||||
import { useClinicStore } from '@/stores/clinicStore.js';
|
|
||||||
|
|
||||||
// ── Tanggal Header ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Mengembalikan string tanggal hari ini dalam format Indonesia */
|
|
||||||
const currentDate = computed(() => {
|
|
||||||
const now = new Date();
|
|
||||||
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
|
|
||||||
const months = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
|
||||||
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
|
||||||
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Mode Toggle ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Mode tampilan aktif: 'list' (default) atau 'calendar' */
|
|
||||||
const viewMode = ref('list');
|
|
||||||
|
|
||||||
// ── Store & Data ───────────────────────────────────────────────────────────
|
|
||||||
const clinicStore = useClinicStore();
|
|
||||||
|
|
||||||
/** Daftar semua klinik/poli dari store */
|
|
||||||
const clinicList = computed(() =>
|
|
||||||
clinicStore.clinics.map(c => ({ id: c.id, title: c.name, kode: c.kode, doctors: c.doctors ?? [] }))
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Poli yang dipilih (untuk mode calendar & dialog) */
|
|
||||||
const selectedPoli = ref(null);
|
|
||||||
|
|
||||||
/** Dokter yang dipilih (untuk mode calendar & dialog) */
|
|
||||||
const selectedDoctor = ref(null);
|
|
||||||
|
|
||||||
// ── Konstanta ──────────────────────────────────────────────────────────────
|
|
||||||
const DAY_MAP = {
|
|
||||||
'Minggu': 0, 'Senin': 1, 'Selasa': 2, 'Rabu': 3,
|
|
||||||
'Kamis': 4, 'Jumat': 5, 'Sabtu': 6
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Storage Jadwal (local state) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Daftar jadwal yang sudah ditambahkan.
|
|
||||||
* Setiap entry: { id, poliId, poliName, dokter, tipe, hari?, jamMulai?, jamSelesai?, mulai?, selesai? }
|
|
||||||
*/
|
|
||||||
const jadwalList = ref([]);
|
|
||||||
|
|
||||||
jadwalList.value = [
|
|
||||||
{
|
|
||||||
"id": 1784689277259,
|
|
||||||
"poliId": 1000,
|
|
||||||
"poliName": "ANAK",
|
|
||||||
"dokter": "dr. Sarah Putri, Sp.A",
|
|
||||||
"tipe": "Adhoc",
|
|
||||||
"mulai": "2026-07-05T07:00:00",
|
|
||||||
"selesai": "2026-07-05T08:00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1784689301842,
|
|
||||||
"poliId": 1000,
|
|
||||||
"poliName": "ANAK",
|
|
||||||
"dokter": "dr. Sarah Putri, Sp.A",
|
|
||||||
"tipe": "Rutin",
|
|
||||||
"hari": [
|
|
||||||
"Minggu",
|
|
||||||
],
|
|
||||||
"jamMulai": "10:00",
|
|
||||||
"jamSelesai": "12:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1784689301842,
|
|
||||||
"poliId": 1000,
|
|
||||||
"poliName": "ANAK",
|
|
||||||
"dokter": "dr. Sarah Putri, Sp.A",
|
|
||||||
"tipe": "Rutin",
|
|
||||||
"hari": [
|
|
||||||
"Kamis",
|
|
||||||
],
|
|
||||||
"jamMulai": "11:00",
|
|
||||||
"jamSelesai": "13:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1784689305888,
|
|
||||||
"poliId": 1000,
|
|
||||||
"poliName": "ANAK",
|
|
||||||
"dokter": "dr. Sarah Putri, Sp.A",
|
|
||||||
"tipe": "Adhoc",
|
|
||||||
"mulai": "2026-07-05T13:00:00",
|
|
||||||
"selesai": "2026-07-05T14:00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 178468930123,
|
|
||||||
"poliId": 1000,
|
|
||||||
"poliName": "ANAK",
|
|
||||||
"dokter": "dr. Sarah Putri, Sp.A",
|
|
||||||
"tipe": "Adhoc",
|
|
||||||
"mulai": "2026-07-05T19:00:00",
|
|
||||||
"selesai": "2026-07-05T20:00:00"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Generate Events Kalender ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format tanggal ke string YYYY-MM-DD.
|
|
||||||
* @param {Date} date
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const toDateStr = (date) => {
|
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Tanggal referensi untuk navigasi bulan (digunakan oleh calendarEvents) */
|
|
||||||
const viewDate = ref(new Date());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menghasilkan event kalender dari jadwalList yang tersimpan,
|
|
||||||
* difilter berdasarkan selectedPoli dan selectedDoctor.
|
|
||||||
* @returns {Array}
|
|
||||||
*/
|
|
||||||
const calendarEvents = computed(() => {
|
|
||||||
const events = [];
|
|
||||||
const year = viewDate.value.getFullYear();
|
|
||||||
const month = viewDate.value.getMonth();
|
|
||||||
|
|
||||||
const filtered = jadwalList.value.filter(j => {
|
|
||||||
if (selectedPoli.value && j.poliId !== selectedPoli.value) return false;
|
|
||||||
if (selectedDoctor.value && j.dokter !== selectedDoctor.value) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
filtered.forEach(jadwal => {
|
|
||||||
if (jadwal.tipe === 'Rutin') {
|
|
||||||
for (let mOffset = -1; mOffset <= 1; mOffset++) {
|
|
||||||
const tYear = month + mOffset < 0 ? year - 1 : month + mOffset > 11 ? year + 1 : year;
|
|
||||||
const tMonth = (month + mOffset + 12) % 12;
|
|
||||||
const daysInMonth = new Date(tYear, tMonth + 1, 0).getDate();
|
|
||||||
|
|
||||||
for (let d = 1; d <= daysInMonth; d++) {
|
|
||||||
const date = new Date(tYear, tMonth, d);
|
|
||||||
const dayName = Object.keys(DAY_MAP).find(k => DAY_MAP[k] === date.getDay());
|
|
||||||
if (!jadwal.hari.includes(dayName)) continue;
|
|
||||||
|
|
||||||
const dateStr = toDateStr(date);
|
|
||||||
/** Tambahkan jam ke title jika tersedia */
|
|
||||||
const jamLabel = jadwal.jamMulai && jadwal.jamSelesai
|
|
||||||
? ` ${jadwal.jamMulai} - ${jadwal.jamSelesai}`
|
|
||||||
: '';
|
|
||||||
events.push({
|
|
||||||
name: `${jamLabel}`,
|
|
||||||
start: jadwal.jamMulai ? new Date(`${dateStr}T${jadwal.jamMulai}:00`) : date,
|
|
||||||
end: jadwal.jamSelesai ? new Date(`${dateStr}T${jadwal.jamSelesai}:00`) : date,
|
|
||||||
color: 'primary',
|
|
||||||
allDay: !jadwal.jamMulai,
|
|
||||||
extendedProps: {
|
|
||||||
id: jadwal.id,
|
|
||||||
poli: jadwal.poliName,
|
|
||||||
dokter: jadwal.dokter,
|
|
||||||
tipe: 'Rutin',
|
|
||||||
hari: jadwal.hari.join(', '),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
/** Format jam adhoc dari datetime string */
|
|
||||||
const adhocMulaiTime = jadwal.mulai ? jadwal.mulai.split('T')[1]?.slice(0, 5) : '';
|
|
||||||
const adhocSelesaiTime = jadwal.selesai ? jadwal.selesai.split('T')[1]?.slice(0, 5) : '';
|
|
||||||
const adhocJamLabel = adhocMulaiTime && adhocSelesaiTime
|
|
||||||
? ` ${adhocMulaiTime} - ${adhocSelesaiTime}`
|
|
||||||
: '';
|
|
||||||
events.push({
|
|
||||||
name: `${adhocJamLabel}`,
|
|
||||||
start: new Date(jadwal.mulai),
|
|
||||||
end: new Date(jadwal.selesai),
|
|
||||||
color: 'secondary',
|
|
||||||
allDay: false,
|
|
||||||
extendedProps: {
|
|
||||||
id: jadwal.id,
|
|
||||||
poli: jadwal.poliName,
|
|
||||||
dokter: jadwal.dokter,
|
|
||||||
tipe: 'Adhoc',
|
|
||||||
shift: `${formatDatetime(jadwal.mulai)} – ${formatDatetime(jadwal.selesai)}`,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return events;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
watch(jadwalList, () => {
|
|
||||||
console.log(jadwalList.value)
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format ISO datetime ke string Indonesia yang ringkas.
|
|
||||||
* @param {string} isoStr
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const formatDatetime = (isoStr) => {
|
|
||||||
if (!isoStr) return '';
|
|
||||||
const d = new Date(isoStr);
|
|
||||||
return d.toLocaleString('id-ID', {
|
|
||||||
day: '2-digit', month: 'short', year: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Dialog Tambah Jadwal ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Apakah dialog tambah jadwal terbuka */
|
|
||||||
const isAddOpen = ref(false);
|
|
||||||
|
|
||||||
/** Tanggal yang diklik sebagai default untuk form */
|
|
||||||
const clickedDate = ref(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menangani klik pada tanggal di kalender.
|
|
||||||
* @param {Date} date - Tanggal yang diklik (sudah dinormalisasi oleh child)
|
|
||||||
*/
|
|
||||||
const handleDateClick = (date) => {
|
|
||||||
clickedDate.value = date;
|
|
||||||
isAddOpen.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menyimpan jadwal baru ke jadwalList dari dialog.
|
|
||||||
* @param {object|Array} jadwalData - Data jadwal dari dialog
|
|
||||||
*/
|
|
||||||
const handleSaveJadwal = (jadwalData) => {
|
|
||||||
if (Array.isArray(jadwalData)) {
|
|
||||||
jadwalList.value.push(...jadwalData);
|
|
||||||
} else {
|
|
||||||
jadwalList.value.push(jadwalData);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Membuka dialog tambah dari tombol header */
|
|
||||||
const openAddDialog = () => {
|
|
||||||
clickedDate.value = new Date();
|
|
||||||
isAddOpen.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Detail Event ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Event yang sedang diklik */
|
|
||||||
const selectedEvent = ref(null);
|
|
||||||
|
|
||||||
/** Apakah dialog detail terbuka */
|
|
||||||
const isDetailOpen = ref(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menangani klik event dari kalender.
|
|
||||||
* @param {object} eventData - Data event yang sudah dinormalisasi oleh child
|
|
||||||
*/
|
|
||||||
const handleEventClick = (eventData) => {
|
|
||||||
selectedEvent.value = eventData;
|
|
||||||
isDetailOpen.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menghapus jadwal dari jadwalList berdasarkan ID event.
|
|
||||||
* @param {number} id - ID jadwal yang akan dihapus
|
|
||||||
*/
|
|
||||||
const handleDeleteJadwal = (id) => {
|
|
||||||
if (!id) return;
|
|
||||||
jadwalList.value = jadwalList.value.filter(j => j.id !== id);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menangani klik edit dari mode list.
|
|
||||||
* @param {object} item - Item tabel yang akan di-edit
|
|
||||||
*/
|
|
||||||
const handleEdit = (item) => {
|
|
||||||
// Untuk saat ini, tampilkan detail event dari item pertama
|
|
||||||
if (item.raw && item.raw.length > 0) {
|
|
||||||
const first = item.raw[0];
|
|
||||||
selectedEvent.value = {
|
|
||||||
id: first.id,
|
|
||||||
poli: first.poliName,
|
|
||||||
dokter: first.dokter,
|
|
||||||
tipe: first.tipe,
|
|
||||||
hari: first.hari ? first.hari.join(', ') : null,
|
|
||||||
tanggal: new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
|
|
||||||
};
|
|
||||||
isDetailOpen.value = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div style="background: var(--color-neutral-300); height: 100%;">
|
|
||||||
<PageHeader icon="mdi-calendar-clock" title="Jadwal Dokter" :subtitle="currentDate" :show-add-button="false"
|
|
||||||
theme="primary">
|
|
||||||
<template #actions>
|
|
||||||
<div class="d-flex align-center gap-3">
|
|
||||||
<!-- Mode Toggle -->
|
|
||||||
<v-btn-toggle v-model="viewMode" mandatory density="compact" variant="outlined" color="white"
|
|
||||||
class="mode-toggle" divided>
|
|
||||||
<v-btn value="list" size="small">
|
|
||||||
<v-icon start size="18">mdi-format-list-bulleted</v-icon>
|
|
||||||
List
|
|
||||||
</v-btn>
|
|
||||||
<v-btn value="calendar" size="small">
|
|
||||||
<v-icon start size="18">mdi-calendar-month</v-icon>
|
|
||||||
Calendar
|
|
||||||
</v-btn>
|
|
||||||
</v-btn-toggle>
|
|
||||||
|
|
||||||
<!-- Tombol Tambah Dokter -->
|
|
||||||
<v-btn color="white" elevation="0" class="add-btn-primary ml-5" @click="openAddDialog">
|
|
||||||
<v-icon left size="20">mdi-plus-circle</v-icon>
|
|
||||||
Tambah Jadwal
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<v-container class="py-6">
|
|
||||||
<!-- ── Mode List (default) ──────────────────────────── -->
|
|
||||||
<JadwalDokterList v-if="viewMode === 'list'" :clinic-list="clinicList" :jadwal-list="jadwalList"
|
|
||||||
@edit="handleEdit" @add="openAddDialog" />
|
|
||||||
|
|
||||||
<!-- ── Mode Calendar ────────────────────────────────── -->
|
|
||||||
<JadwalDokterCalendar v-else v-model:selected-poli="selectedPoli" v-model:selected-doctor="selectedDoctor"
|
|
||||||
:clinic-list="clinicList" :calendar-events="calendarEvents" @date-click="handleDateClick"
|
|
||||||
@event-click="handleEventClick" />
|
|
||||||
</v-container>
|
|
||||||
|
|
||||||
<!-- ══ Dialog Tambah Jadwal (shared) ═══════════════════════ -->
|
|
||||||
<DialogTambahJadwal v-model="isAddOpen" :view-mode="viewMode" :clinic-list="clinicList"
|
|
||||||
:selected-poli="selectedPoli" :selected-doctor="selectedDoctor" :clicked-date="clickedDate"
|
|
||||||
:jadwal-list="jadwalList"
|
|
||||||
@save="handleSaveJadwal" />
|
|
||||||
|
|
||||||
<!-- ══ Dialog Detail Event (shared) ═══════════════════════ -->
|
|
||||||
<DialogDetailEvent v-model="isDetailOpen" :event="selectedEvent" @delete="handleDeleteJadwal" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mode-toggle {
|
|
||||||
border-radius: 8px !important;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-toggle .v-btn {
|
|
||||||
color: rgba(255, 255, 255, 0.8) !important;
|
|
||||||
text-transform: none !important;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-toggle .v-btn--active {
|
|
||||||
background-color: rgba(255, 255, 255, 0.2) !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn-primary {
|
|
||||||
text-transform: none !important;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -331,7 +331,11 @@ const handleAddMember = () => {
|
|||||||
<div class="text-body-2">{{ profileData.birthDate }}</div>
|
<div class="text-body-2">{{ profileData.birthDate }}</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="2">
|
<v-col cols="3">
|
||||||
|
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
|
||||||
|
Nomor Telepon
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2">{{ profileData.registrationNumber }}</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="7">
|
<v-col cols="7">
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
@@ -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(() => {
|
||||||
|
|||||||
+39
-33
@@ -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,36 +38,34 @@ 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"},
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 15,
|
id: 15,
|
||||||
name: "Master Data",
|
name: "Master Data",
|
||||||
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: 25, name: "Master Jadwal Dokter", path: "/Setting/JadwalDokter", icon: "mdi-circle-small" },
|
{ id: 18, name: "Master Anjungan", path: "/Setting/MasterAnjungan", icon: "mdi-circle-small", menuKey: "setting-masteranjungan" },
|
||||||
{ id: 18, name: "Master Anjungan", path: "/Setting/MasterAnjungan", icon: "mdi-circle-small" },
|
{ id: 19, name: "Master Loket", path: "/Setting/MasterLoket", icon: "mdi-circle-small", menuKey: "setting-masterloket" },
|
||||||
{ id: 19, name: "Master Loket", path: "/Setting/MasterLoket", icon: "mdi-circle-small" },
|
{ id: 20, name: "Master Klinik", path: "/Setting/MasterKlinik", icon: "mdi-circle-small", menuKey: "setting-masterklinik" },
|
||||||
{ id: 20, name: "Master Klinik", path: "/Setting/MasterKlinik", icon: "mdi-circle-small" },
|
{ id: 21, name: "Master Klinik Ruang", path: "/Setting/MasterKlinikRuang", icon: "mdi-circle-small", menuKey: "setting-masterklinikruang" },
|
||||||
{ id: 21, name: "Master Klinik Ruang", path: "/Setting/MasterKlinikRuang", icon: "mdi-circle-small" },
|
|
||||||
// { 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');
|
||||||
@@ -102,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[] => {
|
||||||
@@ -127,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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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(', '));
|
||||||
Reference in New Issue
Block a user