457 lines
15 KiB
Vue
457 lines
15 KiB
Vue
<template>
|
|
<v-card class="pa-6 rounded-xl elevation-4">
|
|
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
|
|
<v-icon icon="mdi-lock-check-outline" class="mr-2 text-primary" size="28"></v-icon>
|
|
<span>Edit Hak Akses Menu</span>
|
|
</v-card-title>
|
|
<v-divider class="mb-4"></v-divider>
|
|
<v-card-text class="px-0">
|
|
<v-row v-if="localItem.role || localItem.group" class="mb-4">
|
|
<v-col cols="12">
|
|
<v-alert type="info" variant="tonal" density="compact">
|
|
<strong>Role:</strong> {{ localItem.role }} | <strong>Group:</strong> {{ localItem.group }}
|
|
</v-alert>
|
|
</v-col>
|
|
</v-row>
|
|
<v-row>
|
|
<v-col cols="12">
|
|
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
|
|
<v-table density="comfortable" class="elevation-1 rounded-xl hak-akses-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1 kol-no">No</th>
|
|
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
|
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-status">Status</th>
|
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Akses</th>
|
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Lihat</th>
|
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Tambah</th>
|
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Edit</th>
|
|
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Hapus</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-if="backendPermissions.length > 0">
|
|
<tr v-for="(perm, index) in sortedPermissions" :key="perm.id" :class="{ 'bg-grey-lighten-5': perm.level === 2 }">
|
|
<td class="kol-no text-center">{{ index + 1 }}</td>
|
|
<td>
|
|
<div :style="{ paddingLeft: perm.level === 2 ? '32px' : '0' }" class="d-flex align-center">
|
|
<v-icon v-if="perm.level === 2" icon="mdi-subdirectory-arrow-right" size="small" class="mr-2 text-grey"></v-icon>
|
|
<span :class="{ 'font-weight-bold': perm.level === 1 }">{{ perm.pagename }}</span>
|
|
<v-chip v-if="perm.level" size="x-small" variant="outlined" class="ml-2" color="grey">
|
|
Level {{ perm.level }}
|
|
</v-chip>
|
|
</div>
|
|
</td>
|
|
<td class="text-center kol-status">
|
|
<v-chip
|
|
:color="isPageMapped(perm.pagename) ? 'success' : 'warning'"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ isPageMapped(perm.pagename) ? 'Mapped' : 'Not Mapped' }}
|
|
</v-chip>
|
|
</td>
|
|
<td class="text-center kol-aksi">
|
|
<div class="cek-wrapper">
|
|
<v-checkbox
|
|
v-model="perm.active"
|
|
hide-details
|
|
color="primary"
|
|
></v-checkbox>
|
|
</div>
|
|
</td>
|
|
<td class="text-center kol-aksi">
|
|
<div class="cek-wrapper">
|
|
<v-checkbox
|
|
v-model="perm.read"
|
|
hide-details
|
|
color="primary"
|
|
></v-checkbox>
|
|
</div>
|
|
</td>
|
|
<td class="text-center kol-aksi">
|
|
<div class="cek-wrapper">
|
|
<v-checkbox
|
|
v-model="perm.create"
|
|
hide-details
|
|
color="primary"
|
|
></v-checkbox>
|
|
</div>
|
|
</td>
|
|
<td class="text-center kol-aksi">
|
|
<div class="cek-wrapper">
|
|
<v-checkbox
|
|
v-model="perm.update"
|
|
hide-details
|
|
color="primary"
|
|
></v-checkbox>
|
|
</div>
|
|
</td>
|
|
<td class="text-center kol-aksi">
|
|
<div class="cek-wrapper">
|
|
<v-checkbox
|
|
v-model="perm.delete"
|
|
hide-details
|
|
color="primary"
|
|
></v-checkbox>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template v-else>
|
|
<tr v-for="(menu, index) in orderedMenus" :key="menu.name">
|
|
<td class="kol-no text-center">{{ index + 1 }}</td>
|
|
<td>{{ menu.name }}</td>
|
|
<td class="text-center kol-status">
|
|
<v-chip color="success" size="small" variant="tonal">
|
|
Mapped
|
|
</v-chip>
|
|
</td>
|
|
<td class="text-center kol-aksi">
|
|
<div class="cek-wrapper">
|
|
<v-checkbox v-model="menu.canAccess" hide-details color="primary"></v-checkbox>
|
|
</div>
|
|
</td>
|
|
<td class="text-center kol-aksi">
|
|
<div class="cek-wrapper">
|
|
<v-checkbox v-model="menu.canView" hide-details color="primary"></v-checkbox>
|
|
</div>
|
|
</td>
|
|
<td class="text-center kol-aksi">
|
|
<div class="cek-wrapper">
|
|
<v-checkbox v-model="menu.canAdd" hide-details color="primary"></v-checkbox>
|
|
</div>
|
|
</td>
|
|
<td class="text-center kol-aksi">
|
|
<div class="cek-wrapper">
|
|
<v-checkbox v-model="menu.canEdit" hide-details color="primary"></v-checkbox>
|
|
</div>
|
|
</td>
|
|
<td class="text-center kol-aksi">
|
|
<div class="cek-wrapper">
|
|
<v-checkbox v-model="menu.canDelete" hide-details color="primary"></v-checkbox>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</v-table>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
<v-card-actions class="d-flex justify-end pa-0 mt-4">
|
|
<v-btn
|
|
color="grey-darken-1"
|
|
variant="flat"
|
|
rounded="lg"
|
|
class="text-capitalize mr-2"
|
|
@click="$emit('cancel')"
|
|
>
|
|
Batal
|
|
</v-btn>
|
|
<v-btn
|
|
color="primary"
|
|
variant="flat"
|
|
rounded="lg"
|
|
class="text-capitalize"
|
|
@click="handleSave"
|
|
:loading="isSaving"
|
|
>
|
|
Submit
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch, onMounted } from 'vue';
|
|
import { useNavItemsStore } from '~/stores/navItems1';
|
|
|
|
// Define types for better readability and safety
|
|
interface HakAksesMenu {
|
|
name: string;
|
|
canAccess: boolean;
|
|
canView: boolean;
|
|
canAdd: boolean;
|
|
canEdit: boolean;
|
|
canDelete: boolean;
|
|
}
|
|
|
|
interface BackendPermissionItem {
|
|
id: number;
|
|
create: boolean;
|
|
read: boolean;
|
|
update: boolean;
|
|
disable: boolean;
|
|
delete: boolean;
|
|
active: boolean;
|
|
pagename: string;
|
|
pagesID: number;
|
|
level?: number;
|
|
sort?: number;
|
|
parent?: number;
|
|
}
|
|
|
|
interface HakAksesData {
|
|
id: number;
|
|
role?: string;
|
|
group?: string;
|
|
namaTipeUser: string;
|
|
hakAksesMenu: HakAksesMenu[];
|
|
}
|
|
|
|
// Define props with type validation
|
|
const props = defineProps({
|
|
item: {
|
|
type: Object as () => HakAksesData,
|
|
required: true,
|
|
},
|
|
});
|
|
|
|
// Define emits for clarity
|
|
const emits = defineEmits(['save', 'cancel']);
|
|
|
|
// Use a local copy to avoid mutating the prop directly
|
|
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
|
|
const backendPermissions = ref<BackendPermissionItem[]>([]);
|
|
const isSaving = ref(false);
|
|
const navItemsStore = useNavItemsStore();
|
|
|
|
// Helper function to normalize group name
|
|
const normalizeGroup = (group: string): string => {
|
|
const normalized = group.trim();
|
|
// Jika group mengandung "Instalasi STIM", ambil hanya "STIM"
|
|
if (normalized.toLowerCase().includes('instalasi')) {
|
|
const parts = normalized.split(/\s+/);
|
|
const stimIndex = parts.findIndex(p => p.toLowerCase() === 'stim');
|
|
if (stimIndex !== -1) {
|
|
return 'STIM';
|
|
}
|
|
}
|
|
// Jika group adalah "Instalasi STIM", return "STIM"
|
|
if (normalized.toLowerCase() === 'instalasi stim') {
|
|
return 'STIM';
|
|
}
|
|
return normalized.toUpperCase();
|
|
};
|
|
|
|
// Helper function to normalize role name
|
|
const normalizeRole = (role: string): string => {
|
|
const normalized = role.toLowerCase().trim();
|
|
// Mapping khusus untuk role default
|
|
if (normalized === 'default-roles-sandbox') {
|
|
return 'superadmin';
|
|
}
|
|
return normalized;
|
|
};
|
|
|
|
// Fetch permissions from backend API when component mounts
|
|
onMounted(async () => {
|
|
if (localItem.value.role && localItem.value.group) {
|
|
try {
|
|
// Normalize role and group before making API call
|
|
const normalizedRole = normalizeRole(localItem.value.role);
|
|
const normalizedGroup = normalizeGroup(localItem.value.group);
|
|
|
|
console.log('🔄 Fetching permissions with normalized values:', {
|
|
originalRole: localItem.value.role,
|
|
normalizedRole,
|
|
originalGroup: localItem.value.group,
|
|
normalizedGroup,
|
|
});
|
|
|
|
const response = await $fetch<any>('/api/permission', {
|
|
query: {
|
|
roles: normalizedRole,
|
|
groups: normalizedGroup,
|
|
},
|
|
});
|
|
|
|
if (response && response.data && Array.isArray(response.data)) {
|
|
console.log(`✅ Received ${response.data.length} permissions from API`);
|
|
// Create a deep copy to avoid mutating the original
|
|
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
|
|
...perm,
|
|
}));
|
|
} else {
|
|
console.warn('⚠️ No data received from API or invalid structure');
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Error fetching permissions:', error);
|
|
}
|
|
} else {
|
|
console.warn('⚠️ Role or group is missing:', {
|
|
role: localItem.value.role,
|
|
group: localItem.value.group,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Sort permissions: level 1 first, then level 2 grouped under their parents
|
|
const sortedPermissions = computed(() => {
|
|
if (backendPermissions.value.length === 0) return [];
|
|
|
|
const level1 = backendPermissions.value.filter(p => p.level === 1);
|
|
const level2 = backendPermissions.value.filter(p => p.level === 2);
|
|
|
|
// Sort level 1 by sort order
|
|
level1.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
|
|
|
// Sort level 2 by sort order
|
|
level2.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
|
|
|
// Build result: insert level 2 items after their parent
|
|
const result: BackendPermissionItem[] = [];
|
|
|
|
level1.forEach(parent => {
|
|
result.push(parent);
|
|
// Add children of this parent
|
|
const children = level2.filter(child => child.parent === parent.pagesID);
|
|
result.push(...children);
|
|
});
|
|
|
|
// Add any remaining level 2 items that don't have a parent match
|
|
const addedChildren = new Set(level2.filter(c => c.parent && level1.some(p => p.pagesID === c.parent)).map(c => c.id));
|
|
const remainingChildren = level2.filter(c => !addedChildren.has(c.id));
|
|
result.push(...remainingChildren);
|
|
|
|
return result;
|
|
});
|
|
|
|
// Cek apakah suatu pagename dari backend sudah termapping ke salah satu menu di hakAksesMenu
|
|
const isPageMapped = (pagename: string): boolean => {
|
|
if (!localItem.value.hakAksesMenu || localItem.value.hakAksesMenu.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const lowerPage = (pagename || '').toLowerCase();
|
|
return localItem.value.hakAksesMenu.some((menu) => {
|
|
const name = (menu.name || '').toLowerCase();
|
|
return (
|
|
name === lowerPage ||
|
|
name.includes(lowerPage) ||
|
|
lowerPage.includes(name)
|
|
);
|
|
});
|
|
};
|
|
|
|
interface NavItemOrder {
|
|
name: string;
|
|
children?: NavItemOrder[];
|
|
}
|
|
|
|
// Build a map of menu order based on the sidebar configuration so the list is always aligned
|
|
const menuOrder = computed(() => {
|
|
const order: Record<string, number> = {};
|
|
const walk = (items: NavItemOrder[], startIndex = 0): number => {
|
|
let idx = startIndex;
|
|
items.forEach((item) => {
|
|
order[item.name] = idx;
|
|
idx += 1;
|
|
if (item.children?.length) {
|
|
idx = walk(item.children, idx);
|
|
}
|
|
});
|
|
return idx;
|
|
};
|
|
walk(navItemsStore.navItems);
|
|
return order;
|
|
});
|
|
|
|
const orderedMenus = computed(() => {
|
|
const order = menuOrder.value;
|
|
return [...localItem.value.hakAksesMenu].sort((a, b) => {
|
|
const orderA = order[a.name] ?? Number.MAX_SAFE_INTEGER;
|
|
const orderB = order[b.name] ?? Number.MAX_SAFE_INTEGER;
|
|
return orderA - orderB;
|
|
});
|
|
});
|
|
|
|
// Handle save - convert backend permissions back to menu structure if needed
|
|
const handleSave = async () => {
|
|
isSaving.value = true;
|
|
try {
|
|
// If we have backend permissions, we need to map them back to the menu structure
|
|
if (backendPermissions.value.length > 0) {
|
|
// Update the local item with backend permissions mapped to menu structure
|
|
const updatedItem = {
|
|
...localItem.value,
|
|
backendPermissions: backendPermissions.value,
|
|
};
|
|
emits('save', updatedItem);
|
|
} else {
|
|
// Use existing menu structure
|
|
emits('save', localItem.value);
|
|
}
|
|
} finally {
|
|
isSaving.value = false;
|
|
}
|
|
};
|
|
|
|
// Watch the prop for changes and update the local copy
|
|
watch(() => props.item, (newItem) => {
|
|
localItem.value = JSON.parse(JSON.stringify(newItem));
|
|
// Re-fetch permissions if role/group changed
|
|
if (newItem.role && newItem.group) {
|
|
// Normalize role and group before making API call
|
|
const normalizedRole = normalizeRole(newItem.role);
|
|
const normalizedGroup = normalizeGroup(newItem.group);
|
|
|
|
console.log('🔄 Re-fetching permissions with normalized values:', {
|
|
originalRole: newItem.role,
|
|
normalizedRole,
|
|
originalGroup: newItem.group,
|
|
normalizedGroup,
|
|
});
|
|
|
|
$fetch<any>('/api/permission', {
|
|
query: {
|
|
roles: normalizedRole,
|
|
groups: normalizedGroup,
|
|
},
|
|
}).then(response => {
|
|
if (response && response.data && Array.isArray(response.data)) {
|
|
console.log(`✅ Received ${response.data.length} permissions from API`);
|
|
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
|
|
...perm,
|
|
}));
|
|
}
|
|
}).catch(error => {
|
|
console.error('❌ Error fetching permissions:', error);
|
|
});
|
|
}
|
|
}, { deep: true });
|
|
</script>
|
|
|
|
<style scoped>
|
|
.v-table :deep(th) {
|
|
font-weight: bold !important;
|
|
background-color: #f9fafb !important;
|
|
}
|
|
|
|
.v-table :deep(td) {
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.v-checkbox :deep(.v-selection-control__input) {
|
|
color: #2196F3 !important;
|
|
}
|
|
|
|
.hak-akses-table :deep(.kol-no) {
|
|
width: 56px;
|
|
}
|
|
|
|
.hak-akses-table :deep(.kol-aksi) {
|
|
width: 90px;
|
|
}
|
|
|
|
.hak-akses-table :deep(.kol-status) {
|
|
width: 120px;
|
|
}
|
|
|
|
.cek-wrapper {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
</style> |