first init

This commit is contained in:
ISMAIL MASSERAN
2026-06-08 11:37:14 +08:00
commit 94ecbe5887
1058 changed files with 87732 additions and 0 deletions
+51
View File
@@ -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,
};
}
+83
View File
@@ -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)
})
}
+30
View File
@@ -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,
}
}
+127
View File
@@ -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,
}
}
+84
View File
@@ -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,
}
}