feat : middleware and refresh token
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import api from '@/utils/api';
|
||||
import { useSnackbarStore } from '~/store/snackbar';
|
||||
import type {ModalMode,ParentPageOption} from '~/types/setting/menu';
|
||||
import { useValidation } from '~/composables/useValidation';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
mode?: ModalMode;
|
||||
pageId?: number | null;
|
||||
}>(),
|
||||
{
|
||||
mode: 'create',
|
||||
pageId: null,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'saved']);
|
||||
|
||||
const dialog = ref(props.modelValue);
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
dialog.value = newValue;
|
||||
});
|
||||
|
||||
watch(dialog, (newValue) => {
|
||||
if (!newValue) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
const entryType = ref('main');
|
||||
const labelName = ref('');
|
||||
|
||||
const formRef = ref<any>(null);
|
||||
const valid = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isLoadingDetail = ref(false);
|
||||
|
||||
const snackbarStore = useSnackbarStore();
|
||||
|
||||
const { required, isNumber } = useValidation();
|
||||
|
||||
const parentModule = ref<number | null>(null);
|
||||
const parentPageItems = ref<ParentPageOption[]>([]);
|
||||
const parentPageLoading = ref(false);
|
||||
const parentPageSearch = ref('');
|
||||
const url = ref('');
|
||||
const sortOrder = ref<number | null>(null);
|
||||
const icon = ref('');
|
||||
const active = ref(true);
|
||||
|
||||
const isDetailMode = computed(() => props.mode === 'detail');
|
||||
const isEditMode = computed(() => props.mode === 'edit');
|
||||
const isCreateMode = computed(() => props.mode === 'create');
|
||||
const isFormDisabled = computed(() => isDetailMode.value || isSaving.value || isLoadingDetail.value);
|
||||
|
||||
const titleText = computed(() => {
|
||||
if (isCreateMode.value) return 'Tambah Menu';
|
||||
if (isEditMode.value) return 'Edit Menu';
|
||||
return 'Detail Menu';
|
||||
});
|
||||
|
||||
|
||||
const submitLabel = computed(() => (isEditMode.value ? 'Update' : 'Save'));
|
||||
|
||||
const resetForm = () => {
|
||||
entryType.value = 'main';
|
||||
labelName.value = '';
|
||||
parentModule.value = null;
|
||||
parentPageItems.value = [];
|
||||
parentPageLoading.value = false;
|
||||
parentPageSearch.value = '';
|
||||
url.value = '';
|
||||
sortOrder.value = null;
|
||||
icon.value = '';
|
||||
active.value = true;
|
||||
valid.value = false;
|
||||
formRef.value?.resetValidation?.();
|
||||
};
|
||||
|
||||
const resolveParentLevel = async (parentId: number): Promise<number | null> => {
|
||||
const fromItems = parentPageItems.value.find((p) => p.id === parentId)?.level;
|
||||
if (fromItems != null) return fromItems;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/v1/roles/pages/${parentId}`);
|
||||
const data = response.data?.data;
|
||||
return typeof data?.level === 'number' ? data.level : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchParentPages = async (filterText: string) => {
|
||||
parentPageLoading.value = true;
|
||||
try {
|
||||
const response = await api.get('/api/v1/roles/pages/search', {
|
||||
params: {
|
||||
name: filterText || '',
|
||||
},
|
||||
});
|
||||
const data = response.data?.data;
|
||||
parentPageItems.value = Array.isArray(data) ? data : [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching parent pages:', error);
|
||||
parentPageItems.value = [];
|
||||
} finally {
|
||||
parentPageLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
let parentPageSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
watch(parentPageSearch, (value) => {
|
||||
if (parentPageSearchTimer) clearTimeout(parentPageSearchTimer);
|
||||
parentPageSearchTimer = setTimeout(() => {
|
||||
if (!dialog.value || entryType.value !== 'sub') return;
|
||||
fetchParentPages(value);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
watch([dialog, entryType], ([isOpen, type]) => {
|
||||
if (!isOpen) {
|
||||
parentPageSearch.value = '';
|
||||
parentPageItems.value = [];
|
||||
parentPageLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'sub') {
|
||||
fetchParentPages(parentPageSearch.value);
|
||||
} else {
|
||||
parentModule.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
let lastLoadedKey = '';
|
||||
|
||||
const fetchPageDetail = async () => {
|
||||
if (!props.pageId) return;
|
||||
|
||||
const key = `${props.mode}:${props.pageId}`;
|
||||
if (lastLoadedKey === key) return;
|
||||
lastLoadedKey = key;
|
||||
|
||||
isLoadingDetail.value = true;
|
||||
try {
|
||||
const response = await api.get(`/api/v1/roles/pages/${props.pageId}`);
|
||||
const data = response.data?.data;
|
||||
if (!data) throw new Error('Data menu tidak ditemukan');
|
||||
|
||||
labelName.value = data.name ?? '';
|
||||
icon.value = data.icon ?? '';
|
||||
url.value = data.url ?? '';
|
||||
sortOrder.value = typeof data.sort === 'number' ? data.sort : null;
|
||||
active.value = typeof data.active === 'boolean' ? data.active : true;
|
||||
|
||||
const isSub = data.parent != null;
|
||||
entryType.value = isSub ? 'sub' : 'main';
|
||||
parentModule.value = isSub ? data.parent : null;
|
||||
|
||||
if (isSub) {
|
||||
await fetchParentPages('');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.message || error?.message || 'Gagal memuat detail menu';
|
||||
snackbarStore.showSnackbar(message, 'error');
|
||||
} finally {
|
||||
isLoadingDetail.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
[dialog, () => props.mode, () => props.pageId],
|
||||
async ([isOpen, mode]) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (mode === 'create') {
|
||||
lastLoadedKey = '';
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchPageDetail();
|
||||
},
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
const save = () => {
|
||||
const run = async () => {
|
||||
if (isDetailMode.value) return;
|
||||
if (isSaving.value) return;
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
await formRef.value?.validate?.();
|
||||
if (!valid.value) {
|
||||
snackbarStore.showSnackbar('Lengkapi form terlebih dahulu', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const isSubMenu = entryType.value === 'sub';
|
||||
const parentId = isSubMenu ? parentModule.value : null;
|
||||
let level = 1;
|
||||
|
||||
if (isSubMenu) {
|
||||
if (!parentId) {
|
||||
snackbarStore.showSnackbar('Menu induk tidak ditemukan, silakan pilih ulang', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const parentLevel = await resolveParentLevel(parentId);
|
||||
if (parentLevel == null) {
|
||||
snackbarStore.showSnackbar('Menu induk tidak ditemukan, silakan pilih ulang', 'error');
|
||||
return;
|
||||
}
|
||||
level = parentLevel + 1;
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: labelName.value,
|
||||
icon: icon.value,
|
||||
url: url.value,
|
||||
level,
|
||||
sort: sortOrder.value,
|
||||
active: active.value,
|
||||
};
|
||||
|
||||
if (isCreateMode.value) {
|
||||
if (isSubMenu) payload.parent = parentId;
|
||||
await api.post('/api/v1/roles/pages', payload);
|
||||
snackbarStore.showSnackbar('Menu berhasil ditambahkan', 'success');
|
||||
} else if (isEditMode.value) {
|
||||
if (!props.pageId) throw new Error('ID menu tidak ditemukan');
|
||||
payload.parent = parentId;
|
||||
await api.put(`/api/v1/roles/pages/${props.pageId}`, payload);
|
||||
snackbarStore.showSnackbar('Menu berhasil diperbarui', 'success');
|
||||
}
|
||||
|
||||
emit('saved');
|
||||
close();
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.message || error?.message || 'Gagal menambahkan menu';
|
||||
snackbarStore.showSnackbar(message, 'error');
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit('update:modelValue', false);
|
||||
lastLoadedKey = '';
|
||||
resetForm();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="dialog" max-width="600px" persistent>
|
||||
<v-card>
|
||||
<v-form v-model="valid" ref="formRef" @submit.prevent="save">
|
||||
|
||||
<v-card-title class="d-flex align-center justify-space-between pa-3">
|
||||
<div class="d-flex align-center">
|
||||
<span class="ml-3 text-h6">{{ titleText }}</span>
|
||||
</div>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
@click="close"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text>
|
||||
<div>
|
||||
<label class="font-weight-medium required">Tipe Menu</label>
|
||||
<div class="d-flex gap-2 mt-2 rounded-lg">
|
||||
<v-btn value="main" class="flex-grow-1" @click="entryType = 'main'" color="primary"
|
||||
:readonly="isFormDisabled" :variant="entryType === 'main' ? 'flat' : 'outlined'" height="88" rounded="lg">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<v-icon class="mb-1">mdi-file-document-outline</v-icon>
|
||||
<span>Main Menu</span>
|
||||
</div>
|
||||
</v-btn>
|
||||
<v-btn value="sub" class="flex-grow-1" @click="entryType = 'sub'" color="primary"
|
||||
:readonly="isFormDisabled" :variant="entryType === 'sub' ? 'flat' : 'outlined'" height="88" rounded="lg">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<v-icon class="mb-1">mdi-file-tree-outline</v-icon>
|
||||
<span>Sub-Menu</span>
|
||||
</div>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="font-weight-medium required">Nama Menu</label>
|
||||
<v-text-field v-model="labelName" class="mt-2" variant="outlined" density="compact"
|
||||
:readonly="isFormDisabled" :rules="[required]" />
|
||||
</div>
|
||||
|
||||
<div v-if="entryType === 'sub'">
|
||||
<label class="font-weight-medium required">Menu Induk</label>
|
||||
<v-autocomplete v-model="parentModule" v-model:search="parentPageSearch" class="mt-2"
|
||||
:items="parentPageItems" item-title="name" item-value="id" placeholder="Pilih menu induk"
|
||||
variant="outlined" density="compact" :readonly="isFormDisabled" :loading="parentPageLoading" no-filter clearable
|
||||
:rules="[required]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="font-weight-medium required">URL</label>
|
||||
<v-text-field v-model="url" class="mt-2" placeholder="/path/to/page" variant="outlined"
|
||||
density="compact" :readonly="isFormDisabled" :rules="[required]" />
|
||||
</div>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<label class="font-weight-medium required">Urutan</label>
|
||||
<v-text-field v-model.number="sortOrder" class="mt-2" type="number" placeholder="urutan menu"
|
||||
variant="outlined" density="compact" :readonly="isFormDisabled" :rules="[required, isNumber]" />
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<label class="font-weight-medium required">Menu Icon</label>
|
||||
<v-text-field v-model="icon" class="mt-2" placeholder="icon menu" variant="outlined"
|
||||
density="compact" :readonly="isFormDisabled" :rules="[required]">
|
||||
<template #append-inner>
|
||||
<!-- <v-avatar color="primary" size="32"> -->
|
||||
<Icon v-if="icon" class="text-primary" :icon="'solar:' + icon" width="20" />
|
||||
<!-- </v-avatar> -->
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div>
|
||||
<label class="font-weight-medium required">Status</label>
|
||||
<v-switch v-model="active" class="mt-2" color="primary" inset :readonly="isFormDisabled" hide-details>
|
||||
<template #label>
|
||||
<span>{{ active ? 'Aktif' : 'Nonaktif' }}</span>
|
||||
</template>
|
||||
</v-switch>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn v-if="!isDetailMode" color="primary" type="submit" variant="flat" :loading="isSaving" :disabled="isSaving">{{ submitLabel }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue";
|
||||
|
||||
import type { RolePage } from '~/types/setting/menu';
|
||||
|
||||
const props = defineProps<{
|
||||
page: RolePage;
|
||||
level: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['detail', 'edit', 'delete']);
|
||||
|
||||
const handleDetail = (page: RolePage) => emit('detail', page);
|
||||
const handleEdit = (page: RolePage) => emit('edit', page);
|
||||
const handleDelete = (page: RolePage) => emit('delete', page);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:style="{ marginLeft: (level > 0 ? 40 : 0) + 'px' }"
|
||||
:class="{ 'is-child': level > 0 }"
|
||||
class="page-row-container"
|
||||
>
|
||||
<v-card class="mb-3 page-card" elevation="2" @click="handleDetail(page)">
|
||||
<v-card-text class="pa-5">
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar color="primary" size="40" class="me-3">
|
||||
<Icon :icon="'solar:' + page.icon" width="24" />
|
||||
</v-avatar>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-center">
|
||||
<h6 class="text-subtitle-1 font-weight-semibold">{{ page.name }}</h6>
|
||||
</div>
|
||||
<p class="text-muted mb-0">{{ page.url }}</p>
|
||||
</div>
|
||||
<v-btn icon variant="text" class="ml-2" @click.stop="handleEdit(page)">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon variant="text" class="ml-2" @click.stop="handleDelete(page)">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<div v-if="page.children && page.children.length > 0" class="children-container">
|
||||
<PageRow
|
||||
v-for="child in page.children"
|
||||
:key="child.id"
|
||||
:page="child"
|
||||
:level="level + 1"
|
||||
@detail="handleDetail"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-row-container {
|
||||
position: relative;
|
||||
}
|
||||
.page-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
.children-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.children-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
bottom: 0;
|
||||
left: 20px;
|
||||
width: 3px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.children-container > .page-row-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
left: -20px;
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user