first init
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { computed, ref } from "vue";
|
||||
import type { ApiPagination } from "@/core/types/api";
|
||||
|
||||
function createDefaultPagination(
|
||||
overrides?: Partial<ApiPagination>
|
||||
): ApiPagination {
|
||||
return {
|
||||
current_page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
last_page: 1,
|
||||
from: null,
|
||||
to: null,
|
||||
has_more_pages: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function useApiPagination(initial?: Partial<ApiPagination>) {
|
||||
const pagination = ref<ApiPagination>(createDefaultPagination(initial));
|
||||
|
||||
const tablePagination = computed(() => ({
|
||||
pageIndex: pagination.value.current_page - 1,
|
||||
pageSize: pagination.value.per_page,
|
||||
}));
|
||||
|
||||
function applyPagination(meta: ApiPagination) {
|
||||
pagination.value = meta;
|
||||
}
|
||||
|
||||
function getRangeLabel() {
|
||||
const { from, to, total } = pagination.value;
|
||||
|
||||
if (!total) {
|
||||
return "No results";
|
||||
}
|
||||
|
||||
if (from == null || to == null) {
|
||||
return `${total} row(s)`;
|
||||
}
|
||||
|
||||
return `Showing ${from} to ${to} of ${total}`;
|
||||
}
|
||||
|
||||
return {
|
||||
pagination,
|
||||
tablePagination,
|
||||
applyPagination,
|
||||
getRangeLabel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, type RouteRecordName } from 'vue-router'
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
import mainMenu from '@/main/side-menu'
|
||||
|
||||
type MenuList = (string | Menu)[]
|
||||
|
||||
function routeMatchesMenu(
|
||||
routeName: RouteRecordName | null | undefined,
|
||||
routePath: string,
|
||||
menu: Menu,
|
||||
): boolean {
|
||||
if (!menu.route_name) return false
|
||||
if (menu.route_name === '/') return routePath === '/'
|
||||
return (
|
||||
routeName === menu.route_name ||
|
||||
routePath.includes(String(menu.route_name))
|
||||
)
|
||||
}
|
||||
|
||||
function findTrailInMenuList(
|
||||
menus: MenuList,
|
||||
ancestors: string[],
|
||||
routeName: RouteRecordName | null | undefined,
|
||||
routePath: string,
|
||||
): string[] | null {
|
||||
for (const entry of menus) {
|
||||
if (typeof entry === 'string') continue
|
||||
|
||||
const pathTitles = entry.title ? [...ancestors, entry.title] : ancestors
|
||||
|
||||
if (entry.route_name && routeMatchesMenu(routeName, routePath, entry)) {
|
||||
return pathTitles
|
||||
}
|
||||
|
||||
if (entry.sub_menu) {
|
||||
const found = findTrailInMenuList(
|
||||
entry.sub_menu,
|
||||
pathTitles,
|
||||
routeName,
|
||||
routePath,
|
||||
)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function trailFromRouteMeta(route: ReturnType<typeof useRoute>): string[] {
|
||||
const breadcrumb = route.meta.breadcrumb
|
||||
if (
|
||||
Array.isArray(breadcrumb) &&
|
||||
breadcrumb.length > 0 &&
|
||||
breadcrumb.every((item) => typeof item === 'string')
|
||||
) {
|
||||
return breadcrumb
|
||||
}
|
||||
|
||||
const title = route.meta.title
|
||||
if (typeof title !== 'string' || !title) return []
|
||||
|
||||
const module = route.meta.module
|
||||
if (typeof module === 'string' && module) {
|
||||
const section = module.charAt(0).toUpperCase() + module.slice(1)
|
||||
return [section, title]
|
||||
}
|
||||
|
||||
return [title]
|
||||
}
|
||||
|
||||
export function useBreadcrumb(extraMenus: MenuList[] = []) {
|
||||
const route = useRoute()
|
||||
|
||||
return computed(() => {
|
||||
for (const menu of [mainMenu, ...extraMenus]) {
|
||||
const trail = findTrailInMenuList(menu, [], route.name, route.path)
|
||||
if (trail?.length) return trail
|
||||
}
|
||||
|
||||
return trailFromRouteMeta(route)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export const useQuickSearch = () => {
|
||||
const quickSearchDialogOpen = ref(false)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === 'k') {
|
||||
event.preventDefault()
|
||||
quickSearchDialogOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
quickSearchDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
return {
|
||||
quickSearchDialogOpen,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as combobox from '@zag-js/combobox'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { resolvePostLoginRoute } from '@/modules/auth'
|
||||
|
||||
export function useRoleSwitcher() {
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const switchingRole = ref(false)
|
||||
const selectedRoleId = ref('')
|
||||
const roleComboValue = ref<string[]>([''])
|
||||
|
||||
const roleOptions = ref<{ value: string; label: string }[]>([])
|
||||
|
||||
const availableRoles = computed(() => authStore.roles ?? [])
|
||||
const canSwitchRole = computed(() => availableRoles.value.length > 1)
|
||||
|
||||
function syncRoleSwitcherFromSession() {
|
||||
const roles = availableRoles.value
|
||||
roleOptions.value = roles.map((r) => ({
|
||||
value: r.id,
|
||||
label: r.name,
|
||||
}))
|
||||
|
||||
const activeId = authStore.activeRole?.id ?? ''
|
||||
selectedRoleId.value = activeId
|
||||
roleComboValue.value = activeId ? [activeId] : ['']
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [authStore.user?.id, authStore.activeRole?.id, authStore.roles?.length] as const,
|
||||
() => syncRoleSwitcherFromSession(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const roleCollection = computed(() =>
|
||||
combobox.collection({
|
||||
items: roleOptions.value,
|
||||
itemToValue: (item) => item.value,
|
||||
itemToString: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const onRoleValueChange = async (details: { value: string[] }) => {
|
||||
const nextRoleId = details.value?.[0] ?? ''
|
||||
|
||||
if (!nextRoleId) {
|
||||
roleComboValue.value = authStore.activeRole?.id ? [authStore.activeRole.id] : ['']
|
||||
selectedRoleId.value = authStore.activeRole?.id ?? ''
|
||||
return
|
||||
}
|
||||
|
||||
if (nextRoleId === authStore.activeRole?.id || switchingRole.value || authStore.loading) {
|
||||
roleComboValue.value = [nextRoleId]
|
||||
selectedRoleId.value = nextRoleId
|
||||
return
|
||||
}
|
||||
|
||||
const nextRoleLabel = availableRoles.value.find((r) => r.id === nextRoleId)?.name
|
||||
const currentRoleLabel = authStore.activeRole?.name
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: 'Tukar peranan?',
|
||||
text: `Anda akan bertukar daripada "${currentRoleLabel}" kepada "${nextRoleLabel}".`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Ya, bertukar',
|
||||
cancelButtonText: 'Batal',
|
||||
reverseButtons: true,
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) {
|
||||
roleComboValue.value = authStore.activeRole?.id ? [authStore.activeRole.id] : ['']
|
||||
selectedRoleId.value = authStore.activeRole?.id ?? ''
|
||||
return
|
||||
}
|
||||
|
||||
roleComboValue.value = [nextRoleId]
|
||||
selectedRoleId.value = nextRoleId
|
||||
await onSwitchRole()
|
||||
}
|
||||
|
||||
const onRoleInputValueChange = ({ inputValue }: { inputValue: string }) => {
|
||||
const query = (inputValue ?? '').toLowerCase()
|
||||
const all = availableRoles.value.map((r) => ({
|
||||
value: r.id,
|
||||
label: r.name,
|
||||
}))
|
||||
|
||||
const filtered = all.filter((item) => item.label.toLowerCase().includes(query))
|
||||
roleOptions.value = filtered.length > 0 ? filtered : all
|
||||
}
|
||||
|
||||
const onSwitchRole = async () => {
|
||||
if (!selectedRoleId.value || selectedRoleId.value === authStore.activeRole?.id) return
|
||||
|
||||
switchingRole.value = true
|
||||
try {
|
||||
const res = await authStore.switchRole(selectedRoleId.value)
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: res.message || 'Peranan telah ditukar.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
|
||||
await router.push(resolvePostLoginRoute(res.redirect_path))
|
||||
} finally {
|
||||
switchingRole.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canSwitchRole,
|
||||
roleCollection,
|
||||
roleComboValue,
|
||||
switchingRole,
|
||||
onRoleValueChange,
|
||||
onRoleInputValueChange,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const ENIGMA_ROOT = '.enigma'
|
||||
|
||||
function getLayoutNodes() {
|
||||
const root = document.querySelector(ENIGMA_ROOT)
|
||||
if (!root) return null
|
||||
|
||||
return {
|
||||
sideMenu: root.querySelector<HTMLElement>('.side-menu'),
|
||||
content: root.querySelector<HTMLElement>('.content'),
|
||||
}
|
||||
}
|
||||
|
||||
function isCompactLayout() {
|
||||
return getLayoutNodes()?.sideMenu?.classList.contains('side-menu--collapsed') ?? false
|
||||
}
|
||||
|
||||
function applyCompactLayout(collapsed: boolean) {
|
||||
const nodes = getLayoutNodes()
|
||||
if (!nodes?.sideMenu || !nodes.content) return
|
||||
|
||||
nodes.sideMenu.classList.toggle('side-menu--collapsed', collapsed)
|
||||
nodes.content.classList.toggle('content--compact', collapsed)
|
||||
}
|
||||
|
||||
function persistCompactMenu(collapsed: boolean) {
|
||||
queueMicrotask(() => {
|
||||
localStorage.setItem('compactMenu', collapsed.toString())
|
||||
})
|
||||
}
|
||||
|
||||
export const useSideMenu = () => {
|
||||
const mobileMenuOpen = ref(false)
|
||||
const scrolled = ref(false)
|
||||
|
||||
const toggleCompactMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
const collapsed = !isCompactLayout()
|
||||
applyCompactLayout(collapsed)
|
||||
persistCompactMenu(collapsed)
|
||||
}
|
||||
|
||||
const openMobileMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
mobileMenuOpen.value = true
|
||||
}
|
||||
|
||||
const closeMobileMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
|
||||
const onScrollContent = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
scrolled.value = target.scrollTop > 0
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
if (window.innerWidth <= 1600) {
|
||||
applyCompactLayout(true)
|
||||
persistCompactMenu(true)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyCompactLayout(localStorage.getItem('compactMenu') === 'true')
|
||||
window.addEventListener('resize', onResize)
|
||||
onResize()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
return {
|
||||
mobileMenuOpen,
|
||||
scrolled,
|
||||
toggleCompactMenu,
|
||||
openMobileMenu,
|
||||
closeMobileMenu,
|
||||
onScrollContent,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user