feat(FE) : setting access page master
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useKeycloakRoles } from '@/composables/useKeycloakRoles';
|
||||
|
||||
interface Halaman {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
level: number;
|
||||
parent?: number;
|
||||
icon?: string;
|
||||
role: string[];
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Dynamic page title
|
||||
const pageTitle = 'Edit Role Halaman';
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
buttonBack: true,
|
||||
pageTitle: 'Edit Role Halaman',
|
||||
breadcrumbs: [
|
||||
{ text: 'Setting' },
|
||||
{ text: 'Halaman', href: '/setting/halaman' },
|
||||
{ text: 'Edit Role' }
|
||||
]
|
||||
});
|
||||
|
||||
// Form data
|
||||
const form = ref();
|
||||
const valid = ref(true);
|
||||
const halamanData = ref<Halaman | null>(null);
|
||||
const selectedRoles = ref<string[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadingData = ref(false);
|
||||
|
||||
// Get roles from Keycloak
|
||||
const { roles: keycloakRoles, loading: loadingRoles, error: rolesError, fetchRoles, getRoleOptions } = useKeycloakRoles();
|
||||
|
||||
// Available roles (dynamic from Keycloak)
|
||||
const availableRoles = computed(() => getRoleOptions());
|
||||
|
||||
// Snackbar state
|
||||
const snackbar = ref(false);
|
||||
const snackbarMessage = ref('');
|
||||
const snackbarColor = ref('success');
|
||||
|
||||
const showSnackbar = (message: string, color: string = 'success') => {
|
||||
snackbarMessage.value = message;
|
||||
snackbarColor.value = color;
|
||||
snackbar.value = true;
|
||||
};
|
||||
|
||||
// Get parent name
|
||||
const parentName = computed(() => {
|
||||
if (!halamanData.value || halamanData.value.level === 1) return null;
|
||||
|
||||
// We need to load all pages to find parent
|
||||
return 'Header Group';
|
||||
});
|
||||
|
||||
// Load data from API
|
||||
const loadData = async () => {
|
||||
const id = route.query.id;
|
||||
|
||||
if (!id) {
|
||||
showSnackbar('ID halaman tidak ditemukan', 'error');
|
||||
setTimeout(() => {
|
||||
router.push('/setting/halaman');
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
loadingData.value = true;
|
||||
|
||||
try {
|
||||
const response = await $fetch(`/api/halaman/${id}`);
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'data' in response) {
|
||||
const data = response.data as Halaman;
|
||||
halamanData.value = data;
|
||||
selectedRoles.value = [...data.role];
|
||||
} else {
|
||||
const message = response && typeof response === 'object' && 'message' in response && typeof response.message === 'string'
|
||||
? response.message
|
||||
: 'Data halaman tidak ditemukan';
|
||||
showSnackbar(message, 'error');
|
||||
setTimeout(() => {
|
||||
router.push('/setting/halaman');
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading halaman:', error);
|
||||
showSnackbar('Gagal memuat data', 'error');
|
||||
setTimeout(() => {
|
||||
router.push('/setting/halaman');
|
||||
}, 2000);
|
||||
} finally {
|
||||
loadingData.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSimpan = async () => {
|
||||
if (!halamanData.value) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
role: selectedRoles.value
|
||||
};
|
||||
|
||||
const response = await $fetch(`/api/halaman/${halamanData.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: payload
|
||||
});
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success) {
|
||||
const message = 'message' in response && typeof response.message === 'string'
|
||||
? response.message
|
||||
: 'Role berhasil diperbarui';
|
||||
showSnackbar(message, 'success');
|
||||
|
||||
// Redirect to list after 1 second
|
||||
setTimeout(() => {
|
||||
router.push('/setting/halaman');
|
||||
}, 1000);
|
||||
} else {
|
||||
const message = response && typeof response === 'object' && 'message' in response && typeof response.message === 'string'
|
||||
? response.message
|
||||
: 'Gagal menyimpan data';
|
||||
showSnackbar(message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving role:', error);
|
||||
showSnackbar('Terjadi kesalahan saat menyimpan data', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKembali = () => {
|
||||
router.push('/setting/halaman');
|
||||
};
|
||||
|
||||
// Load data on mount
|
||||
onMounted(async () => {
|
||||
// Fetch roles from Keycloak first
|
||||
await fetchRoles();
|
||||
|
||||
// Then load halaman data
|
||||
await loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-form ref="form" v-model="valid" lazy-validation>
|
||||
<v-row>
|
||||
<!-- Loading State -->
|
||||
<v-col v-if="loadingData" cols="12">
|
||||
<v-card elevation="10">
|
||||
<v-card-text class="text-center pa-8">
|
||||
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
|
||||
<p class="text-body-1 mt-4">Memuat data...</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Form Content -->
|
||||
<template v-else-if="halamanData">
|
||||
<!-- Info Halaman Card -->
|
||||
<v-col cols="12">
|
||||
<v-card elevation="10">
|
||||
<v-card-item class="pa-4">
|
||||
<div class="d-flex align-center ga-3">
|
||||
<v-avatar size="48" class="rounded-lg bg-light-primary">
|
||||
<Icon icon="solar:document-text-bold-duotone" class="text-primary" height="24" />
|
||||
</v-avatar>
|
||||
<div>
|
||||
<h5 class="text-h5 font-weight-bold">{{ halamanData.name }}</h5>
|
||||
<p class="text-caption text-medium-emphasis mb-0">{{ halamanData.url }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-item>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<div class="d-flex align-center ga-2 mb-2">
|
||||
<Icon icon="solar:layers-minimalistic-bold-duotone" class="text-primary" height="18" />
|
||||
<span class="text-body-2 font-weight-medium">Level:</span>
|
||||
<v-chip size="small" color="primary" variant="tonal">
|
||||
{{ halamanData.level === 1 ? 'Header Group' : 'Menu Item' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<div class="d-flex align-center ga-2 mb-2">
|
||||
<Icon icon="solar:shield-user-bold-duotone" class="text-secondary" height="18" />
|
||||
<span class="text-body-2 font-weight-medium">Akses Saat Ini:</span>
|
||||
<v-chip
|
||||
v-if="halamanData.role.length === 0"
|
||||
size="small"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
>
|
||||
Belum ada role
|
||||
</v-chip>
|
||||
<template v-else>
|
||||
<v-chip
|
||||
v-for="role in halamanData.role"
|
||||
:key="role"
|
||||
size="small"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ role }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Edit Role Card -->
|
||||
<v-col cols="12">
|
||||
<v-card elevation="10">
|
||||
<v-card-item>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<h5 class="text-h5 font-weight-bold">Atur Akses Role</h5>
|
||||
<v-chip size="small" color="info" variant="tonal">
|
||||
{{ selectedRoles.length }} role dipilih
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-item>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text>
|
||||
<v-alert
|
||||
v-if="rolesError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-body-2">
|
||||
Gagal memuat roles: {{ rolesError }}
|
||||
</span>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-label class="mb-3 font-weight-medium">
|
||||
Pilih Role yang Dapat Mengakses Halaman Ini:
|
||||
</v-label>
|
||||
|
||||
<v-card variant="outlined" class="pa-4">
|
||||
<div v-if="loadingRoles" class="d-flex justify-center align-center pa-4">
|
||||
<v-progress-circular indeterminate color="primary" size="32"></v-progress-circular>
|
||||
<span class="ml-3 text-body-2">Memuat roles...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="availableRoles.length === 0" class="text-center pa-4">
|
||||
<Icon icon="solar:box-minimalistic-bold-duotone" height="48" class="text-disabled mb-2" />
|
||||
<p class="text-body-2 text-disabled mb-0">Tidak ada roles tersedia</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="d-flex flex-column ga-3">
|
||||
<v-checkbox
|
||||
v-for="role in availableRoles"
|
||||
:key="role.value"
|
||||
v-model="selectedRoles"
|
||||
:value="role.value"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
color="primary"
|
||||
>
|
||||
<template #label>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-avatar size="32" class="rounded-md" :color="role.value === 'superadmin' ? 'error' : 'primary'">
|
||||
<Icon
|
||||
:icon="role.value === 'superadmin' ? 'solar:shield-star-bold-duotone' : 'solar:shield-user-bold-duotone'"
|
||||
class="text-white"
|
||||
height="18"
|
||||
/>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<span class="text-body-1 font-weight-medium">{{ role.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<v-col cols="12">
|
||||
<v-card elevation="10">
|
||||
<v-card-text>
|
||||
<div class="d-flex justify-end ga-3">
|
||||
<v-btn
|
||||
color="default"
|
||||
size="large"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-arrow-left"
|
||||
@click="handleKembali"
|
||||
>
|
||||
Kembali
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
prepend-icon="mdi-content-save"
|
||||
@click="handleSimpan"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
>
|
||||
Simpan Perubahan
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-form>
|
||||
|
||||
<!-- Snackbar for notifications -->
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
:color="snackbarColor"
|
||||
:timeout="3000"
|
||||
location="top"
|
||||
>
|
||||
{{ snackbarMessage }}
|
||||
<template #actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="snackbar = false"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
interface Halaman {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
level: number;
|
||||
parent?: number;
|
||||
icon?: string;
|
||||
role: string[];
|
||||
}
|
||||
|
||||
const page = ref({ title: 'Halaman' });
|
||||
const breadcrumbs = ref([
|
||||
{
|
||||
text: 'Setting',
|
||||
disabled: false,
|
||||
href: '#'
|
||||
},
|
||||
{
|
||||
text: 'Halaman',
|
||||
disabled: true,
|
||||
href: '#'
|
||||
}
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const search = ref('');
|
||||
const halamanList = ref<Halaman[]>([]);
|
||||
const loading = ref(false);
|
||||
const expandedPanels = ref<number[]>([]);
|
||||
|
||||
// Snackbar state
|
||||
const snackbar = ref(false);
|
||||
const snackbarMessage = ref('');
|
||||
const snackbarColor = ref('success');
|
||||
|
||||
const showSnackbar = (message: string, color: string = 'success') => {
|
||||
snackbarMessage.value = message;
|
||||
snackbarColor.value = color;
|
||||
snackbar.value = true;
|
||||
};
|
||||
|
||||
// Load data from API
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/halaman');
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'data' in response) {
|
||||
halamanList.value = Array.isArray(response.data) ? response.data : [];
|
||||
|
||||
// Expand all panels by default
|
||||
const level1Count = halamanList.value.filter(h => h.level === 1).length;
|
||||
expandedPanels.value = Array.from({ length: level1Count }, (_, i) => i);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading halaman:', error);
|
||||
showSnackbar('Gagal memuat data', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Group pages by parent (level 1)
|
||||
const groupedPages = computed(() => {
|
||||
const level1 = halamanList.value.filter(h => h.level === 1);
|
||||
const level2 = halamanList.value.filter(h => h.level === 2);
|
||||
|
||||
return level1.map(parent => ({
|
||||
...parent,
|
||||
children: level2.filter(child => child.parent === parent.id)
|
||||
}));
|
||||
});
|
||||
|
||||
// Filter grouped pages based on search
|
||||
const filteredGroupedPages = computed(() => {
|
||||
if (!search.value) return groupedPages.value;
|
||||
|
||||
const searchLower = search.value.toLowerCase();
|
||||
return groupedPages.value
|
||||
.map(group => ({
|
||||
...group,
|
||||
children: group.children.filter(child =>
|
||||
child.name.toLowerCase().includes(searchLower) ||
|
||||
child.url.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}))
|
||||
.filter(group =>
|
||||
group.name.toLowerCase().includes(searchLower) ||
|
||||
group.children.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
const handleEdit = (halaman: Halaman) => {
|
||||
router.push({
|
||||
path: '/setting/halaman/form',
|
||||
query: {
|
||||
id: halaman.id
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Load data on mount
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
pageTitle: 'Halaman',
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card elevation="10">
|
||||
<v-card-text>
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
placeholder="Cari halaman..."
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
style="max-width: 400px;"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="d-flex justify-center align-center pa-8">
|
||||
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<v-expansion-panels
|
||||
v-model="expandedPanels"
|
||||
multiple
|
||||
>
|
||||
<v-expansion-panel
|
||||
v-for="group in filteredGroupedPages"
|
||||
:key="group.id"
|
||||
elevation="0"
|
||||
>
|
||||
<v-expansion-panel-title class="bg-light-primary">
|
||||
<div class="d-flex align-center ga-3">
|
||||
<v-avatar size="32" class="rounded-md bg-primary">
|
||||
<Icon icon="solar:folder-with-files-bold-duotone" class="text-white" height="18" />
|
||||
</v-avatar>
|
||||
<div>
|
||||
<h6 class="text-h6 font-weight-bold">{{ group.name }}</h6>
|
||||
<span class="text-caption text-medium-emphasis">{{ group.children.length }} halaman</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
|
||||
<v-expansion-panel-text>
|
||||
<v-list density="compact" class="pa-0">
|
||||
<v-list-item
|
||||
v-for="(child, idx) in group.children"
|
||||
:key="child.id"
|
||||
class="px-4 py-3"
|
||||
:class="{ 'border-t': idx > 0 }"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="28" class="rounded-md bg-lightsecondary mr-3">
|
||||
<Icon
|
||||
:icon="`solar:${child.icon || 'document-text-bold-duotone'}`"
|
||||
class="text-secondary"
|
||||
height="16"
|
||||
/>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="font-weight-medium">
|
||||
{{ child.name }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption">
|
||||
{{ child.url }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<div v-if="child.role.length > 0" class="d-flex ga-1 mr-2">
|
||||
<v-chip
|
||||
v-for="role in child.role"
|
||||
:key="role"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ role }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-chip
|
||||
v-else
|
||||
size="small"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
>
|
||||
Belum ada role
|
||||
</v-chip>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="handleEdit(child)"
|
||||
>
|
||||
<v-icon size="small">mdi-account-key-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Assign Role</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
<v-alert
|
||||
v-if="filteredGroupedPages.length === 0"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
>
|
||||
Tidak ada data halaman yang ditemukan
|
||||
</v-alert>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Snackbar for notifications -->
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
:color="snackbarColor"
|
||||
:timeout="3000"
|
||||
location="top"
|
||||
>
|
||||
{{ snackbarMessage }}
|
||||
<template #actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="snackbar = false"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
Reference in New Issue
Block a user