Files
web-antrean/pages/Setting/HakAkses.vue
T
2026-01-08 12:44:40 +07:00

2012 lines
64 KiB
Vue

<template>
<div class="hak-akses-page pa-4">
<v-breadcrumbs :items="breadcrumbs" class="pl-0 mb-4">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<!-- Dialog for Add/Edit Form -->
<v-dialog
v-model="showAddEditDialog"
max-width="1200px"
persistent
scrollable
transition="dialog-bottom-transition"
>
<v-card class="modern-dialog-card">
<div class="dialog-header" :class="viewMode === 'add' ? 'header-add' : 'header-edit'">
<div class="dialog-header-content">
<div class="dialog-header-left">
<v-avatar
:color="viewMode === 'add' ? 'green-darken-2' : 'orange-darken-2'"
size="56"
class="dialog-avatar elevation-4"
>
<v-icon :icon="viewMode === 'add' ? 'mdi-plus' : 'mdi-pencil'" color="white" size="28"></v-icon>
</v-avatar>
<div class="dialog-header-text">
<h2 class="dialog-title">{{ formTitle }}</h2>
<p class="dialog-subtitle">
{{ viewMode === 'add' ? 'Tambahkan hak akses baru ke sistem' : 'Ubah informasi hak akses yang ada' }}
</p>
</div>
</div>
<v-btn
icon
variant="text"
size="small"
class="dialog-close-btn"
@click="cancelForm"
>
<v-icon color="white" size="24">mdi-close</v-icon>
</v-btn>
</div>
</div>
<v-card-text class="dialog-content">
<v-row>
<v-col cols="12">
<v-alert
type="info"
variant="tonal"
density="comfortable"
class="mb-4"
>
<v-alert-title>Group-Based Access</v-alert-title>
Hak akses akan diberikan ke semua user yang memiliki group dan role yang dipilih.
Setiap user dapat memiliki multiple hak akses dari berbagai group.
</v-alert>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold text-medium-emphasis">Role <span class="text-error">*</span></v-label>
<v-select
v-model="editedItem.role"
:items="availableRoles"
placeholder="Pilih Role"
variant="outlined"
density="comfortable"
class="mt-1"
required
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold text-medium-emphasis">Group <span class="text-error">*</span></v-label>
<v-select
v-model="selectedGroup"
:items="availableGroupPaths"
item-title="label"
item-value="value"
placeholder="Pilih Group"
variant="outlined"
density="comfortable"
class="mt-1"
required
@update:model-value="onGroupSelected"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props" :title="item.raw.label" :subtitle="item.raw.path"></v-list-item>
</template>
</v-select>
<div v-if="selectedGroupUsers.length > 0" class="mt-2">
<v-chip size="small" color="info" class="mr-1 mb-1">
{{ selectedGroupUsers.length }} user akan mendapatkan hak akses ini
</v-chip>
</div>
</v-col>
</v-row>
<v-row v-if="selectedGroupUsers.length > 0">
<v-col cols="12">
<v-label class="font-weight-bold text-medium-emphasis mb-2">User yang akan mendapatkan hak akses:</v-label>
<v-card variant="outlined" class="pa-3">
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="user in selectedGroupUsers"
:key="user.id"
size="small"
color="primary"
variant="tonal"
>
{{ user.namaLengkap || user.namaUser }} ({{ user.namaUser }})
</v-chip>
</div>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User (Optional)</v-label>
<v-text-field
v-model="editedItem.namaTipeUser"
placeholder="Masukkan Nama Tipe User"
variant="outlined"
density="comfortable"
class="mt-1"
></v-text-field>
</v-col>
</v-row>
<v-row v-if="viewMode === 'add' || viewMode === 'editName'">
<v-col cols="12">
<v-btn
color="info"
prepend-icon="mdi-download"
variant="outlined"
rounded="lg"
class="text-capitalize"
@click="fetchPermissionsFromBackend"
:loading="isFetchingPermissions"
:disabled="!editedItem.role || !editedItem.group"
>
Ambil Data dari Backend API
</v-btn>
<span class="ml-3 text-caption text-medium-emphasis">
* Klik tombol ini untuk mengambil permissions dari backend berdasarkan Role dan Group yang sudah diisi
</span>
</v-col>
</v-row>
<!-- Display fetched permissions info -->
<v-row v-if="fetchedBackendData && fetchedBackendData.length > 0" class="mt-4">
<v-col cols="12">
<v-card variant="outlined" class="pa-4 bg-blue-lighten-5">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-3">
<v-icon icon="mdi-information" class="mr-2" color="info"></v-icon>
Data dari Backend API
</v-card-title>
<v-card-text class="pa-0">
<div class="mb-3">
<strong>Total Permissions:</strong> {{ fetchedBackendData.length }} items
</div>
<v-table density="compact" class="elevation-1">
<thead>
<tr>
<th class="text-left">No</th>
<th class="text-left">Page Name</th>
<th class="text-center">Active</th>
<th class="text-center">Read</th>
<th class="text-center">Create</th>
<th class="text-center">Update</th>
<th class="text-center">Delete</th>
<th class="text-center">Level</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
<tr v-for="(perm, index) in fetchedBackendData" :key="perm.id">
<td>{{ index + 1 }}</td>
<td>
<strong>{{ perm.pagename }}</strong>
<div class="text-caption text-grey">{{ perm.pagesID }}</div>
</td>
<td class="text-center">
<v-icon :color="perm.active ? 'green' : 'grey'">
{{ perm.active ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</td>
<td class="text-center">
<v-icon :color="perm.read ? 'green' : 'grey'">
{{ perm.read ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</td>
<td class="text-center">
<v-icon :color="perm.create ? 'green' : 'grey'">
{{ perm.create ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</td>
<td class="text-center">
<v-icon :color="perm.update ? 'green' : 'grey'">
{{ perm.update ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</td>
<td class="text-center">
<v-icon :color="perm.delete ? 'green' : 'grey'">
{{ perm.delete ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</td>
<td class="text-center">{{ perm.level || '-' }}</td>
<td class="text-center">
<v-chip
:color="getMappingStatus(perm.pagename) ? 'success' : 'warning'"
size="small"
>
{{ getMappingStatus(perm.pagename) ? 'Mapped' : 'Not Mapped' }}
</v-chip>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Display mapped permissions summary -->
<v-row v-if="editedItem.hakAksesMenu && editedItem.hakAksesMenu.length > 0" class="mt-4">
<v-col cols="12">
<v-card variant="outlined" class="pa-4 bg-green-lighten-5">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-3">
<v-icon icon="mdi-check-circle" class="mr-2" color="success"></v-icon>
Permissions yang Sudah di-Mapping ke Menu
</v-card-title>
<v-card-text class="pa-0">
<div class="mb-3">
<strong>Total Menu:</strong> {{ editedItem.hakAksesMenu.length }} items
<span class="ml-4">
<strong>Dengan Akses:</strong>
{{ editedItem.hakAksesMenu.filter(m => m.canAccess).length }} items
</span>
</div>
<v-alert
type="info"
variant="tonal"
density="compact"
class="mb-3"
>
Data permissions dari backend telah di-mapping ke struktur menu sidebar.
Anda dapat mengedit permissions di tab "Edit Hak Akses" setelah menyimpan data ini.
</v-alert>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="dialog-actions">
<v-spacer></v-spacer>
<v-btn
color="grey-darken-1"
variant="outlined"
rounded="lg"
class="text-capitalize mr-2"
@click="cancelForm"
size="large"
>
<v-icon left>mdi-close</v-icon>
Batal
</v-btn>
<v-btn
:color="viewMode === 'add' ? 'green-darken-2' : 'orange-darken-2'"
variant="flat"
rounded="lg"
class="text-capitalize"
@click="saveItem"
size="large"
>
<v-icon left>{{ viewMode === 'add' ? 'mdi-check' : 'mdi-content-save' }}</v-icon>
{{ viewMode === 'add' ? 'Simpan' : 'Update' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog for Edit Access -->
<v-dialog
v-model="showEditAccessDialog"
max-width="1400px"
persistent
scrollable
transition="dialog-bottom-transition"
>
<v-card class="modern-dialog-card">
<div class="dialog-header header-edit-access">
<div class="dialog-header-content">
<div class="dialog-header-left">
<v-avatar
color="green-darken-2"
size="56"
class="dialog-avatar elevation-4"
>
<v-icon icon="mdi-lock-check" color="white" size="28"></v-icon>
</v-avatar>
<div class="dialog-header-text">
<h2 class="dialog-title">Edit Hak Akses</h2>
<p class="dialog-subtitle">Kelola permissions dan akses menu untuk pengguna</p>
</div>
</div>
<v-btn
icon
variant="text"
size="small"
class="dialog-close-btn"
@click="cancelForm"
>
<v-icon color="white" size="24">mdi-close</v-icon>
</v-btn>
</div>
</div>
<v-card-text class="dialog-content">
<EditHakAkses
:item="editedItem"
@save="updateItemAccess"
@cancel="cancelForm"
/>
</v-card-text>
</v-card>
</v-dialog>
<!-- Dialog for View Detail -->
<v-dialog
v-model="showViewDialog"
max-width="1200px"
scrollable
:persistent="false"
transition="dialog-bottom-transition"
>
<v-card class="modern-dialog-card">
<div class="dialog-header header-view">
<div class="dialog-header-content">
<div class="dialog-header-left">
<v-avatar
color="blue-darken-2"
size="56"
class="dialog-avatar elevation-4"
>
<v-icon icon="mdi-eye" color="white" size="28"></v-icon>
</v-avatar>
<div class="dialog-header-text">
<h2 class="dialog-title">Detail Hak Akses</h2>
<p class="dialog-subtitle">Lihat informasi detail hak akses pengguna</p>
</div>
</div>
<v-btn
icon
variant="text"
size="small"
class="dialog-close-btn"
@click="cancelForm"
>
<v-icon color="white" size="24">mdi-close</v-icon>
</v-btn>
</div>
</div>
<v-card-text class="dialog-content">
<div v-if="viewMode === 'view'">
<v-row>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">User ID</v-label>
<v-text-field
v-model="editedItem.userId"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Nama Lengkap</v-label>
<v-text-field
v-model="editedItem.namaLengkap"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Nama User</v-label>
<v-text-field
v-model="editedItem.namaUser"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Tipe User</v-label>
<v-text-field
v-model="editedItem.tipeUser"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold text-medium-emphasis mb-2">Hak Akses (Multiple Groups)</v-label>
<v-card variant="outlined" class="pa-3">
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="(hakAkses, idx) in viewModeHakAksesList"
:key="idx"
size="small"
color="primary"
variant="tonal"
>
<v-icon start size="14">mdi-account-group</v-icon>
{{ hakAkses.group }} / {{ hakAkses.role }}
</v-chip>
<span v-if="viewModeHakAksesList.length === 0" class="text-grey">Tidak ada hak akses</span>
</div>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
<v-table density="comfortable" class="elevation-1 rounded-xl">
<thead>
<tr>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">No</th>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Akses</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Lihat</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Tambah</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Edit</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1">Hapus</th>
</tr>
</thead>
<tbody>
<tr v-for="(menu, index) in editedItem.hakAksesMenu" :key="index">
<td>{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center">
<v-icon :color="menu.canAccess ? 'green' : 'grey-lighten-2'">{{ menu.canAccess ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canView ? 'green' : 'grey-lighten-2'">{{ menu.canView ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canAdd ? 'green' : 'grey-lighten-2'">{{ menu.canAdd ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canEdit ? 'green' : 'grey-lighten-2'">{{ menu.canEdit ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
<td class="text-center">
<v-icon :color="menu.canDelete ? 'green' : 'grey-lighten-2'">{{ menu.canDelete ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="dialog-actions">
<v-spacer></v-spacer>
<v-btn
color="grey-darken-1"
variant="outlined"
rounded="lg"
class="text-capitalize"
@click="cancelForm"
size="large"
>
<v-icon left>mdi-close</v-icon>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Main Table View - Always visible, dialog overlays it -->
<div>
<v-card class="rounded-xl elevation-2" style="background-color: white;">
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-shield-lock-outline</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Hak Akses</h2>
<p class="page-subtitle">{{ new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) }} - Manajemen Hak Akses</p>
</div>
</div>
<div class="header-stats">
<v-chip color="white" variant="flat" class="stat-chip mr-2">
<v-icon start size="16">mdi-account-group</v-icon>
{{ filteredHakAksesData.length }} User
</v-chip>
<v-btn
color="white"
@click="showAddForm"
elevation="0"
class="add-btn"
>
<v-icon left size="20">mdi-plus-circle</v-icon>
Tambah Baru
</v-btn>
</div>
</div>
</div>
<!-- Table -->
<v-card-text>
<div class="d-flex flex-column flex-sm-row align-center justify-space-between mb-4">
<div class="d-flex align-center mb-4 mb-sm-0">
<span class="mr-2 text-subtitle-1 text-medium-emphasis">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50]"
variant="outlined"
density="compact"
hide-details
style="max-width: 80px;"
rounded="lg"
class="mr-2"
></v-select>
<span class="text-subtitle-1 text-medium-emphasis">entries</span>
</div>
<div class="d-flex align-center">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
variant="outlined"
density="compact"
hide-details
rounded="lg"
clearable
style="min-width: 300px; width: 100%; max-width: 400px;"
></v-text-field>
</div>
</div>
<!-- Filter bar -->
<v-row class="mb-4" dense>
<v-col cols="12" sm="4">
<v-select
v-model="filterTipeUser"
:items="availableTipeUsers"
label="Filter Tipe User"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" sm="4">
<v-select
v-model="filterRole"
:items="availableRoles"
label="Filter Role"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" sm="4">
<v-select
v-model="filterGroup"
:items="availableGroups"
label="Filter Group"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
</v-row>
<v-data-table
:headers="headers"
:items="filteredHakAksesData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
class="rounded-lg elevation-0 custom-table"
>
<template v-slot:[`item.actions`]="{ item }">
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="red" size="small" class="mr-2" @click="deleteItem(item)">
<v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn icon color="green" size="small" @click="editAccess(item)">
<v-icon>mdi-lock-check</v-icon>
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<template v-slot:item.id="{ item }">
<div class="text-center">{{ item.id }}</div>
</template>
<!-- Tampilkan User ID dengan format generated -->
<template v-slot:item.userId="{ item }">
<div class="text-center">
{{ generateDisplayUserId(item) }}
</div>
</template>
<!-- Tipe User dengan chip -->
<template v-slot:item.tipeUser="{ item }">
<v-chip
v-if="item.tipeUser"
color="purple-lighten-1"
size="small"
>
{{ item.tipeUser }}
</v-chip>
<span v-else class="text-grey">-</span>
</template>
<!-- Hak Akses List - menampilkan multiple hak akses per user -->
<template v-slot:item.hakAksesList="{ item }">
<div class="d-flex flex-wrap gap-1">
<v-tooltip
v-for="(hakAkses, idx) in item.hakAksesList"
:key="idx"
location="top"
>
<template v-slot:activator="{ props }">
<v-chip
v-bind="props"
:color="hakAkses.isGroupBased ? 'primary' : 'grey'"
size="small"
variant="tonal"
class="mb-1"
>
<v-icon start size="14">{{ hakAkses.isGroupBased ? 'mdi-account-group' : 'mdi-account' }}</v-icon>
{{ hakAkses.group }} / {{ hakAkses.role }}
</v-chip>
</template>
<span>{{ hakAkses.groupPath || hakAkses.group }} - {{ hakAkses.role }}</span>
</v-tooltip>
<span v-if="item.hakAksesList.length === 0" class="text-grey">-</span>
</div>
</template>
<template v-slot:bottom>
<v-row class="ma-2">
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
{{ showingEntriesText }}
</v-col>
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
<v-pagination
v-model="page"
:length="pageCount"
rounded="circle"
:total-visible="5"
></v-pagination>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card class="pa-6 rounded-xl elevation-4">
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" rounded="lg" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red-darken-1" variant="text" rounded="lg" @click="confirmDelete">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
location="top"
>
{{ snackbar.message }}
<template v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false">
Tutup
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import EditHakAkses from '@/components/HakAkses/editHakAkses.vue';
import { useNavItemsStore } from '~/stores/navItems1';
definePageMeta({
middleware: ['auth']
})
interface HakAksesMenu {
name: string;
canAccess: boolean;
canView: boolean;
canAdd: boolean;
canEdit: boolean;
canDelete: boolean;
}
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
interface HakAksesData {
id: number;
userId?: string; // Optional: untuk backward compatibility dan tracking
namaLengkap?: string;
namaUser?: string;
tipeUser?: string;
role: string;
group: string;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
// New: untuk group-based access
isGroupBased?: boolean; // Flag untuk membedakan group-based vs individual
groupPath?: string; // Full group path untuk matching
}
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 SessionData {
roles: string[];
groups: string[];
}
// State for views
const viewMode = ref<'table' | 'add' | 'editName' | 'editAccess' | 'view'>('table');
const allHakAksesData = useLocalStorage<HakAksesData[]>('allHakAksesData', []);
const navItemsStore = useNavItemsStore();
// Data table headers
// Changed to show user with their multiple access rights
const headers = ref([
{ title: 'No', key: 'id' as const, align: 'center' as const },
{ title: 'User ID', key: 'userId' as const, sortable: true, align: 'center' as const },
{ title: 'Nama Lengkap', key: 'namaLengkap' as const, sortable: true },
{ title: 'Nama User', key: 'namaUser' as const, sortable: true },
{ title: 'Tipe User', key: 'tipeUser' as const, sortable: true },
{ title: 'Hak Akses', key: 'hakAksesList' as const, sortable: false },
{ title: 'Aksi', align: 'center' as const, key: 'actions' as const, sortable: false },
]);
// Breadcrumbs
const breadcrumbs = computed(() => {
const baseCrumbs = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Hak Akses', disabled: false, href: '/setting/hakakses' },
];
if (viewMode.value === 'add') {
return [...baseCrumbs, { title: 'Tambah Hak Akses', disabled: true, href: '/setting/tambahhakakses' }];
} else if (viewMode.value === 'editName') {
return [...baseCrumbs, { title: 'Edit Nama Tipe User', disabled: true, href: '/setting/editnamahakakses' }];
} else if (viewMode.value === 'editAccess') {
return [...baseCrumbs, { title: 'Edit Hak Akses', disabled: true, href: '/setting/edithakakses' }];
} else if (viewMode.value === 'view') {
return [...baseCrumbs, { title: 'Detail Hak Akses', disabled: true, href: '/setting/viewhakakses' }];
}
return baseCrumbs;
});
// Table and pagination state
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
const editedIndex = ref(-1);
const editedItem = ref<HakAksesData>({
id: 0,
role: '',
group: '',
namaTipeUser: '',
hakAksesMenu: [],
});
// Available roles and groups from UserLogin data
const availableRoles = ref<string[]>([]);
const availableGroups = ref<string[]>([]);
const availableUsers = ref<any[]>([]);
const availableTipeUsers = ref<string[]>([]);
const selectedUserId = ref<string | null>(null);
const selectedTipeUser = ref<string | null>(null);
const selectedGroup = ref<string | null>(null);
const selectedGroupUsers = ref<any[]>([]);
const isFetchingPermissions = ref(false);
const fetchedBackendData = ref<BackendPermissionItem[]>([]);
// Group paths dengan label yang lebih user-friendly
const availableGroupPaths = computed(() => {
const groupMap = new Map<string, { value: string; label: string; path: string }>();
availableUsers.value.forEach((user) => {
if (Array.isArray(user.groups)) {
user.groups.forEach((groupPath: string) => {
if (groupPath && !groupMap.has(groupPath)) {
const parts = groupPath.split('/').filter(Boolean);
const label = parts.length > 1 ? parts[1] : parts[0] || groupPath;
groupMap.set(groupPath, {
value: groupPath,
label: label,
path: groupPath,
});
}
});
}
});
return Array.from(groupMap.values()).sort((a, b) => a.label.localeCompare(b.label));
});
// --- Menu management logic ---
const buildMenuTemplate = (items: NavItem[]): HakAksesMenu[] => {
const result: HakAksesMenu[] = [];
const walk = (list: NavItem[]) => {
list.forEach((item) => {
result.push({
name: item.name,
canAccess: false,
canView: false,
canAdd: false,
canEdit: false,
canDelete: false,
});
if (item.children?.length) {
walk(item.children);
}
});
};
walk(navItemsStore.navItems);
return result;
};
const mergePermissions = (base: HakAksesMenu[], existing: HakAksesMenu[]) => {
return base.map((menu) => {
const matched = existing.find((item) => item.name === menu.name);
return matched ? { ...menu, ...matched } : menu;
});
};
// Computed properties
const pageCount = computed(() => {
return Math.ceil(allHakAksesData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allHakAksesData.value.length);
const total = allHakAksesData.value.length;
return `Showing ${start} to ${end} of ${total} entries`;
});
// Filter state
const filterTipeUser = ref<string | null>(null);
const filterRole = ref<string | null>(null);
const filterGroup = ref<string | null>(null);
// Group hak akses data by user untuk menampilkan multiple hak akses per user
const groupedHakAksesByUser = computed(() => {
const userMap = new Map<string, {
userId: string;
namaLengkap: string;
namaUser: string;
tipeUser: string;
hakAksesList: HakAksesData[];
}>();
allHakAksesData.value.forEach((item) => {
// Untuk group-based, kita perlu mencari user yang memiliki group tersebut
if (item.isGroupBased && item.groupPath) {
// Find users with this group
const usersWithGroup = availableUsers.value.filter((u: any) =>
Array.isArray(u.groups) && u.groups.includes(item.groupPath)
);
usersWithGroup.forEach((user: any) => {
const key = user.id;
if (!userMap.has(key)) {
userMap.set(key, {
userId: user.id,
namaLengkap: user.namaLengkap || '',
namaUser: user.namaUser || '',
tipeUser: user.tipeUser || '',
hakAksesList: [],
});
}
const userData = userMap.get(key)!;
userData.hakAksesList.push(item);
});
} else if (item.userId) {
// Legacy: individual user access
const key = item.userId;
if (!userMap.has(key)) {
userMap.set(key, {
userId: item.userId,
namaLengkap: item.namaLengkap || '',
namaUser: item.namaUser || '',
tipeUser: item.tipeUser || '',
hakAksesList: [],
});
}
const userData = userMap.get(key)!;
userData.hakAksesList.push(item);
}
});
return Array.from(userMap.values()).map((userData, index) => ({
id: index + 1,
...userData,
}));
});
// Data tabel setelah difilter oleh dropdown (menggunakan grouped data)
const filteredHakAksesData = computed(() =>
groupedHakAksesByUser.value.filter((item) => {
const matchTipeUser = !filterTipeUser.value || (item.tipeUser || '') === filterTipeUser.value;
const matchRole = !filterRole.value ||
item.hakAksesList.some(ha => ha.role === filterRole.value);
const matchGroup = !filterGroup.value ||
item.hakAksesList.some(ha => ha.group === filterGroup.value || ha.groupPath === filterGroup.value);
return matchTipeUser && matchRole && matchGroup;
}),
);
// Generate display User ID dengan format US-001, US-002, dll
// Berdasarkan urutan di filteredHakAksesData (bukan user ID asli)
const generateDisplayUserId = (item: any): string => {
if (!item || !item.userId) {
return 'US-000';
}
// Cari index di filteredHakAksesData
const index = filteredHakAksesData.value.findIndex(
(i) => i.userId === item.userId
);
// Jika tidak ditemukan, cari di groupedHakAksesByUser
const finalIndex = index >= 0
? index
: groupedHakAksesByUser.value.findIndex((i) => i.userId === item.userId);
const number = finalIndex >= 0 ? finalIndex + 1 : 1;
return `US-${String(number).padStart(3, '0')}`;
};
const formTitle = computed(() => {
if (viewMode.value === 'editName') return 'Edit Nama Tipe User';
if (viewMode.value === 'editAccess') return 'Edit Hak Akses';
if (viewMode.value === 'view') return 'Detail Hak Akses';
return 'Tambah Hak Akses';
});
// Delete dialog state
const showDeleteDialog = ref(false);
// Re-indexes IDs to be sequential
const reindexData = () => {
allHakAksesData.value.forEach((item, index) => {
item.id = index + 1;
});
};
// Dialog state
const showAddEditDialog = computed({
get: () => viewMode.value === 'add' || viewMode.value === 'editName',
set: (val: boolean) => {
if (!val) {
viewMode.value = 'table';
resetForm();
}
}
});
const showEditAccessDialog = computed({
get: () => viewMode.value === 'editAccess',
set: (val: boolean) => {
if (!val) {
viewMode.value = 'table';
resetForm();
}
}
});
const showViewDialog = computed({
get: () => viewMode.value === 'view',
set: (val: boolean) => {
if (!val) {
viewMode.value = 'table';
resetForm();
}
}
});
// Functions for actions
const showAddForm = () => {
resetForm();
viewMode.value = 'add';
};
const viewModeHakAksesList = ref<HakAksesData[]>([]);
const viewItem = (item: any) => {
// Item is from groupedHakAksesByUser
// For view, we'll show all hak akses for this user
// Store the list of hak akses for display
viewModeHakAksesList.value = item.hakAksesList || [];
if (item.hakAksesList && item.hakAksesList.length > 0) {
// Merge all hak akses menus (OR logic - if any access allows, then allow)
const baseMenuItems = buildMenuTemplate(navItemsStore.navItems);
const mergedMenu: HakAksesMenu[] = baseMenuItems.map(menu => {
let canAccess = false;
let canView = false;
let canAdd = false;
let canEdit = false;
let canDelete = false;
item.hakAksesList.forEach((ha: HakAksesData) => {
const haMenu = ha.hakAksesMenu.find(m => m.name === menu.name);
if (haMenu) {
canAccess = canAccess || haMenu.canAccess;
canView = canView || haMenu.canView;
canAdd = canAdd || haMenu.canAdd;
canEdit = canEdit || haMenu.canEdit;
canDelete = canDelete || haMenu.canDelete;
}
});
return {
name: menu.name,
canAccess,
canView,
canAdd,
canEdit,
canDelete,
};
});
editedItem.value = {
id: item.id,
userId: item.userId,
namaLengkap: item.namaLengkap,
namaUser: item.namaUser,
tipeUser: item.tipeUser,
role: '', // Not used in view mode
group: '', // Not used in view mode
namaTipeUser: item.hakAksesList[0]?.namaTipeUser || '',
hakAksesMenu: mergedMenu,
};
} else {
editedItem.value = {
id: item.id,
userId: item.userId,
namaLengkap: item.namaLengkap,
namaUser: item.namaUser,
tipeUser: item.tipeUser,
role: '',
group: '',
namaTipeUser: '',
hakAksesMenu: [],
};
}
viewMode.value = 'view';
};
const editItem = (item: any) => {
// Item is now from groupedHakAksesByUser, so we need to edit the first hak akses or create new
// For now, we'll edit the group-based access if it exists
if (item.hakAksesList && item.hakAksesList.length > 0) {
// Find the group-based access for this user
const groupBasedAccess = item.hakAksesList.find((ha: HakAksesData) => ha.isGroupBased);
if (groupBasedAccess) {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === groupBasedAccess.id);
editedItem.value = { ...groupBasedAccess };
selectedGroup.value = groupBasedAccess.groupPath || null;
onGroupSelected(selectedGroup.value);
viewMode.value = 'editName';
return;
}
}
// Fallback: create new access
resetForm();
viewMode.value = 'add';
};
const editAccess = (item: any) => {
// Item is from groupedHakAksesByUser
// For edit access, we'll edit all group-based accesses for this user
// For simplicity, we'll edit the first one or create new
if (item.hakAksesList && item.hakAksesList.length > 0) {
const groupBasedAccess = item.hakAksesList.find((ha: HakAksesData) => ha.isGroupBased);
if (groupBasedAccess) {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === groupBasedAccess.id);
const baseMenuItems = buildMenuTemplate(navItemsStore.navItems);
const mergedMenuItems = mergePermissions(baseMenuItems, groupBasedAccess.hakAksesMenu);
editedItem.value = {
...groupBasedAccess,
hakAksesMenu: mergedMenuItems
};
selectedGroup.value = groupBasedAccess.groupPath || null;
onGroupSelected(selectedGroup.value);
viewMode.value = 'editAccess';
return;
}
}
// Fallback
resetForm();
viewMode.value = 'add';
};
const deleteItem = (item: any) => {
// Item is from groupedHakAksesByUser
// We need to delete all group-based accesses for this user
// For now, we'll delete the first group-based access
if (item.hakAksesList && item.hakAksesList.length > 0) {
const groupBasedAccess = item.hakAksesList.find((ha: HakAksesData) => ha.isGroupBased);
if (groupBasedAccess) {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === groupBasedAccess.id);
showDeleteDialog.value = true;
return;
}
}
// Fallback
editedIndex.value = -1;
showDeleteDialog.value = false;
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
editedIndex.value = -1;
};
const confirmDelete = () => {
if (editedIndex.value > -1) {
allHakAksesData.value.splice(editedIndex.value, 1);
reindexData();
}
closeDeleteDialog();
};
const cancelForm = () => {
viewMode.value = 'table';
resetForm();
};
const updateItemAccess = (updatedItem: HakAksesData & { backendPermissions?: BackendPermissionItem[] }) => {
if (editedIndex.value > -1) {
const itemToUpdate = allHakAksesData.value[editedIndex.value];
// If backend permissions are provided, we can store them or map them to menu structure
if (updatedItem.backendPermissions && updatedItem.backendPermissions.length > 0) {
// Store backend permissions for reference
(itemToUpdate as any).backendPermissions = updatedItem.backendPermissions;
// Also update the menu structure if needed
if (updatedItem.hakAksesMenu) {
itemToUpdate.hakAksesMenu = updatedItem.hakAksesMenu;
}
} else {
// Use existing menu structure update
Object.assign(itemToUpdate, updatedItem);
}
snackbar.value = {
show: true,
message: 'Hak akses berhasil diperbarui!',
color: 'success',
timeout: 3000,
};
}
cancelForm();
};
const resetForm = () => {
editedItem.value = {
id: 0,
role: '',
group: '',
groupPath: '',
namaTipeUser: '',
hakAksesMenu: buildMenuTemplate(navItemsStore.navItems),
isGroupBased: true,
};
selectedUserId.value = null;
selectedTipeUser.value = null;
selectedGroup.value = null;
selectedGroupUsers.value = [];
fetchedBackendData.value = [];
viewModeHakAksesList.value = [];
};
// Check if a permission is mapped to a menu
const getMappingStatus = (pagename: string): boolean => {
if (!editedItem.value.hakAksesMenu || editedItem.value.hakAksesMenu.length === 0) {
return false;
}
return editedItem.value.hakAksesMenu.some(menu =>
menu.name.toLowerCase() === pagename.toLowerCase() ||
menu.name.toLowerCase().includes(pagename.toLowerCase()) ||
pagename.toLowerCase().includes(menu.name.toLowerCase())
);
};
// Handle group selection
const onGroupSelected = (groupPath: string | null) => {
if (!groupPath) {
selectedGroupUsers.value = [];
editedItem.value.group = '';
editedItem.value.groupPath = '';
return;
}
// Extract group name from path
const parts = groupPath.split('/').filter(Boolean);
editedItem.value.group = parts.length > 1 ? parts[1] : parts[0] || groupPath;
editedItem.value.groupPath = groupPath;
// Find all users with this group
selectedGroupUsers.value = availableUsers.value.filter((u: any) =>
Array.isArray(u.groups) && u.groups.includes(groupPath)
);
console.log(`📋 Selected group: ${groupPath}, found ${selectedGroupUsers.value.length} users`);
};
// Snackbar for notifications
const snackbar = ref({
show: false,
message: '',
color: 'success',
timeout: 3000,
});
// Fetch permissions from backend API
const fetchPermissionsFromBackend = async () => {
if (!editedItem.value.role || !editedItem.value.group) {
snackbar.value = {
show: true,
message: 'Role dan Group harus diisi terlebih dahulu!',
color: 'warning',
timeout: 3000,
};
return;
}
isFetchingPermissions.value = true;
try {
console.log('🔄 Fetching permissions from backend...');
console.log('📋 Request params:', {
roles: editedItem.value.role,
groups: editedItem.value.group,
});
const response = await $fetch<any>('/api/permission', {
query: {
roles: editedItem.value.role,
groups: editedItem.value.group,
},
});
console.log('📦 Full response:', response);
// Map backend permissions to our menu structure
// Backend response structure: { message, data: [...], meta: {...} }
if (response && (response as any).data && Array.isArray((response as any).data)) {
const backendPermissions = (response as any).data as BackendPermissionItem[];
console.log(`✅ Received ${backendPermissions.length} permissions from backend`);
console.log('📄 Backend permissions:', backendPermissions);
// Store fetched data for display
fetchedBackendData.value = backendPermissions;
const menuTemplate = buildMenuTemplate(navItemsStore.navItems);
console.log(`📋 Menu template has ${menuTemplate.length} items`);
// Map backend permissions to menu items
const mappedPermissions = menuTemplate.map((menu) => {
// Find matching permission from backend (by pagename or menu name)
const backendPerm = backendPermissions.find((perm: BackendPermissionItem) =>
perm.pagename?.toLowerCase() === menu.name.toLowerCase() ||
perm.pagename?.toLowerCase().includes(menu.name.toLowerCase()) ||
menu.name.toLowerCase().includes(perm.pagename?.toLowerCase() || '')
);
if (backendPerm) {
console.log(`✅ Matched menu "${menu.name}" with permission "${backendPerm.pagename}"`);
return {
name: menu.name,
canAccess: backendPerm.active || backendPerm.read || false,
canView: backendPerm.read || false,
canAdd: backendPerm.create || false,
canEdit: backendPerm.update || false,
canDelete: backendPerm.delete || false,
};
} else {
console.log(`⚠️ No match found for menu "${menu.name}"`);
}
return menu;
});
editedItem.value.hakAksesMenu = mappedPermissions;
const matchedCount = mappedPermissions.filter(m =>
backendPermissions.some(p =>
p.pagename?.toLowerCase() === m.name.toLowerCase() ||
p.pagename?.toLowerCase().includes(m.name.toLowerCase()) ||
m.name.toLowerCase().includes(p.pagename?.toLowerCase() || '')
)
).length;
snackbar.value = {
show: true,
message: `Berhasil mengambil ${backendPermissions.length} permissions dari backend. ${matchedCount} menu berhasil di-mapping.`,
color: 'success',
timeout: 5000,
};
} else {
console.warn('⚠️ Response does not have expected structure:', response);
fetchedBackendData.value = [];
snackbar.value = {
show: true,
message: 'Response dari backend tidak memiliki struktur yang diharapkan',
color: 'warning',
timeout: 3000,
};
}
} catch (error: any) {
console.error('❌ Error fetching permissions from backend:', error);
snackbar.value = {
show: true,
message: `Gagal mengambil data dari backend: ${error.message || 'Unknown error'}`,
color: 'error',
timeout: 5000,
};
} finally {
isFetchingPermissions.value = false;
}
};
// Load available roles and groups from UserLogin data
const loadAvailableRolesAndGroups = async () => {
try {
const users = await $fetch('/api/users/list').catch(() => []);
const rolesSet = new Set<string>();
const groupsSet = new Set<string>();
// Store users for selection
availableUsers.value = (users as any[]).map((u: any) => ({
id: u.id,
namaLengkap: u.namaLengkap || '',
namaUser: u.namaUser || '',
tipeUser: u.tipeUser || '',
roles: u.roles || [],
realmRoles: u.realmRoles || [],
groups: u.groups || [],
}));
(users as any[]).forEach((u: any) => {
if (Array.isArray(u.roles)) {
u.roles.forEach((r: string) => rolesSet.add(r));
}
if (Array.isArray(u.realmRoles)) {
u.realmRoles.forEach((r: string) => rolesSet.add(r));
}
if (Array.isArray(u.groups)) {
u.groups.forEach((g: string) => {
// Extract group name from path (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groupsSet.add(parts[1]); // Get second part as group name for filter
} else if (parts.length === 1) {
groupsSet.add(parts[0]);
}
});
}
});
availableRoles.value = Array.from(rolesSet).sort();
availableGroups.value = Array.from(groupsSet).sort();
// Extract unique tipe users
const tipeUsersSet = new Set<string>();
(users as any[]).forEach((u: any) => {
if (u.tipeUser) {
tipeUsersSet.add(u.tipeUser);
}
});
availableTipeUsers.value = Array.from(tipeUsersSet).sort();
console.log(`✅ Loaded ${availableUsers.value.length} users, ${availableRoles.value.length} roles, ${availableGroups.value.length} groups`);
} catch (error) {
console.error('Error loading roles and groups:', error);
}
};
const saveItem = async () => {
// Validate required fields
if (!editedItem.value.role || !selectedGroup.value) {
snackbar.value = {
show: true,
message: 'Role dan Group wajib diisi!',
color: 'warning',
timeout: 3000,
};
return;
}
// Check if permissions have been fetched from backend
// If not, show warning and ask user to fetch first
const hasFetchedPermissions = fetchedBackendData.value.length > 0 ||
(editedItem.value.hakAksesMenu &&
editedItem.value.hakAksesMenu.length > 0 &&
editedItem.value.hakAksesMenu.some(m => m.canAccess || m.canView || m.canAdd || m.canEdit || m.canDelete));
if (!hasFetchedPermissions) {
snackbar.value = {
show: true,
message: 'Silakan klik tombol "Ambil Data dari Backend API" terlebih dahulu untuk mengambil permissions!',
color: 'warning',
timeout: 5000,
};
return;
}
// Ensure hakAksesMenu exists (fallback to empty template if somehow missing)
if (!editedItem.value.hakAksesMenu || editedItem.value.hakAksesMenu.length === 0) {
editedItem.value.hakAksesMenu = buildMenuTemplate(navItemsStore.navItems);
}
// Check if this group+role combination already exists
const existingIndex = allHakAksesData.value.findIndex(
item => item.isGroupBased &&
item.groupPath === editedItem.value.groupPath &&
item.role === editedItem.value.role
);
if (editedIndex.value > -1 || existingIndex > -1) {
// Edit existing group-based access
const indexToUpdate = editedIndex.value > -1 ? editedIndex.value : existingIndex;
const existingItem = allHakAksesData.value[indexToUpdate];
// Update the group-based access entry
allHakAksesData.value[indexToUpdate] = {
...existingItem,
role: editedItem.value.role,
group: editedItem.value.group,
groupPath: editedItem.value.groupPath,
namaTipeUser: editedItem.value.namaTipeUser || '',
hakAksesMenu: editedItem.value.hakAksesMenu || [],
isGroupBased: true,
};
console.log('✅ Group-based access updated at index:', indexToUpdate);
snackbar.value = {
show: true,
message: `Hak akses untuk group "${editedItem.value.group}" berhasil diperbarui! ${selectedGroupUsers.value.length} user terpengaruh.`,
color: 'success',
timeout: 4000,
};
} else {
// Create new group-based access entry
const newId = allHakAksesData.value.length > 0
? Math.max(...allHakAksesData.value.map(i => i.id)) + 1
: 1;
const groupBasedAccess: HakAksesData = {
id: newId,
role: editedItem.value.role,
group: editedItem.value.group,
groupPath: editedItem.value.groupPath || selectedGroup.value || '',
namaTipeUser: editedItem.value.namaTipeUser || '',
hakAksesMenu: editedItem.value.hakAksesMenu || [],
isGroupBased: true,
};
allHakAksesData.value.push(groupBasedAccess);
console.log('✅ New group-based access added with ID:', newId);
console.log(`📋 Affected users: ${selectedGroupUsers.value.length}`);
snackbar.value = {
show: true,
message: `Hak akses untuk group "${editedItem.value.group}" berhasil ditambahkan! ${selectedGroupUsers.value.length} user mendapatkan hak akses ini.`,
color: 'success',
timeout: 4000,
};
}
// Force update localStorage by triggering reactivity
await nextTick();
allHakAksesData.value = [...allHakAksesData.value];
// Double-check localStorage is updated
await nextTick();
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('allHakAksesData');
console.log('💾 localStorage after save:', stored ? JSON.parse(stored).length + ' items' : 'empty');
}
console.log('💾 Final allHakAksesData:', allHakAksesData.value);
console.log('💾 Final allHakAksesData length:', allHakAksesData.value.length);
// Reset editedIndex after save
editedIndex.value = -1;
cancelForm();
};
// Handle initial data load and ID re-indexing
onMounted(async () => {
console.log('🚀 HakAkses page mounted');
console.log('📊 Initial allHakAksesData:', allHakAksesData.value);
console.log('📊 Initial allHakAksesData length:', allHakAksesData.value.length);
// Only reindex if data exists and IDs are not sequential
if (allHakAksesData.value.length > 0) {
const needsReindex = allHakAksesData.value.some((item, index) => item.id !== index + 1);
if (needsReindex) {
console.log('🔄 Re-indexing data...');
reindexData();
// Force save after reindex
allHakAksesData.value = [...allHakAksesData.value];
}
}
await loadAvailableRolesAndGroups();
console.log('✅ After load - allHakAksesData length:', allHakAksesData.value.length);
console.log('✅ After load - allHakAksesData:', allHakAksesData.value);
// Debug: Check localStorage directly
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('allHakAksesData');
console.log('💾 localStorage value:', stored);
if (stored) {
try {
const parsed = JSON.parse(stored);
console.log('📦 Parsed localStorage data:', parsed);
console.log('📦 Parsed data length:', parsed.length);
} catch (e) {
console.error('❌ Error parsing localStorage:', e);
}
} else {
console.log('⚠️ No data in localStorage');
}
}
});
</script>
<style scoped lang="scss">
// Colors from Design System
$neutral-900: #212121;
$neutral-800: #4D4D4D;
$neutral-700: #717171;
$neutral-600: #89939E;
$neutral-500: #ABBED1;
$neutral-400: #E5F7FA;
$neutral-300: #F5F7FA;
$neutral-100: #FFFFFF;
$success-700: #1B6E53;
$success-600: #009262;
$success-400: #32C997;
$success-300: #84DFC1;
$success-200: #F1FBF8;
$danger-600: #E02B1D;
// Font Family & Weights
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
// Apply font family
* {
font-family: $font-family-base;
}
// Ensure page container doesn't shrink
.hak-akses-page,
.hak-akses-page * {
box-sizing: border-box;
}
.hak-akses-page {
font-family: $font-family-base;
background-color: $neutral-300;
min-height: 100vh;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
padding: 16px !important;
box-sizing: border-box;
margin: 0 !important;
padding-left: 16px !important;
padding-right: 16px !important;
display: block !important;
position: relative !important;
}
// Ensure no max-width constraints and white background
.hak-akses-page :deep(.v-card) {
max-width: 100% !important;
width: 100% !important;
min-width: 100% !important;
background-color: $neutral-100 !important;
border-radius: 12px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
margin: 0 !important;
}
// Override v-main constraints from layout
:deep(.v-main) {
padding: 0 !important;
width: 100% !important;
max-width: 100% !important;
}
:deep(.v-main__wrap) {
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
// Override any container constraints
:deep(.v-container),
:deep(.container) {
max-width: 100% !important;
width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
// Ensure v-card-text has white background and proper padding
.hak-akses-page :deep(.v-card-text) {
padding: 24px !important;
background-color: $neutral-100 !important;
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
// Ensure wrapper div doesn't shrink
.hak-akses-page > div {
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
display: block !important;
}
// Ensure v-card wrapper doesn't shrink
.hak-akses-page > div > .v-card {
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
display: block !important;
}
// Ensure all child elements don't constrain width
.hak-akses-page > div > * {
max-width: 100% !important;
}
// ============================================
// PAGE HEADER
// ============================================
.page-header {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(0, 146, 98, 0.2);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: $neutral-100;
}
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 36px;
line-height: 44px;
font-weight: $font-weight-semibold;
margin: 0;
color: $neutral-100;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
color: $neutral-100;
}
.header-stats {
display: flex;
align-items: center;
gap: 8px;
}
.stat-chip {
color: $success-600 !important;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.add-btn {
font-weight: $font-weight-semibold;
text-transform: none;
letter-spacing: 0.5px;
font-size: 16px;
line-height: 24px;
color: $success-600 !important;
}
// ============================================
// DATA TABLE
// ============================================
.custom-table :deep(table) {
border-collapse: collapse;
}
.custom-table :deep(th) {
background-color: #f5f5f5 !important;
font-weight: bold;
font-size: 0.875rem; /* text-sm */
}
.custom-table :deep(td) {
padding-top: 12px !important;
padding-bottom: 12px !important;
}
.v-btn--icon {
border-radius: 8px;
}
.v-select :deep(.v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
// Ensure search field stays large
.hak-akses-page :deep(.v-text-field[label="Search"]) {
min-width: 300px !important;
width: 100% !important;
max-width: 400px !important;
}
.hak-akses-page :deep(.v-text-field[label="Search"] .v-field) {
min-width: 300px !important;
}
.handle {
cursor: grab;
}
// ============================================
// MODERN DIALOG STYLING
// ============================================
// Ensure overlay doesn't hide content completely
:deep(.v-overlay__scrim) {
background-color: rgba(0, 0, 0, 0.4) !important;
}
.modern-dialog-card {
border-radius: 16px !important;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important;
}
.dialog-header {
padding: 32px;
color: white;
position: relative;
overflow: hidden;
}
.dialog-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
pointer-events: none;
}
.dialog-header-content {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 1;
}
.dialog-header-left {
display: flex;
align-items: center;
gap: 20px;
}
.dialog-avatar {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.dialog-header-text {
flex: 1;
}
.dialog-title {
font-size: 28px;
font-weight: $font-weight-semibold;
margin: 0 0 8px 0;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.dialog-subtitle {
font-size: 14px;
margin: 0;
opacity: 0.9;
color: white;
}
.dialog-close-btn {
color: white !important;
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.dialog-close-btn:hover {
background: rgba(255, 255, 255, 0.3) !important;
transform: rotate(90deg);
}
.header-add {
background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%);
}
.header-edit {
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
}
.header-edit-access {
background: linear-gradient(135deg, #009262 0%, #1B6E53 100%);
}
.header-view {
background: linear-gradient(135deg, #2196f3 0%, #1976d2 100%);
}
.dialog-content {
padding: 32px !important;
background-color: #f5f7fa;
max-height: 70vh;
overflow-y: auto;
}
.dialog-actions {
padding: 20px 32px !important;
background-color: white;
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
// Dialog animation
.dialog-bottom-transition-enter-active,
.dialog-bottom-transition-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.dialog-bottom-transition-enter-from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
.dialog-bottom-transition-leave-to {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
}
.header-stats {
width: 100%;
flex-wrap: wrap;
}
.dialog-header {
padding: 24px;
}
.dialog-title {
font-size: 24px;
}
.dialog-content {
padding: 24px !important;
}
.dialog-actions {
padding: 16px 24px !important;
}
.dialog-header-left {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
}
// Ensure container never shrinks on any screen size
@media (min-width: 0px) {
.hak-akses-page {
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
}
.hak-akses-page :deep(.v-card) {
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
}
}
</style>