Claude güncellemesi MenuManager güncellemesi
This commit is contained in:
parent
3ecb4e2102
commit
5626c27a90
5 changed files with 1048 additions and 623 deletions
|
|
@ -1,118 +1,89 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MenuItem } from '@/proxy/menus/menu'
|
||||
import { getMenus, MenuService } from '@/services/menu.service'
|
||||
import { useStoreActions } from '@/store/store'
|
||||
import { useState, useEffect } from 'react'
|
||||
import useAuth from './useAuth'
|
||||
import {
|
||||
buildMenuTree,
|
||||
flattenMenuTree,
|
||||
menuTreeSignature,
|
||||
reindexTree,
|
||||
} from '@/views/menu/menuTree.utils'
|
||||
|
||||
const menuService = new MenuService()
|
||||
|
||||
export const useMenuData = () => {
|
||||
const [menuItems, setMenuItems] = useState<MenuItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { getConfig } = useStoreActions((a) => a.abpConfig)
|
||||
const { authenticated } = useAuth()
|
||||
const [baseline, setBaseline] = useState('')
|
||||
|
||||
const buildHierarchy = (items: MenuItem[]): MenuItem[] => {
|
||||
const itemMap = new Map<string, MenuItem>()
|
||||
const rootItems: MenuItem[] = []
|
||||
const { getConfig } = useStoreActions((actions) => actions.abpConfig)
|
||||
|
||||
// Create a map for quick lookup and initialize children arrays
|
||||
items.forEach((item) => {
|
||||
itemMap.set(item.code!!, { ...item, children: [] })
|
||||
})
|
||||
const mountedRef = useRef(true)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// Build the hierarchy
|
||||
items.forEach((item) => {
|
||||
const menuItem = itemMap.get(item.code!!)!
|
||||
|
||||
if (item.parentCode && itemMap.has(item.parentCode)) {
|
||||
const parent = itemMap.get(item.parentCode)!
|
||||
if (!parent.children) {
|
||||
parent.children = []
|
||||
}
|
||||
parent.children.push(menuItem)
|
||||
} else {
|
||||
rootItems.push(menuItem)
|
||||
}
|
||||
})
|
||||
|
||||
// Sort items by order recursively
|
||||
const sortItems = (items: MenuItem[]): MenuItem[] => {
|
||||
return items
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children && item.children.length > 0 ? sortItems(item.children) : [],
|
||||
}))
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return sortItems(rootItems)
|
||||
}
|
||||
const fetchMenuData = useCallback(async () => {
|
||||
const requestId = ++requestIdRef.current
|
||||
const isCurrent = () => mountedRef.current && requestId === requestIdRef.current
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const fetchMenuData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Simulate API call with mock data
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
const response = await getMenus()
|
||||
if (!isCurrent()) return
|
||||
|
||||
if (response.data) {
|
||||
const hierarchicalMenu = buildHierarchy(response.data.items || [])
|
||||
setMenuItems(hierarchicalMenu)
|
||||
}
|
||||
const tree = reindexTree(buildMenuTree(response.data?.items ?? []))
|
||||
setMenuItems(tree)
|
||||
setBaseline(menuTreeSignature(tree))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load menu data')
|
||||
if (isCurrent()) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load menu data')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (isCurrent()) setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveMenuData = async (updatedMenuItems: MenuItem[]) => {
|
||||
const menuService = new MenuService()
|
||||
const saveMenuData = useCallback(
|
||||
async (updatedMenuItems: MenuItem[]) => {
|
||||
const normalized = reindexTree(updatedMenuItems)
|
||||
|
||||
try {
|
||||
// Flatten the hierarchy for API
|
||||
const flatten = (
|
||||
items: MenuItem[],
|
||||
parentCode: string | undefined = undefined,
|
||||
): MenuItem[] => {
|
||||
const result: MenuItem[] = []
|
||||
items.forEach((item, index) => {
|
||||
const flatItem = {
|
||||
...item,
|
||||
parentCode,
|
||||
order: index + 1,
|
||||
children: undefined,
|
||||
}
|
||||
result.push(flatItem)
|
||||
await menuService.updateAll(flattenMenuTree(normalized))
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
result.push(...flatten(item.children, item.code))
|
||||
}
|
||||
})
|
||||
return result
|
||||
if (mountedRef.current) {
|
||||
setMenuItems(normalized)
|
||||
setBaseline(menuTreeSignature(normalized))
|
||||
}
|
||||
const items = flatten(updatedMenuItems)
|
||||
await menuService.updateAll(items)
|
||||
getConfig(false)
|
||||
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
throw new Error(err instanceof Error ? err.message : 'Failed to save menu data')
|
||||
}
|
||||
}
|
||||
// Sol menü / yetki cache'ini tazele
|
||||
getConfig(false)
|
||||
},
|
||||
[getConfig],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchMenuData()
|
||||
}, [])
|
||||
}, [fetchMenuData])
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => menuItems.length > 0 && menuTreeSignature(menuItems) !== baseline,
|
||||
[menuItems, baseline],
|
||||
)
|
||||
|
||||
return {
|
||||
menuItems,
|
||||
setMenuItems,
|
||||
loading,
|
||||
error,
|
||||
isDirty,
|
||||
refetch: fetchMenuData,
|
||||
saveMenuData,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,408 +1,546 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { MenuItem } from '@/proxy/menus/menu'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { MenuService } from '@/services/menu.service'
|
||||
import navigationIcon from '@/proxy/menus/navigation-icon.config'
|
||||
import React, { memo, useMemo, useState } from 'react'
|
||||
import { Field, FieldProps, Form, Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import {
|
||||
FaChevronDown,
|
||||
FaChevronRight,
|
||||
FaExternalLinkAlt,
|
||||
FaIndent,
|
||||
FaLock,
|
||||
FaOutdent,
|
||||
FaPen,
|
||||
FaPlus,
|
||||
FaQuestionCircle,
|
||||
FaTrashAlt,
|
||||
} from 'react-icons/fa'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
FormContainer,
|
||||
FormItem,
|
||||
Input,
|
||||
Switcher,
|
||||
Notification,
|
||||
toast,
|
||||
Select,
|
||||
Switcher,
|
||||
toast,
|
||||
} from '@/components/ui'
|
||||
import { Field, FieldProps, Form, Formik } from 'formik'
|
||||
import { SelectBoxOption } from '@/types/shared'
|
||||
import * as Yup from 'yup'
|
||||
import { FaChevronDown, FaChevronRight, FaQuestionCircle, FaPlus, FaTrashAlt } from 'react-icons/fa'
|
||||
import { ConfirmDialog } from '@/components/shared'
|
||||
import { MenuItem } from '@/proxy/menus/menu'
|
||||
import { MenuDto } from '@/proxy/menus/models'
|
||||
import navigationIcon from '@/proxy/menus/navigation-icon.config'
|
||||
import { MenuService } from '@/services/menu.service'
|
||||
import { SelectBoxOption } from '@/types/shared'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { IconPickerField } from '@/views/shared/MenuAddDialog'
|
||||
|
||||
interface MenuItemComponentProps {
|
||||
const menuService = new MenuService()
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
code: Yup.string().trim().required().matches(/^\S+$/, 'Code cannot contain spaces'),
|
||||
displayName: Yup.string().trim().required(),
|
||||
order: Yup.number().min(0).required(),
|
||||
url: Yup.string().nullable(),
|
||||
icon: Yup.string().nullable(),
|
||||
cssClass: Yup.string().nullable(),
|
||||
requiredPermissionName: Yup.string().nullable(),
|
||||
target: Yup.string().nullable(),
|
||||
elementId: Yup.string().nullable(),
|
||||
isDisabled: Yup.boolean(),
|
||||
})
|
||||
|
||||
const inputClass = 'h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100'
|
||||
const stopPropagation = (event: React.SyntheticEvent) => event.stopPropagation()
|
||||
|
||||
export interface MenuItemComponentProps {
|
||||
item: MenuItem
|
||||
isDesignMode: boolean
|
||||
depth: number
|
||||
children?: React.ReactNode
|
||||
isDragOverlay?: boolean
|
||||
refetch: () => void
|
||||
isDragging?: boolean
|
||||
isHighlighted?: boolean
|
||||
isExpanded?: boolean
|
||||
canIndent?: boolean
|
||||
canOutdent?: boolean
|
||||
permissions: SelectBoxOption[]
|
||||
expansionCommand: { expanded: boolean; version: number }
|
||||
refetch: () => void
|
||||
onToggleExpand?: (id: string) => void
|
||||
onIndent?: (id: string) => void
|
||||
onOutdent?: (id: string) => void
|
||||
setNodeRef?: (node: HTMLElement | null) => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
|
||||
const MenuItemComponentBase: React.FC<MenuItemComponentProps> = ({
|
||||
item,
|
||||
isDesignMode,
|
||||
depth,
|
||||
children,
|
||||
isDragOverlay = false,
|
||||
refetch,
|
||||
isDragging = false,
|
||||
isHighlighted = false,
|
||||
isExpanded = true,
|
||||
canIndent = false,
|
||||
canOutdent = false,
|
||||
permissions,
|
||||
expansionCommand,
|
||||
refetch,
|
||||
onToggleExpand,
|
||||
onIndent,
|
||||
onOutdent,
|
||||
setNodeRef,
|
||||
dragHandleProps,
|
||||
style,
|
||||
}) => {
|
||||
const { translate } = useLocalization()
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: item.id || '',
|
||||
data: {
|
||||
type: 'menu-item',
|
||||
item,
|
||||
},
|
||||
disabled: !isDesignMode,
|
||||
})
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
code: Yup.string().required(),
|
||||
displayName: Yup.string().required(),
|
||||
order: Yup.number().required(),
|
||||
url: Yup.string().nullable(),
|
||||
icon: Yup.string().nullable(),
|
||||
cssClass: Yup.string().nullable(),
|
||||
requiredPermissionName: Yup.string().nullable(),
|
||||
target: Yup.string().nullable(),
|
||||
elementId: Yup.string().nullable(),
|
||||
isDisabled: Yup.boolean(),
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create')
|
||||
const [formData, setFormData] = useState<Partial<MenuDto>>({})
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(expansionCommand.expanded)
|
||||
}, [expansionCommand.expanded, expansionCommand.version])
|
||||
const childCount = item.children?.length ?? 0
|
||||
const hasChildren = childCount > 0
|
||||
const IconComponent = (item.icon && navigationIcon[item.icon]) || FaQuestionCircle
|
||||
const displayLabel = item.displayName ? translate('::' + item.displayName) : (item.code ?? '')
|
||||
|
||||
const getCreateInitialValues = (): Partial<MenuDto> => ({
|
||||
code: '',
|
||||
displayName: '',
|
||||
order: (item.children?.length || 0) + 1,
|
||||
parentCode: item.code,
|
||||
url: '',
|
||||
icon: '',
|
||||
cssClass: '',
|
||||
requiredPermissionName: '',
|
||||
target: '',
|
||||
isDisabled: false,
|
||||
elementId: '',
|
||||
})
|
||||
// Satırda aynı bilgiyi tekrar etmemek için: permission, code ile aynıysa
|
||||
// sadece kilit ikonu gösterilir (tam ad tooltip'te).
|
||||
const permissionLabel =
|
||||
item.requiredPermissionName && item.requiredPermissionName !== item.code
|
||||
? item.requiredPermissionName
|
||||
: ''
|
||||
|
||||
const getEditInitialValues = (): Partial<MenuDto> => ({
|
||||
id: item.id,
|
||||
code: item.code || '',
|
||||
displayName: item.displayName || '',
|
||||
order: item.order,
|
||||
parentCode: item.parentCode || '',
|
||||
url: item.url || '',
|
||||
icon: item.icon || '',
|
||||
cssClass: item.cssClass || '',
|
||||
requiredPermissionName: item.requiredPermissionName || '',
|
||||
target: item.target || '',
|
||||
isDisabled: item.isDisabled ?? false,
|
||||
elementId: item.elementId || '',
|
||||
})
|
||||
const createInitialValues = useMemo<Partial<MenuDto>>(
|
||||
() => ({
|
||||
code: '',
|
||||
displayName: '',
|
||||
order: childCount + 1,
|
||||
parentCode: item.code,
|
||||
url: '',
|
||||
icon: '',
|
||||
cssClass: '',
|
||||
requiredPermissionName: '',
|
||||
target: '',
|
||||
isDisabled: false,
|
||||
elementId: '',
|
||||
}),
|
||||
[childCount, item.code],
|
||||
)
|
||||
|
||||
const [formData, setFormData] = useState<Partial<MenuDto>>(getCreateInitialValues())
|
||||
const editInitialValues = useMemo<Partial<MenuDto>>(
|
||||
() => ({
|
||||
id: item.id,
|
||||
code: item.code ?? '',
|
||||
displayName: item.displayName ?? '',
|
||||
order: item.order,
|
||||
parentCode: item.parentCode ?? '',
|
||||
url: item.url ?? '',
|
||||
icon: item.icon ?? '',
|
||||
cssClass: item.cssClass ?? '',
|
||||
requiredPermissionName: item.requiredPermissionName ?? '',
|
||||
target: item.target ?? '',
|
||||
isDisabled: item.isDisabled ?? false,
|
||||
elementId: item.elementId ?? '',
|
||||
}),
|
||||
[item],
|
||||
)
|
||||
|
||||
const toggleExpanded = (event?: React.MouseEvent) => {
|
||||
const notify = (type: 'success' | 'danger', message: string) =>
|
||||
toast.push(
|
||||
<Notification title={type === 'success' ? translate('::Success') : translate('::Error')} type={type}>
|
||||
{message}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
|
||||
const toggleExpanded = (event?: React.SyntheticEvent) => {
|
||||
event?.stopPropagation()
|
||||
setIsExpanded((current) => !current)
|
||||
if (item.id && hasChildren) onToggleExpand?.(item.id)
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
const openCreateModal = (event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
setModalMode('create')
|
||||
setFormData(getCreateInitialValues())
|
||||
setFormData(createInitialValues)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (event: React.MouseEvent) => {
|
||||
if (!isDesignMode) return
|
||||
event.stopPropagation()
|
||||
if (!isDesignMode || isDragOverlay) return
|
||||
setModalMode('edit')
|
||||
setFormData(getEditInitialValues())
|
||||
setFormData(editInitialValues)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = window.confirm(`Delete "${item.displayName}"?`)
|
||||
if (!confirmed) return
|
||||
const menuService = new MenuService()
|
||||
await menuService.delete(item.id!)
|
||||
refetch()
|
||||
if (!item.id) return
|
||||
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
await menuService.delete(item.id)
|
||||
notify('success', translate('::KayitSilindi'))
|
||||
refetch()
|
||||
} catch (error) {
|
||||
notify('danger', error instanceof Error ? error.message : translate('::IslemBasarisiz'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
setIsConfirmOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const rowStateClass = isHighlighted
|
||||
? 'bg-amber-50 dark:bg-amber-900/40 ring-1 ring-amber-400'
|
||||
: hasChildren
|
||||
? 'bg-blue-50 dark:bg-blue-900'
|
||||
: depth === 0
|
||||
? 'bg-white dark:bg-gray-800'
|
||||
: 'bg-gray-50 dark:bg-gray-900'
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
className={`
|
||||
flex items-center gap-1 p-1 rounded-lg transition-all duration-200 group min-h-[30px]
|
||||
flex items-center gap-1 p-1 rounded-lg transition-colors duration-200 group min-h-[30px]
|
||||
${isDesignMode ? 'cursor-move hover:bg-blue-50 dark:hover:bg-blue-900 border border-transparent hover:border-blue-200 dark:hover:border-blue-400' : 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800'}
|
||||
${isDragOverlay ? 'shadow-lg bg-white dark:bg-gray-800 border border-blue-300 dark:border-blue-500 z-50' : ''}
|
||||
${isDragOverlay ? 'shadow-lg border border-blue-300 dark:border-blue-500 z-50' : ''}
|
||||
${isDragging ? 'opacity-50' : ''}
|
||||
${item.children && item.children.length > 0 ? 'bg-blue-50 dark:bg-blue-900' : depth === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-900'}
|
||||
${item.isDisabled ? 'opacity-60' : ''}
|
||||
${rowStateClass}
|
||||
`}
|
||||
{...(isDesignMode ? { ...attributes, ...listeners } : { onClick: toggleExpanded })}
|
||||
{...(isDesignMode ? dragHandleProps : { onClick: toggleExpanded })}
|
||||
>
|
||||
{isDesignMode &&
|
||||
(item.children && item.children.length > 0 ? (
|
||||
<Button
|
||||
aria-label={isExpanded ? 'Collapse menu item' : 'Expand menu item'}
|
||||
className="!h-6 !w-6 !px-0"
|
||||
icon={isExpanded ? <FaChevronDown size={12} /> : <FaChevronRight size={12} />}
|
||||
shape="circle"
|
||||
size="xs"
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
variant="plain"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={toggleExpanded}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true" className="h-6 w-6 flex-shrink-0" />
|
||||
))}
|
||||
|
||||
{isDesignMode && (
|
||||
<div className="flex gap-2 items-center mr-2">
|
||||
<Button
|
||||
onClick={openCreateModal}
|
||||
title="New Item"
|
||||
size="xs"
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
icon={
|
||||
<FaPlus
|
||||
size={16}
|
||||
className="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300"
|
||||
/>
|
||||
}
|
||||
className="!h-6 !w-6 !px-0"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
title="Delete Item"
|
||||
size="xs"
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
icon={
|
||||
<FaTrashAlt
|
||||
size={16}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
/>
|
||||
}
|
||||
className="!h-6 !w-6 !px-0"
|
||||
/>
|
||||
</div>
|
||||
{hasChildren ? (
|
||||
<Button
|
||||
aria-label={isExpanded ? 'Collapse menu item' : 'Expand menu item'}
|
||||
className="!h-6 !w-6 !px-0"
|
||||
icon={isExpanded ? <FaChevronDown size={12} /> : <FaChevronRight size={12} />}
|
||||
shape="circle"
|
||||
size="xs"
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
type="button"
|
||||
variant="plain"
|
||||
onPointerDown={stopPropagation}
|
||||
onClick={toggleExpanded}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true" className="h-6 w-6 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0 text-gray-600 dark:text-gray-300 text-xl">
|
||||
{navigationIcon[item.icon || ''] ? (
|
||||
React.createElement(navigationIcon[item.icon || ''], {
|
||||
className: 'text-gray-400 dark:text-gray-500',
|
||||
})
|
||||
) : (
|
||||
<FaQuestionCircle className="text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-gray-500 dark:text-gray-400 text-lg">
|
||||
<IconComponent />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={openEditModal}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
title={item.code}
|
||||
className={`
|
||||
!h-auto !justify-start !rounded-none !px-0 !py-0 truncate text-gray-800 dark:text-gray-100 leading-6 text-sm text-left hover:!bg-transparent active:!bg-transparent focus:!bg-transparent
|
||||
${item.children && item.children.length > 0 ? 'font-semibold' : 'font-normal'}
|
||||
${hasChildren ? 'font-semibold' : 'font-normal'}
|
||||
${isDesignMode ? 'hover:text-blue-600 dark:hover:text-blue-400' : ''}
|
||||
`}
|
||||
onPointerDown={isDesignMode ? stopPropagation : undefined}
|
||||
onClick={openEditModal}
|
||||
>
|
||||
{translate('::' + item.displayName)}
|
||||
{displayLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{isDesignMode && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded">#{item.order}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{/* Sabit genişlikli meta kolonu — sağdaki rozet/aksiyonların hizası bozulmaz */}
|
||||
<div className="hidden md:flex items-center justify-end gap-2 w-[280px] min-w-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.url && (
|
||||
<span
|
||||
title={item.url}
|
||||
className="inline-flex items-center gap-1 min-w-0 truncate"
|
||||
>
|
||||
<FaExternalLinkAlt size={9} className="flex-shrink-0" />
|
||||
<span className="truncate">{item.url}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{item.requiredPermissionName && (
|
||||
<span
|
||||
title={item.requiredPermissionName}
|
||||
className="inline-flex items-center gap-1 min-w-0 flex-shrink-0 truncate"
|
||||
>
|
||||
<FaLock size={9} className="flex-shrink-0" />
|
||||
{permissionLabel && <span className="truncate">{permissionLabel}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.isDisabled && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200">
|
||||
{translate('::Disabled')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{item.children && item.children.length > 0 && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-300 bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded-full">
|
||||
{item.children.length}
|
||||
{isDesignMode && (
|
||||
<span className="text-xs text-center min-w-[2.5rem] bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-0.5 rounded">
|
||||
#{item.order}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="w-6 text-center">
|
||||
{hasChildren && (
|
||||
<span className="text-xs text-gray-600 dark:text-gray-200 bg-blue-100 dark:bg-blue-900 px-2 py-0.5 rounded-full">
|
||||
{childCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{isDesignMode && !isDragOverlay && (
|
||||
<div
|
||||
className="flex items-center gap-0.5 opacity-60 group-hover:opacity-100 transition-opacity"
|
||||
onPointerDown={stopPropagation}
|
||||
>
|
||||
<Button
|
||||
className="!h-6 !w-6 !px-0"
|
||||
disabled={!canOutdent}
|
||||
icon={<FaOutdent size={13} />}
|
||||
shape="circle"
|
||||
size="xs"
|
||||
title={translate('::MoveLeft')}
|
||||
type="button"
|
||||
variant="plain"
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (item.id) onOutdent?.(item.id)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="!h-6 !w-6 !px-0"
|
||||
disabled={!canIndent}
|
||||
icon={<FaIndent size={13} />}
|
||||
shape="circle"
|
||||
size="xs"
|
||||
title={translate('::MoveRight')}
|
||||
type="button"
|
||||
variant="plain"
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (item.id) onIndent?.(item.id)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="!h-6 !w-6 !px-0"
|
||||
icon={<FaPen size={12} className="text-blue-600 dark:text-blue-400" />}
|
||||
shape="circle"
|
||||
size="xs"
|
||||
title={translate('::Edit')}
|
||||
type="button"
|
||||
variant="plain"
|
||||
onClick={openEditModal}
|
||||
/>
|
||||
<Button
|
||||
className="!h-6 !w-6 !px-0"
|
||||
icon={<FaPlus size={13} className="text-green-600 dark:text-green-400" />}
|
||||
shape="circle"
|
||||
size="xs"
|
||||
title={translate('::New')}
|
||||
type="button"
|
||||
variant="plain"
|
||||
onClick={openCreateModal}
|
||||
/>
|
||||
<Button
|
||||
className="!h-6 !w-6 !px-0"
|
||||
icon={<FaTrashAlt size={12} className="text-red-600 dark:text-red-400" />}
|
||||
shape="circle"
|
||||
size="xs"
|
||||
title={translate('::Delete')}
|
||||
type="button"
|
||||
variant="plain"
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
setIsConfirmOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children && isExpanded && <div className="mt-1">{children}</div>}
|
||||
|
||||
{isConfirmOpen && (
|
||||
<ConfirmDialog
|
||||
isOpen={isConfirmOpen}
|
||||
type="danger"
|
||||
title={translate('::DeleteConfirmation')}
|
||||
confirmText={translate('::Delete')}
|
||||
cancelText={translate('::Cancel')}
|
||||
onCancel={() => !isDeleting && setIsConfirmOpen(false)}
|
||||
onRequestClose={() => !isDeleting && setIsConfirmOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<p className="dark:text-gray-200">
|
||||
{displayLabel}
|
||||
{hasChildren ? ` (+${childCount})` : ''}
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<Dialog
|
||||
isOpen={isModalOpen}
|
||||
width={640}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onRequestClose={() => setIsModalOpen(false)}
|
||||
width={600}
|
||||
>
|
||||
<h5 className="mb-4 dark:text-gray-100">
|
||||
{modalMode === 'edit' ? translate('::Edit Menu Item') : translate('::New Item')}
|
||||
{modalMode === 'edit' ? translate('::Edit') : translate('::New')}
|
||||
</h5>
|
||||
<Formik
|
||||
validationSchema={validationSchema}
|
||||
initialValues={formData}
|
||||
enableReinitialize
|
||||
initialValues={formData}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={async (values, { setSubmitting }) => {
|
||||
try {
|
||||
const menuService = new MenuService()
|
||||
if (modalMode === 'edit' && item.id) {
|
||||
await menuService.update(item.id, values as MenuDto)
|
||||
} else {
|
||||
await menuService.create(values as MenuDto)
|
||||
}
|
||||
toast.push(
|
||||
<Notification title="Başarılı" type="success">
|
||||
{modalMode === 'edit'
|
||||
? translate('::KayitGuncellendi')
|
||||
: translate('::KayitEklendi')}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
|
||||
notify(
|
||||
'success',
|
||||
modalMode === 'edit'
|
||||
? translate('::KayitGuncellendi')
|
||||
: translate('::KayitEklendi'),
|
||||
)
|
||||
setIsModalOpen(false)
|
||||
refetch()
|
||||
} catch (error) {
|
||||
console.error('Failed to save menu item:', error)
|
||||
toast.push(
|
||||
<Notification title="Hata" type="danger">
|
||||
{translate('::IslemBasarisiz')}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
notify(
|
||||
'danger',
|
||||
error instanceof Error ? error.message : translate('::IslemBasarisiz'),
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ values, isSubmitting }) => (
|
||||
{({ values, errors, touched, isSubmitting, setFieldValue }) => (
|
||||
<Form>
|
||||
<FormContainer>
|
||||
<FormItem label="Code *" className="mb-2">
|
||||
<Field
|
||||
type="text"
|
||||
name="code"
|
||||
component={Input}
|
||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
||||
autoFocus
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Display Name *" className="mb-2">
|
||||
<Field
|
||||
type="text"
|
||||
name="displayName"
|
||||
component={Input}
|
||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Order *" className="mb-2">
|
||||
<Field
|
||||
type="number"
|
||||
name="order"
|
||||
component={Input}
|
||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="URL" className="mb-2">
|
||||
<Field
|
||||
type="text"
|
||||
name="url"
|
||||
component={Input}
|
||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Icon" className="mb-2">
|
||||
<Field
|
||||
type="text"
|
||||
name="icon"
|
||||
component={Input}
|
||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Parent Code" className="mb-2">
|
||||
<Input
|
||||
disabled
|
||||
value={values.parentCode || ''}
|
||||
className="h-8 text-sm px-2 bg-gray-100 dark:bg-gray-800 dark:text-gray-300"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="CSS Class" className="mb-2">
|
||||
<Field
|
||||
type="text"
|
||||
name="cssClass"
|
||||
component={Input}
|
||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Permission Name" className="mb-2">
|
||||
<Field
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
name="requiredPermissionName"
|
||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4">
|
||||
<FormItem
|
||||
label="Code *"
|
||||
className="mb-2"
|
||||
invalid={Boolean(errors.code && touched.code)}
|
||||
errorMessage={errors.code as string}
|
||||
>
|
||||
{({ field, form }: FieldProps<SelectBoxOption>) => (
|
||||
<Select
|
||||
field={field}
|
||||
form={form}
|
||||
isClearable={true}
|
||||
options={permissions}
|
||||
value={permissions?.filter(
|
||||
(option) => option.value === values.requiredPermissionName,
|
||||
)}
|
||||
onChange={(option) => form.setFieldValue(field.name, option?.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormItem>
|
||||
<Field
|
||||
autoFocus
|
||||
type="text"
|
||||
name="code"
|
||||
component={Input}
|
||||
className={inputClass}
|
||||
disabled={modalMode === 'edit' && hasChildren}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Target" className="mb-2">
|
||||
<Field
|
||||
type="text"
|
||||
name="target"
|
||||
component={Input}
|
||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
label="Display Name *"
|
||||
className="mb-2"
|
||||
invalid={Boolean(errors.displayName && touched.displayName)}
|
||||
errorMessage={errors.displayName as string}
|
||||
>
|
||||
<Field
|
||||
type="text"
|
||||
name="displayName"
|
||||
component={Input}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Element Id" className="mb-2">
|
||||
<Field
|
||||
type="text"
|
||||
name="elementId"
|
||||
component={Input}
|
||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="URL" className="mb-2">
|
||||
<Field type="text" name="url" component={Input} className={inputClass} />
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Is Disabled" className="mb-2">
|
||||
<Field name="isDisabled" component={Switcher} />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
label="Order *"
|
||||
className="mb-2"
|
||||
invalid={Boolean(errors.order && touched.order)}
|
||||
errorMessage={errors.order as string}
|
||||
>
|
||||
<Field type="number" name="order" component={Input} className={inputClass} />
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Icon" className="mb-2">
|
||||
<IconPickerField
|
||||
value={values.icon ?? ''}
|
||||
onChange={(iconKey) => setFieldValue('icon', iconKey)}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Parent Code" className="mb-2">
|
||||
<Input
|
||||
disabled
|
||||
value={values.parentCode ?? ''}
|
||||
className="h-8 text-sm px-2 bg-gray-100 dark:bg-gray-800 dark:text-gray-300"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Permission Name" className="mb-2">
|
||||
<Field name="requiredPermissionName">
|
||||
{({ field, form }: FieldProps<SelectBoxOption>) => (
|
||||
<Select
|
||||
isClearable
|
||||
field={field}
|
||||
form={form}
|
||||
options={permissions}
|
||||
value={
|
||||
permissions.find(
|
||||
(option) => option.value === values.requiredPermissionName,
|
||||
) ?? null
|
||||
}
|
||||
onChange={(option) =>
|
||||
form.setFieldValue(field.name, option?.value ?? '')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="CSS Class" className="mb-2">
|
||||
<Field type="text" name="cssClass" component={Input} className={inputClass} />
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Target" className="mb-2">
|
||||
<Field type="text" name="target" component={Input} className={inputClass} />
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Element Id" className="mb-2">
|
||||
<Field
|
||||
type="text"
|
||||
name="elementId"
|
||||
component={Input}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Is Disabled" className="mb-2">
|
||||
<Field name="isDisabled" component={Switcher} />
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="plain" size="sm" onClick={() => setIsModalOpen(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
>
|
||||
{translate('::Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" variant="solid" size="sm" loading={isSubmitting}>
|
||||
|
|
@ -418,3 +556,5 @@ export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MenuItemComponent = memo(MenuItemComponentBase)
|
||||
|
|
|
|||
|
|
@ -1,56 +1,96 @@
|
|||
import { useState } from 'react'
|
||||
import { SortableMenuTree } from './SortableMenuTree'
|
||||
import { useMenuData } from '@/utils/hooks/useMenuData'
|
||||
import {
|
||||
FaRegBell,
|
||||
FaSpinner,
|
||||
FaBars,
|
||||
FaRegSave,
|
||||
FaChevronRight,
|
||||
FaChevronDown,
|
||||
} from 'react-icons/fa'
|
||||
import { Container } from '@/components/shared'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import {
|
||||
FaBars,
|
||||
FaChevronDown,
|
||||
FaChevronRight,
|
||||
FaRegBell,
|
||||
FaRegSave,
|
||||
FaSearch,
|
||||
FaSpinner,
|
||||
FaSyncAlt,
|
||||
FaTimes,
|
||||
} from 'react-icons/fa'
|
||||
import { SortableMenuTree } from './SortableMenuTree'
|
||||
import { collectBranchIds, countMenuItems, filterMenuTree } from './menuTree.utils'
|
||||
import { Container } from '@/components/shared'
|
||||
import { Button, Input, Notification, toast } from '@/components/ui'
|
||||
import { APP_NAME } from '@/constants/app.constant'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { useMenuData } from '@/utils/hooks/useMenuData'
|
||||
|
||||
export const MenuManager = () => {
|
||||
const { menuItems, setMenuItems, loading, error, refetch, saveMenuData } = useMenuData()
|
||||
const [isDesignMode, setIsDesignMode] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [expansionCommand, setExpansionCommand] = useState({ expanded: true, version: 0 })
|
||||
const { menuItems, setMenuItems, loading, error, isDirty, refetch, saveMenuData } = useMenuData()
|
||||
const { translate } = useLocalization()
|
||||
|
||||
const [isDesignMode, setIsDesignMode] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const { items: visibleItems, matchedIds } = useMemo(
|
||||
() => filterMenuTree(menuItems, searchTerm),
|
||||
[menuItems, searchTerm],
|
||||
)
|
||||
|
||||
const isFiltered = searchTerm.trim().length > 0
|
||||
const totalCount = useMemo(() => countMenuItems(menuItems), [menuItems])
|
||||
|
||||
// Kaydedilmemiş değişiklikte sekme kapanışını uyar
|
||||
useEffect(() => {
|
||||
if (!isDirty) return
|
||||
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [isDirty])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isDesignMode) return
|
||||
if (!isDesignMode || isSaving) return
|
||||
|
||||
try {
|
||||
setIsSaving(true)
|
||||
|
||||
await saveMenuData(menuItems)
|
||||
setIsDesignMode(false)
|
||||
toast.push(
|
||||
<Notification title={translate('::Success')} type="success">
|
||||
{translate('::KayitGuncellendi')}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to save menu configuration:', err)
|
||||
toast.push(
|
||||
<Notification title={translate('::Error')} type="danger">
|
||||
{err instanceof Error ? err.message : translate('::IslemBasarisiz')}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleDesignMode = () => {
|
||||
setIsDesignMode((current) => !current)
|
||||
}
|
||||
const handleToggleExpand = useCallback((id: string) => {
|
||||
setCollapsedIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setAllExpanded = (expanded: boolean) => {
|
||||
setExpansionCommand((current) => ({ expanded, version: current.version + 1 }))
|
||||
}
|
||||
const setAllExpanded = (expanded: boolean) =>
|
||||
setCollapsedIds(expanded ? new Set() : new Set(collectBranchIds(menuItems)))
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="flex items-center gap-3 text-gray-600 dark:text-gray-300">
|
||||
<FaSpinner className="animate-spin" />
|
||||
<span className="text-lg">Loading menu configuration...</span>
|
||||
<span className="text-lg">{translate('::LoadingWithThreeDot')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -58,20 +98,15 @@ export const MenuManager = () => {
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md max-w-md w-full mx-4">
|
||||
<div className="flex items-center gap-3 text-red-600 dark:text-red-400 mb-4">
|
||||
<FaRegBell size={24} />
|
||||
<h2 className="text-lg font-semibold">Error Loading Menu</h2>
|
||||
<h2 className="text-lg font-semibold">{translate('::Error')}</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">{error}</p>
|
||||
<Button
|
||||
block
|
||||
variant="solid"
|
||||
className="!rounded-lg !py-2 hover:!bg-blue-700 dark:bg-blue-700 dark:hover:!bg-blue-800 transition-colors"
|
||||
onClick={refetch}
|
||||
>
|
||||
Retry
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6 break-words">{error}</p>
|
||||
<Button block variant="solid" icon={<FaSyncAlt />} onClick={refetch}>
|
||||
{translate('::Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -82,30 +117,59 @@ export const MenuManager = () => {
|
|||
<Container>
|
||||
<Helmet
|
||||
titleTemplate={`%s | ${APP_NAME}`}
|
||||
title={translate('::' + 'App.Menus.Manager')}
|
||||
title={translate('::App.Menus.Manager')}
|
||||
defaultTitle={APP_NAME}
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded px-2 sm:px-2 lg:px-3 py-3">
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-4">
|
||||
{/* Sol kısım: Başlık */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded px-2 lg:px-3 py-3">
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FaBars size={20} className="text-gray-600 dark:text-gray-300" />
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Menu Manager
|
||||
{translate('::App.Menus.Manager')}
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({menuItems.length} root items)
|
||||
({menuItems.length} / {totalCount})
|
||||
</span>
|
||||
{isDirty && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200">
|
||||
{translate('::UnsavedChanges')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sağ kısım: Design Mode + Save butonu */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative">
|
||||
<FaSearch
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
className="!h-8 !pl-7 !pr-7 text-sm w-56"
|
||||
placeholder={translate('::Search')}
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
{isFiltered && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
size="xs"
|
||||
className="!absolute !right-1 !top-1/2 !-translate-y-1/2 !h-5 !w-5 !px-0"
|
||||
icon={<FaTimes size={10} />}
|
||||
title={translate('::Clear')}
|
||||
onClick={() => setSearchTerm('')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-sm font-medium ${isDesignMode ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}`}
|
||||
>
|
||||
Design Mode
|
||||
{translate('::DesignMode')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -115,15 +179,15 @@ export const MenuManager = () => {
|
|||
aria-checked={isDesignMode}
|
||||
aria-label="Toggle design mode"
|
||||
className={`
|
||||
relative !inline-flex !h-6 !w-11 !min-w-11 !items-center !justify-start !rounded-full !border-0 !px-0 shadow-inner transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
${isDesignMode ? '!bg-blue-600 hover:!bg-blue-600 dark:!bg-blue-700 dark:hover:!bg-blue-700' : '!bg-gray-300 hover:!bg-gray-300 dark:!bg-gray-700 dark:hover:!bg-gray-700'}
|
||||
`}
|
||||
onClick={handleToggleDesignMode}
|
||||
relative !inline-flex !h-6 !w-11 !min-w-11 !items-center !justify-start !rounded-full !border-0 !px-0 shadow-inner transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
${isDesignMode ? '!bg-blue-600 hover:!bg-blue-600 dark:!bg-blue-700 dark:hover:!bg-blue-700' : '!bg-gray-300 hover:!bg-gray-300 dark:!bg-gray-700 dark:hover:!bg-gray-700'}
|
||||
`}
|
||||
onClick={() => setIsDesignMode((current) => !current)}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white dark:bg-gray-200 transition-transform duration-200 ease-in-out
|
||||
${isDesignMode ? 'translate-x-6' : 'translate-x-1'}
|
||||
inline-block h-4 w-4 transform rounded-full bg-white dark:bg-gray-200 transition-transform duration-200 ease-in-out
|
||||
${isDesignMode ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</Button>
|
||||
|
|
@ -134,7 +198,7 @@ export const MenuManager = () => {
|
|||
size="sm"
|
||||
variant="plain"
|
||||
className="!h-auto !rounded-lg !px-2 !py-1"
|
||||
icon={<FaChevronRight size={10} />}
|
||||
icon={<FaChevronDown size={10} />}
|
||||
onClick={() => setAllExpanded(true)}
|
||||
>
|
||||
{translate('::ListForms.ListFormEdit.ExpandAll')}
|
||||
|
|
@ -143,7 +207,7 @@ export const MenuManager = () => {
|
|||
size="sm"
|
||||
variant="plain"
|
||||
className="!h-auto !rounded-lg !px-2 !py-1"
|
||||
icon={<FaChevronDown size={10} />}
|
||||
icon={<FaChevronRight size={10} />}
|
||||
onClick={() => setAllExpanded(false)}
|
||||
>
|
||||
{translate('::ListForms.ListFormEdit.CollapseAll')}
|
||||
|
|
@ -151,44 +215,50 @@ export const MenuManager = () => {
|
|||
</div>
|
||||
|
||||
<Button
|
||||
disabled={!isDesignMode || isSaving}
|
||||
size="sm"
|
||||
variant="plain"
|
||||
className="!h-auto !rounded-lg !px-2 !py-1"
|
||||
icon={<FaSyncAlt size={10} />}
|
||||
title={translate('::Refresh')}
|
||||
onClick={refetch}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="solid"
|
||||
className={`
|
||||
!inline-flex !h-auto !items-center !justify-center gap-2 !rounded-lg !px-2 !py-1 transition-colors
|
||||
${isDesignMode ? 'text-white' : 'cursor-not-allowed'}
|
||||
${isSaving ? 'opacity-50' : ''}
|
||||
`}
|
||||
disabled={!isDesignMode || !isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
icon={<FaRegSave size={10} />}
|
||||
className="!h-auto !rounded-lg !px-2 !py-1"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<FaSpinner size={10} className="animate-spin" />
|
||||
{translate('::Saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaRegSave size={10} />
|
||||
{translate('::App.SaveChanges')}
|
||||
</>
|
||||
)}
|
||||
{isSaving ? translate('::Saving') : translate('::App.SaveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{menuItems.length > 0 ? (
|
||||
{isFiltered && (
|
||||
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{matchedIds.size} {translate('::Results')} — {translate('::DragDisabledWhileFiltering')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{visibleItems.length > 0 ? (
|
||||
<SortableMenuTree
|
||||
expansionCommand={expansionCommand}
|
||||
items={menuItems}
|
||||
collapsedIds={collapsedIds}
|
||||
isDesignMode={isDesignMode}
|
||||
isFiltered={isFiltered}
|
||||
items={visibleItems}
|
||||
matchedIds={matchedIds}
|
||||
refetch={refetch}
|
||||
sourceItems={menuItems}
|
||||
onItemsChange={setMenuItems}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<FaBars size={24} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-lg">No menu items found</p>
|
||||
<p className="text-sm">Try refreshing the page or contact your administrator</p>
|
||||
<p className="text-lg">{translate('::NoDataFound')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,248 +1,218 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCenter,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragStartEvent,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { MenuItemComponent } from './MenuItemComponent'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { MenuItemComponent, MenuItemComponentProps } from './MenuItemComponent'
|
||||
import { findMenuItem, indentMenuItem, moveMenuItem, outdentMenuItem } from './menuTree.utils'
|
||||
import { MenuItem } from '@/proxy/menus/menu'
|
||||
import { getPermissionsList } from '@/services/identity.service'
|
||||
import { PermissionDefinitionRecord } from '@/proxy/admin/models'
|
||||
import { getPermissionsList } from '@/services/identity.service'
|
||||
import { SelectBoxOption } from '@/types/shared'
|
||||
|
||||
interface SortableMenuTreeProps {
|
||||
/** Ekranda gösterilen (filtrelenmiş olabilir) ağaç */
|
||||
items: MenuItem[]
|
||||
/** Taşıma işlemlerinin uygulanacağı tam ağaç */
|
||||
sourceItems: MenuItem[]
|
||||
onItemsChange: (items: MenuItem[]) => void
|
||||
isDesignMode: boolean
|
||||
isFiltered?: boolean
|
||||
matchedIds?: Set<string>
|
||||
collapsedIds: Set<string>
|
||||
onToggleExpand: (id: string) => void
|
||||
refetch: () => void
|
||||
expansionCommand: { expanded: boolean; version: number }
|
||||
}
|
||||
|
||||
type SortableRowProps = Omit<MenuItemComponentProps, 'setNodeRef' | 'dragHandleProps' | 'style'> & {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const SortableMenuRow: React.FC<SortableRowProps> = ({ disabled, ...props }) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.item.id ?? '',
|
||||
data: { type: 'menu-item' },
|
||||
disabled,
|
||||
})
|
||||
|
||||
return (
|
||||
<MenuItemComponent
|
||||
{...props}
|
||||
isDragging={isDragging}
|
||||
setNodeRef={setNodeRef}
|
||||
dragHandleProps={disabled ? undefined : { ...attributes, ...listeners }}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
|
||||
items,
|
||||
sourceItems,
|
||||
onItemsChange,
|
||||
isDesignMode,
|
||||
isFiltered = false,
|
||||
matchedIds,
|
||||
collapsedIds,
|
||||
onToggleExpand,
|
||||
refetch,
|
||||
expansionCommand,
|
||||
}) => {
|
||||
const [permissions, setPermissions] = useState<SelectBoxOption[]>([])
|
||||
const [activeItem, setActiveItem] = React.useState<MenuItem | null>(null)
|
||||
const [activeItem, setActiveItem] = useState<MenuItem | null>(null)
|
||||
|
||||
// Filtre açıkken ara düğümler gizli olduğu için sürükle-bırak kapatılır.
|
||||
const dragEnabled = isDesignMode && !isFiltered
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
const flattenItems = (
|
||||
items: MenuItem[],
|
||||
parentPath: number[] = [],
|
||||
): Array<{ item: MenuItem; path: number[] }> => {
|
||||
return items.flatMap((item, index) => {
|
||||
const path = [...parentPath, index]
|
||||
const self = { item, path }
|
||||
|
||||
if (item.children?.length) {
|
||||
return [self, ...flattenItems(item.children, path)]
|
||||
}
|
||||
|
||||
return [self]
|
||||
})
|
||||
}
|
||||
|
||||
const findItemById = (items: MenuItem[], id: string): MenuItem | null => {
|
||||
for (const item of items) {
|
||||
if (item.id === id) return item
|
||||
if (item.children) {
|
||||
const found = findItemById(item.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const removeItemFromTree = (items: MenuItem[], id: string): MenuItem[] => {
|
||||
return items.reduce((acc: MenuItem[], item) => {
|
||||
if (item.id === id) return acc
|
||||
|
||||
const newItem = { ...item }
|
||||
if (newItem.children && newItem.children.length > 0) {
|
||||
newItem.children = removeItemFromTree(newItem.children, id)
|
||||
}
|
||||
|
||||
acc.push(newItem)
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
const insertItemAtPath = (
|
||||
items: MenuItem[],
|
||||
item: MenuItem,
|
||||
targetPath: number[],
|
||||
): MenuItem[] => {
|
||||
if (targetPath.length === 1) {
|
||||
const newItems = [...items]
|
||||
newItems.splice(targetPath[0], 0, item)
|
||||
return newItems
|
||||
}
|
||||
|
||||
const [firstIndex, ...restPath] = targetPath
|
||||
const newItems = [...items]
|
||||
|
||||
if (newItems[firstIndex]) {
|
||||
newItems[firstIndex] = {
|
||||
...newItems[firstIndex],
|
||||
children: insertItemAtPath(newItems[firstIndex].children || [], item, restPath),
|
||||
}
|
||||
}
|
||||
|
||||
return newItems
|
||||
}
|
||||
|
||||
const getInsertionPath = (
|
||||
items: MenuItem[],
|
||||
activeId: string,
|
||||
overId: string,
|
||||
): number[] | null => {
|
||||
const flat = flattenItems(items)
|
||||
const activeFlat = flat.find((f) => f.item.id === activeId)
|
||||
const overFlat = flat.find((f) => f.item.id === overId)
|
||||
|
||||
if (!activeFlat || !overFlat) return null
|
||||
|
||||
const isSameParent = activeFlat.path.slice(0, -1).join() === overFlat.path.slice(0, -1).join()
|
||||
|
||||
const insertPath = [...overFlat.path]
|
||||
|
||||
if (isSameParent) {
|
||||
const activeIndex = activeFlat.path[activeFlat.path.length - 2]
|
||||
const overIndex = overFlat.path[overFlat.path.length - 1]
|
||||
|
||||
if (activeIndex < overIndex) {
|
||||
insertPath[insertPath.length - 1] += 1
|
||||
}
|
||||
}
|
||||
|
||||
return insertPath
|
||||
}
|
||||
|
||||
const updateOrderNumbers = (items: MenuItem[]): MenuItem[] => {
|
||||
return items.map((item, index) => ({
|
||||
...item,
|
||||
order: index + 1,
|
||||
children: item.children ? updateOrderNumbers(item.children) : [],
|
||||
}))
|
||||
}
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event
|
||||
const activeItem = findItemById(items, active.id as string)
|
||||
setActiveItem(activeItem)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
setActiveItem(null)
|
||||
|
||||
if (!over || active.id === over.id || !isDesignMode) return
|
||||
|
||||
const activeId = active.id as string
|
||||
const overId = over.id as string
|
||||
const activeItem = findItemById(items, activeId)
|
||||
if (!activeItem) return
|
||||
|
||||
const insertionPath = getInsertionPath(items, activeId, overId)
|
||||
if (!insertionPath) return
|
||||
|
||||
let newItems = removeItemFromTree(items, activeId)
|
||||
newItems = insertItemAtPath(newItems, activeItem, insertionPath)
|
||||
const finalItems = updateOrderNumbers(newItems)
|
||||
onItemsChange(finalItems)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPermissions = async () => {
|
||||
const response = await getPermissionsList()
|
||||
if (response.data) {
|
||||
setPermissions(
|
||||
response.data.map((p: PermissionDefinitionRecord) => ({
|
||||
value: p.name,
|
||||
label: p.name,
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
let cancelled = false
|
||||
|
||||
fetchPermissions()
|
||||
getPermissionsList()
|
||||
.then((response) => {
|
||||
if (cancelled || !response.data) return
|
||||
|
||||
const options = response.data
|
||||
.filter((permission: PermissionDefinitionRecord) => Boolean(permission.name))
|
||||
.map((permission: PermissionDefinitionRecord) => ({
|
||||
value: permission.name,
|
||||
label: permission.name,
|
||||
}))
|
||||
.sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''))
|
||||
|
||||
setPermissions(options)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPermissions([])
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const renderMenuItem = (item: MenuItem, depth: number = 0): React.ReactNode => {
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => setActiveItem(findMenuItem(sourceItems, event.active.id as string)),
|
||||
[sourceItems],
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveItem(null)
|
||||
|
||||
const { active, over } = event
|
||||
if (!over || !dragEnabled || active.id === over.id) return
|
||||
|
||||
const next = moveMenuItem(sourceItems, active.id as string, over.id as string)
|
||||
if (next) onItemsChange(next)
|
||||
},
|
||||
[dragEnabled, onItemsChange, sourceItems],
|
||||
)
|
||||
|
||||
const handleIndent = useCallback(
|
||||
(id: string) => {
|
||||
const next = indentMenuItem(sourceItems, id)
|
||||
if (next) onItemsChange(next)
|
||||
},
|
||||
[onItemsChange, sourceItems],
|
||||
)
|
||||
|
||||
const handleOutdent = useCallback(
|
||||
(id: string) => {
|
||||
const next = outdentMenuItem(sourceItems, id)
|
||||
if (next) onItemsChange(next)
|
||||
},
|
||||
[onItemsChange, sourceItems],
|
||||
)
|
||||
|
||||
const renderMenuItem = (item: MenuItem, depth: number, index: number) => {
|
||||
const id = item.id ?? ''
|
||||
const children = item.children ?? []
|
||||
const isExpanded = isFiltered || !collapsedIds.has(id)
|
||||
|
||||
return (
|
||||
<div key={item.id} className="bg-white dark:bg-gray-800 rounded-md">
|
||||
<MenuItemComponent
|
||||
item={item}
|
||||
isDesignMode={isDesignMode}
|
||||
<div key={id || `${depth}-${index}`} className="rounded-md">
|
||||
<SortableMenuRow
|
||||
canIndent={isDesignMode && !isFiltered && index > 0}
|
||||
canOutdent={isDesignMode && !isFiltered && depth > 0}
|
||||
depth={depth}
|
||||
refetch={refetch}
|
||||
disabled={!dragEnabled}
|
||||
isDesignMode={isDesignMode}
|
||||
isExpanded={isExpanded}
|
||||
isHighlighted={Boolean(matchedIds?.has(id))}
|
||||
item={item}
|
||||
permissions={permissions}
|
||||
expansionCommand={expansionCommand}
|
||||
refetch={refetch}
|
||||
onIndent={handleIndent}
|
||||
onOutdent={handleOutdent}
|
||||
onToggleExpand={onToggleExpand}
|
||||
>
|
||||
{Array.isArray(item.children) && item.children.length > 0 && (
|
||||
{children.length > 0 && (
|
||||
<SortableContext
|
||||
items={item.children
|
||||
.filter((child): child is MenuItem & { id: string } => !!child.id)
|
||||
.map((child) => child.id)}
|
||||
items={children.map((child) => child.id ?? '').filter(Boolean)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="ml-4 border-gray-200 dark:border-gray-700">
|
||||
{item.children.map((child) => renderMenuItem(child, depth + 1))}
|
||||
<div className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-1">
|
||||
{children.map((child, childIndex) =>
|
||||
renderMenuItem(child, depth + 1, childIndex),
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</MenuItemComponent>
|
||||
</SortableMenuRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rootIds = useMemo(
|
||||
() => items.map((item) => item.id ?? '').filter(Boolean),
|
||||
[items],
|
||||
)
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setActiveItem(null)}
|
||||
>
|
||||
<div className="space-y-1">{items.map((item) => renderMenuItem(item))}</div>
|
||||
<SortableContext items={rootIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1">
|
||||
{items.map((item, index) => renderMenuItem(item, 0, index))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{activeItem ? (
|
||||
{activeItem && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-md shadow-lg">
|
||||
<MenuItemComponent
|
||||
item={activeItem}
|
||||
isDesignMode={isDesignMode}
|
||||
isDragOverlay
|
||||
depth={0}
|
||||
isDragOverlay={true}
|
||||
refetch={refetch}
|
||||
isDesignMode={isDesignMode}
|
||||
item={activeItem}
|
||||
permissions={permissions}
|
||||
expansionCommand={expansionCommand}
|
||||
refetch={refetch}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)
|
||||
|
|
|
|||
274
ui/src/views/menu/menuTree.utils.ts
Normal file
274
ui/src/views/menu/menuTree.utils.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import type { MenuItem } from '@/proxy/menus/menu'
|
||||
import type { MenuDto } from '@/proxy/menus/models'
|
||||
|
||||
/** Aynı seviyedeki kayıtları order (eşitse displayName) ile sıralar. */
|
||||
const sortByOrder = (items: MenuItem[]): MenuItem[] => {
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
(a.order ?? 0) - (b.order ?? 0) ||
|
||||
(a.displayName ?? '').localeCompare(b.displayName ?? ''),
|
||||
)
|
||||
items.forEach((item) => {
|
||||
if (item.children?.length) sortByOrder(item.children)
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Düz listeyi ağaca çevirir.
|
||||
* - parentCode karşılığı olmayan kayıtlar root'a alınır (orphan koruması).
|
||||
* - Çevrimsel parent zincirleri (A -> B -> A) tespit edilip root'a taşınır,
|
||||
* böylece kayıtlar ekranda kaybolmaz.
|
||||
*/
|
||||
export const buildMenuTree = (items: MenuDto[]): MenuItem[] => {
|
||||
const nodes: MenuItem[] = items.map((item) => ({ ...item, children: [] }))
|
||||
|
||||
const byCode = new Map<string, MenuItem>()
|
||||
nodes.forEach((node) => {
|
||||
if (node.code && !byCode.has(node.code)) byCode.set(node.code, node)
|
||||
})
|
||||
|
||||
const roots: MenuItem[] = []
|
||||
nodes.forEach((node) => {
|
||||
const parent = node.parentCode ? byCode.get(node.parentCode) : undefined
|
||||
if (parent && parent !== node) parent.children!.push(node)
|
||||
else roots.push(node)
|
||||
})
|
||||
|
||||
// Çevrim nedeniyle hiçbir root'tan ulaşılamayan düğümleri kurtar.
|
||||
const reachable = new Set<MenuItem>()
|
||||
const visit = (list: MenuItem[]) => {
|
||||
list.forEach((node) => {
|
||||
if (reachable.has(node)) return
|
||||
reachable.add(node)
|
||||
visit(node.children ?? [])
|
||||
})
|
||||
}
|
||||
visit(roots)
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (reachable.has(node)) return
|
||||
const parent = node.parentCode ? byCode.get(node.parentCode) : undefined
|
||||
if (parent?.children) {
|
||||
parent.children = parent.children.filter((child) => child !== node)
|
||||
}
|
||||
roots.push(node)
|
||||
visit([node])
|
||||
})
|
||||
|
||||
return sortByOrder(roots)
|
||||
}
|
||||
|
||||
/** Ağacı API'nin beklediği düz listeye çevirir; order/parentCode yeniden hesaplanır. */
|
||||
export const flattenMenuTree = (items: MenuItem[], parentCode?: string): MenuDto[] =>
|
||||
items.flatMap((item, index) => {
|
||||
const { children, ...rest } = item
|
||||
return [
|
||||
{ ...rest, parentCode, order: index + 1 },
|
||||
...flattenMenuTree(children ?? [], item.code),
|
||||
]
|
||||
})
|
||||
|
||||
/** order ve parentCode alanlarını ağaçtaki gerçek konuma göre günceller. */
|
||||
export const reindexTree = (items: MenuItem[], parentCode?: string): MenuItem[] =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
parentCode,
|
||||
order: index + 1,
|
||||
children: item.children?.length ? reindexTree(item.children, item.code) : [],
|
||||
}))
|
||||
|
||||
export const countMenuItems = (items: MenuItem[]): number =>
|
||||
items.reduce((total, item) => total + 1 + countMenuItems(item.children ?? []), 0)
|
||||
|
||||
/** Alt kaydı olan tüm düğümlerin id'leri (expand/collapse all için). */
|
||||
export const collectBranchIds = (items: MenuItem[], acc: string[] = []): string[] => {
|
||||
items.forEach((item) => {
|
||||
if (item.children?.length) {
|
||||
if (item.id) acc.push(item.id)
|
||||
collectBranchIds(item.children, acc)
|
||||
}
|
||||
})
|
||||
return acc
|
||||
}
|
||||
|
||||
export const getItemPath = (
|
||||
items: MenuItem[],
|
||||
id: string,
|
||||
base: number[] = [],
|
||||
): number[] | null => {
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const path = [...base, index]
|
||||
if (items[index].id === id) return path
|
||||
|
||||
const children = items[index].children
|
||||
if (children?.length) {
|
||||
const found = getItemPath(children, id, path)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const getItemAtPath = (items: MenuItem[], path: number[]): MenuItem | null => {
|
||||
const [index, ...rest] = path
|
||||
const item = items[index]
|
||||
if (!item) return null
|
||||
return rest.length ? getItemAtPath(item.children ?? [], rest) : item
|
||||
}
|
||||
|
||||
export const findMenuItem = (items: MenuItem[], id: string): MenuItem | null => {
|
||||
const path = getItemPath(items, id)
|
||||
return path ? getItemAtPath(items, path) : null
|
||||
}
|
||||
|
||||
const removeAtPath = (items: MenuItem[], path: number[]): MenuItem[] => {
|
||||
const [index, ...rest] = path
|
||||
const next = [...items]
|
||||
|
||||
if (!rest.length) {
|
||||
next.splice(index, 1)
|
||||
return next
|
||||
}
|
||||
|
||||
const parent = next[index]
|
||||
if (!parent) return next
|
||||
next[index] = { ...parent, children: removeAtPath(parent.children ?? [], rest) }
|
||||
return next
|
||||
}
|
||||
|
||||
const insertAtPath = (items: MenuItem[], item: MenuItem, path: number[]): MenuItem[] => {
|
||||
const [index, ...rest] = path
|
||||
const next = [...items]
|
||||
|
||||
if (!rest.length) {
|
||||
next.splice(index, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
const parent = next[index]
|
||||
if (!parent) return next
|
||||
next[index] = { ...parent, children: insertAtPath(parent.children ?? [], item, rest) }
|
||||
return next
|
||||
}
|
||||
|
||||
/** a, b'nin atası (veya kendisi) mi? */
|
||||
const isAncestorPath = (a: number[], b: number[]) =>
|
||||
a.length <= b.length && a.every((value, index) => b[index] === value)
|
||||
|
||||
/** Ağaçtaki görsel (document) sıraya göre karşılaştırma. */
|
||||
const comparePaths = (a: number[], b: number[]) => {
|
||||
const length = Math.min(a.length, b.length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (a[i] !== b[i]) return a[i] - b[i]
|
||||
}
|
||||
return a.length - b.length
|
||||
}
|
||||
|
||||
/**
|
||||
* activeId kaydını overId kaydının kardeşi olacak şekilde taşır.
|
||||
* Hedef yol, kayıt ağaçtan çıkarıldıktan SONRA yeniden hesaplanır; böylece
|
||||
* index kayması kaynaklı yanlış konumlanma oluşmaz.
|
||||
* Bir kaydı kendi alt ağacına bırakmak yok sayılır (veri kaybını önler).
|
||||
*/
|
||||
export const moveMenuItem = (
|
||||
items: MenuItem[],
|
||||
activeId: string,
|
||||
overId: string,
|
||||
): MenuItem[] | null => {
|
||||
if (activeId === overId) return null
|
||||
|
||||
const activePath = getItemPath(items, activeId)
|
||||
const overPath = getItemPath(items, overId)
|
||||
if (!activePath || !overPath) return null
|
||||
if (isAncestorPath(activePath, overPath)) return null
|
||||
|
||||
const active = getItemAtPath(items, activePath)
|
||||
if (!active) return null
|
||||
|
||||
const movingDown = comparePaths(activePath, overPath) < 0
|
||||
|
||||
const without = removeAtPath(items, activePath)
|
||||
const nextOverPath = getItemPath(without, overId)
|
||||
if (!nextOverPath) return null
|
||||
|
||||
const targetPath = movingDown
|
||||
? [...nextOverPath.slice(0, -1), nextOverPath[nextOverPath.length - 1] + 1]
|
||||
: nextOverPath
|
||||
|
||||
return reindexTree(insertAtPath(without, active, targetPath))
|
||||
}
|
||||
|
||||
/** Kaydı bir üstteki kardeşin altına taşır. */
|
||||
export const indentMenuItem = (items: MenuItem[], id: string): MenuItem[] | null => {
|
||||
const path = getItemPath(items, id)
|
||||
if (!path) return null
|
||||
|
||||
const index = path[path.length - 1]
|
||||
if (index === 0) return null
|
||||
|
||||
const item = getItemAtPath(items, path)
|
||||
if (!item) return null
|
||||
|
||||
const without = removeAtPath(items, path)
|
||||
const siblingPath = [...path.slice(0, -1), index - 1]
|
||||
const sibling = getItemAtPath(without, siblingPath)
|
||||
if (!sibling) return null
|
||||
|
||||
return reindexTree(
|
||||
insertAtPath(without, item, [...siblingPath, sibling.children?.length ?? 0]),
|
||||
)
|
||||
}
|
||||
|
||||
/** Kaydı bir üst seviyeye, mevcut ebeveyninin hemen altına taşır. */
|
||||
export const outdentMenuItem = (items: MenuItem[], id: string): MenuItem[] | null => {
|
||||
const path = getItemPath(items, id)
|
||||
if (!path || path.length < 2) return null
|
||||
|
||||
const item = getItemAtPath(items, path)
|
||||
if (!item) return null
|
||||
|
||||
const without = removeAtPath(items, path)
|
||||
const targetPath = [...path.slice(0, -2), path[path.length - 2] + 1]
|
||||
|
||||
return reindexTree(insertAtPath(without, item, targetPath))
|
||||
}
|
||||
|
||||
export interface MenuTreeFilterResult {
|
||||
items: MenuItem[]
|
||||
matchedIds: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Arama terimine göre ağacı filtreler.
|
||||
* Eşleşen kaydın tüm alt ağacı korunur, eşleşmeyen ama altında eşleşme olan
|
||||
* kayıtlar sadece eşleşen dallarıyla birlikte gösterilir.
|
||||
*/
|
||||
export const filterMenuTree = (items: MenuItem[], term: string): MenuTreeFilterResult => {
|
||||
const query = term.trim().toLowerCase()
|
||||
if (!query) return { items, matchedIds: new Set<string>() }
|
||||
|
||||
const matchedIds = new Set<string>()
|
||||
|
||||
const walk = (nodes: MenuItem[]): MenuItem[] =>
|
||||
nodes.reduce<MenuItem[]>((acc, node) => {
|
||||
const matchedChildren = walk(node.children ?? [])
|
||||
const isMatch = [node.displayName, node.code, node.url, node.requiredPermissionName].some(
|
||||
(value) => value?.toLowerCase().includes(query),
|
||||
)
|
||||
|
||||
if (isMatch || matchedChildren.length) {
|
||||
if (isMatch && node.id) matchedIds.add(node.id)
|
||||
acc.push({ ...node, children: isMatch ? (node.children ?? []) : matchedChildren })
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return { items: walk(items), matchedIds }
|
||||
}
|
||||
|
||||
/** Kaydedilmemiş değişiklik tespiti için ağacın konum imzası. */
|
||||
export const menuTreeSignature = (items: MenuItem[]): string =>
|
||||
flattenMenuTree(items)
|
||||
.map((item) => `${item.id ?? item.code}:${item.parentCode ?? ''}:${item.order}`)
|
||||
.join('|')
|
||||
Loading…
Reference in a new issue