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 { MenuItem } from '@/proxy/menus/menu'
|
||||||
import { getMenus, MenuService } from '@/services/menu.service'
|
import { getMenus, MenuService } from '@/services/menu.service'
|
||||||
import { useStoreActions } from '@/store/store'
|
import { useStoreActions } from '@/store/store'
|
||||||
import { useState, useEffect } from 'react'
|
import {
|
||||||
import useAuth from './useAuth'
|
buildMenuTree,
|
||||||
|
flattenMenuTree,
|
||||||
|
menuTreeSignature,
|
||||||
|
reindexTree,
|
||||||
|
} from '@/views/menu/menuTree.utils'
|
||||||
|
|
||||||
|
const menuService = new MenuService()
|
||||||
|
|
||||||
export const useMenuData = () => {
|
export const useMenuData = () => {
|
||||||
const [menuItems, setMenuItems] = useState<MenuItem[]>([])
|
const [menuItems, setMenuItems] = useState<MenuItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const { getConfig } = useStoreActions((a) => a.abpConfig)
|
const [baseline, setBaseline] = useState('')
|
||||||
const { authenticated } = useAuth()
|
|
||||||
|
|
||||||
const buildHierarchy = (items: MenuItem[]): MenuItem[] => {
|
const { getConfig } = useStoreActions((actions) => actions.abpConfig)
|
||||||
const itemMap = new Map<string, MenuItem>()
|
|
||||||
const rootItems: MenuItem[] = []
|
|
||||||
|
|
||||||
// Create a map for quick lookup and initialize children arrays
|
const mountedRef = useRef(true)
|
||||||
items.forEach((item) => {
|
const requestIdRef = useRef(0)
|
||||||
itemMap.set(item.code!!, { ...item, children: [] })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build the hierarchy
|
useEffect(() => {
|
||||||
items.forEach((item) => {
|
mountedRef.current = true
|
||||||
const menuItem = itemMap.get(item.code!!)!
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
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) : [],
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// Simulate API call with mock data
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
||||||
|
|
||||||
const response = await getMenus()
|
const response = await getMenus()
|
||||||
|
if (!isCurrent()) return
|
||||||
|
|
||||||
if (response.data) {
|
const tree = reindexTree(buildMenuTree(response.data?.items ?? []))
|
||||||
const hierarchicalMenu = buildHierarchy(response.data.items || [])
|
setMenuItems(tree)
|
||||||
setMenuItems(hierarchicalMenu)
|
setBaseline(menuTreeSignature(tree))
|
||||||
}
|
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false)
|
if (isCurrent()) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const saveMenuData = async (updatedMenuItems: MenuItem[]) => {
|
const saveMenuData = useCallback(
|
||||||
const menuService = new MenuService()
|
async (updatedMenuItems: MenuItem[]) => {
|
||||||
|
const normalized = reindexTree(updatedMenuItems)
|
||||||
|
|
||||||
try {
|
await menuService.updateAll(flattenMenuTree(normalized))
|
||||||
// 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)
|
|
||||||
|
|
||||||
if (item.children && item.children.length > 0) {
|
if (mountedRef.current) {
|
||||||
result.push(...flatten(item.children, item.code))
|
setMenuItems(normalized)
|
||||||
}
|
setBaseline(menuTreeSignature(normalized))
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
const items = flatten(updatedMenuItems)
|
|
||||||
await menuService.updateAll(items)
|
|
||||||
getConfig(false)
|
|
||||||
|
|
||||||
return { success: true }
|
// Sol menü / yetki cache'ini tazele
|
||||||
} catch (err) {
|
getConfig(false)
|
||||||
throw new Error(err instanceof Error ? err.message : 'Failed to save menu data')
|
},
|
||||||
}
|
[getConfig],
|
||||||
}
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMenuData()
|
fetchMenuData()
|
||||||
}, [])
|
}, [fetchMenuData])
|
||||||
|
|
||||||
|
const isDirty = useMemo(
|
||||||
|
() => menuItems.length > 0 && menuTreeSignature(menuItems) !== baseline,
|
||||||
|
[menuItems, baseline],
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
menuItems,
|
menuItems,
|
||||||
setMenuItems,
|
setMenuItems,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
isDirty,
|
||||||
refetch: fetchMenuData,
|
refetch: fetchMenuData,
|
||||||
saveMenuData,
|
saveMenuData,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,408 +1,546 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { memo, useMemo, useState } from 'react'
|
||||||
import { useSortable } from '@dnd-kit/sortable'
|
import { Field, FieldProps, Form, Formik } from 'formik'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import * as Yup from 'yup'
|
||||||
import { MenuItem } from '@/proxy/menus/menu'
|
import {
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
FaChevronDown,
|
||||||
import { MenuService } from '@/services/menu.service'
|
FaChevronRight,
|
||||||
import navigationIcon from '@/proxy/menus/navigation-icon.config'
|
FaExternalLinkAlt,
|
||||||
|
FaIndent,
|
||||||
|
FaLock,
|
||||||
|
FaOutdent,
|
||||||
|
FaPen,
|
||||||
|
FaPlus,
|
||||||
|
FaQuestionCircle,
|
||||||
|
FaTrashAlt,
|
||||||
|
} from 'react-icons/fa'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
FormContainer,
|
FormContainer,
|
||||||
FormItem,
|
FormItem,
|
||||||
Input,
|
Input,
|
||||||
Switcher,
|
|
||||||
Notification,
|
Notification,
|
||||||
toast,
|
|
||||||
Select,
|
Select,
|
||||||
|
Switcher,
|
||||||
|
toast,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { Field, FieldProps, Form, Formik } from 'formik'
|
import { ConfirmDialog } from '@/components/shared'
|
||||||
import { SelectBoxOption } from '@/types/shared'
|
import { MenuItem } from '@/proxy/menus/menu'
|
||||||
import * as Yup from 'yup'
|
|
||||||
import { FaChevronDown, FaChevronRight, FaQuestionCircle, FaPlus, FaTrashAlt } from 'react-icons/fa'
|
|
||||||
import { MenuDto } from '@/proxy/menus/models'
|
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
|
item: MenuItem
|
||||||
isDesignMode: boolean
|
isDesignMode: boolean
|
||||||
depth: number
|
depth: number
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
isDragOverlay?: boolean
|
isDragOverlay?: boolean
|
||||||
refetch: () => void
|
isDragging?: boolean
|
||||||
|
isHighlighted?: boolean
|
||||||
|
isExpanded?: boolean
|
||||||
|
canIndent?: boolean
|
||||||
|
canOutdent?: boolean
|
||||||
permissions: SelectBoxOption[]
|
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,
|
item,
|
||||||
isDesignMode,
|
isDesignMode,
|
||||||
depth,
|
depth,
|
||||||
children,
|
children,
|
||||||
isDragOverlay = false,
|
isDragOverlay = false,
|
||||||
refetch,
|
isDragging = false,
|
||||||
|
isHighlighted = false,
|
||||||
|
isExpanded = true,
|
||||||
|
canIndent = false,
|
||||||
|
canOutdent = false,
|
||||||
permissions,
|
permissions,
|
||||||
expansionCommand,
|
refetch,
|
||||||
|
onToggleExpand,
|
||||||
|
onIndent,
|
||||||
|
onOutdent,
|
||||||
|
setNodeRef,
|
||||||
|
dragHandleProps,
|
||||||
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
const { translate } = useLocalization()
|
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 [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create')
|
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create')
|
||||||
|
const [formData, setFormData] = useState<Partial<MenuDto>>({})
|
||||||
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const childCount = item.children?.length ?? 0
|
||||||
setIsExpanded(expansionCommand.expanded)
|
const hasChildren = childCount > 0
|
||||||
}, [expansionCommand.expanded, expansionCommand.version])
|
const IconComponent = (item.icon && navigationIcon[item.icon]) || FaQuestionCircle
|
||||||
|
const displayLabel = item.displayName ? translate('::' + item.displayName) : (item.code ?? '')
|
||||||
|
|
||||||
const getCreateInitialValues = (): Partial<MenuDto> => ({
|
// Satırda aynı bilgiyi tekrar etmemek için: permission, code ile aynıysa
|
||||||
code: '',
|
// sadece kilit ikonu gösterilir (tam ad tooltip'te).
|
||||||
displayName: '',
|
const permissionLabel =
|
||||||
order: (item.children?.length || 0) + 1,
|
item.requiredPermissionName && item.requiredPermissionName !== item.code
|
||||||
parentCode: item.code,
|
? item.requiredPermissionName
|
||||||
url: '',
|
: ''
|
||||||
icon: '',
|
|
||||||
cssClass: '',
|
|
||||||
requiredPermissionName: '',
|
|
||||||
target: '',
|
|
||||||
isDisabled: false,
|
|
||||||
elementId: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const getEditInitialValues = (): Partial<MenuDto> => ({
|
const createInitialValues = useMemo<Partial<MenuDto>>(
|
||||||
id: item.id,
|
() => ({
|
||||||
code: item.code || '',
|
code: '',
|
||||||
displayName: item.displayName || '',
|
displayName: '',
|
||||||
order: item.order,
|
order: childCount + 1,
|
||||||
parentCode: item.parentCode || '',
|
parentCode: item.code,
|
||||||
url: item.url || '',
|
url: '',
|
||||||
icon: item.icon || '',
|
icon: '',
|
||||||
cssClass: item.cssClass || '',
|
cssClass: '',
|
||||||
requiredPermissionName: item.requiredPermissionName || '',
|
requiredPermissionName: '',
|
||||||
target: item.target || '',
|
target: '',
|
||||||
isDisabled: item.isDisabled ?? false,
|
isDisabled: false,
|
||||||
elementId: item.elementId || '',
|
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()
|
event?.stopPropagation()
|
||||||
setIsExpanded((current) => !current)
|
if (item.id && hasChildren) onToggleExpand?.(item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
setModalMode('create')
|
setModalMode('create')
|
||||||
setFormData(getCreateInitialValues())
|
setFormData(createInitialValues)
|
||||||
setIsModalOpen(true)
|
setIsModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditModal = (event: React.MouseEvent) => {
|
const openEditModal = (event: React.MouseEvent) => {
|
||||||
if (!isDesignMode) return
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
if (!isDesignMode || isDragOverlay) return
|
||||||
setModalMode('edit')
|
setModalMode('edit')
|
||||||
setFormData(getEditInitialValues())
|
setFormData(editInitialValues)
|
||||||
setIsModalOpen(true)
|
setIsModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
const confirmed = window.confirm(`Delete "${item.displayName}"?`)
|
if (!item.id) return
|
||||||
if (!confirmed) return
|
|
||||||
const menuService = new MenuService()
|
try {
|
||||||
await menuService.delete(item.id!)
|
setIsDeleting(true)
|
||||||
refetch()
|
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 (
|
return (
|
||||||
<div className="select-none">
|
<div className="select-none">
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
className={`
|
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'}
|
${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' : ''}
|
${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 &&
|
{hasChildren ? (
|
||||||
(item.children && item.children.length > 0 ? (
|
<Button
|
||||||
<Button
|
aria-label={isExpanded ? 'Collapse menu item' : 'Expand menu item'}
|
||||||
aria-label={isExpanded ? 'Collapse menu item' : 'Expand menu item'}
|
className="!h-6 !w-6 !px-0"
|
||||||
className="!h-6 !w-6 !px-0"
|
icon={isExpanded ? <FaChevronDown size={12} /> : <FaChevronRight size={12} />}
|
||||||
icon={isExpanded ? <FaChevronDown size={12} /> : <FaChevronRight size={12} />}
|
shape="circle"
|
||||||
shape="circle"
|
size="xs"
|
||||||
size="xs"
|
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
type="button"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={stopPropagation}
|
||||||
onClick={toggleExpanded}
|
onClick={toggleExpanded}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span aria-hidden="true" className="h-6 w-6 flex-shrink-0" />
|
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex-shrink-0 text-gray-500 dark:text-gray-400 text-lg">
|
||||||
<div className="flex-shrink-0 text-gray-600 dark:text-gray-300 text-xl">
|
<IconComponent />
|
||||||
{navigationIcon[item.icon || ''] ? (
|
</div>
|
||||||
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 items-center gap-2 flex-1 min-w-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openEditModal}
|
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
|
title={item.code}
|
||||||
className={`
|
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
|
!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' : ''}
|
${isDesignMode ? 'hover:text-blue-600 dark:hover:text-blue-400' : ''}
|
||||||
`}
|
`}
|
||||||
|
onPointerDown={isDesignMode ? stopPropagation : undefined}
|
||||||
|
onClick={openEditModal}
|
||||||
>
|
>
|
||||||
{translate('::' + item.displayName)}
|
{displayLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
{isDesignMode && (
|
{/* Sabit genişlikli meta kolonu — sağdaki rozet/aksiyonların hizası bozulmaz */}
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
<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">
|
||||||
<span className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded">#{item.order}</span>
|
{item.url && (
|
||||||
</div>
|
<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 && (
|
{isDesignMode && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-300 bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded-full">
|
<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.children.length}
|
#{item.order}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children && isExpanded && <div className="mt-1">{children}</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 && (
|
{isModalOpen && (
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
width={640}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
onRequestClose={() => setIsModalOpen(false)}
|
onRequestClose={() => setIsModalOpen(false)}
|
||||||
width={600}
|
|
||||||
>
|
>
|
||||||
<h5 className="mb-4 dark:text-gray-100">
|
<h5 className="mb-4 dark:text-gray-100">
|
||||||
{modalMode === 'edit' ? translate('::Edit Menu Item') : translate('::New Item')}
|
{modalMode === 'edit' ? translate('::Edit') : translate('::New')}
|
||||||
</h5>
|
</h5>
|
||||||
<Formik
|
<Formik
|
||||||
validationSchema={validationSchema}
|
|
||||||
initialValues={formData}
|
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
|
initialValues={formData}
|
||||||
|
validationSchema={validationSchema}
|
||||||
onSubmit={async (values, { setSubmitting }) => {
|
onSubmit={async (values, { setSubmitting }) => {
|
||||||
try {
|
try {
|
||||||
const menuService = new MenuService()
|
|
||||||
if (modalMode === 'edit' && item.id) {
|
if (modalMode === 'edit' && item.id) {
|
||||||
await menuService.update(item.id, values as MenuDto)
|
await menuService.update(item.id, values as MenuDto)
|
||||||
} else {
|
} else {
|
||||||
await menuService.create(values as MenuDto)
|
await menuService.create(values as MenuDto)
|
||||||
}
|
}
|
||||||
toast.push(
|
|
||||||
<Notification title="Başarılı" type="success">
|
notify(
|
||||||
{modalMode === 'edit'
|
'success',
|
||||||
? translate('::KayitGuncellendi')
|
modalMode === 'edit'
|
||||||
: translate('::KayitEklendi')}
|
? translate('::KayitGuncellendi')
|
||||||
</Notification>,
|
: translate('::KayitEklendi'),
|
||||||
{ placement: 'bottom-end' },
|
|
||||||
)
|
)
|
||||||
setIsModalOpen(false)
|
setIsModalOpen(false)
|
||||||
refetch()
|
refetch()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save menu item:', error)
|
notify(
|
||||||
toast.push(
|
'danger',
|
||||||
<Notification title="Hata" type="danger">
|
error instanceof Error ? error.message : translate('::IslemBasarisiz'),
|
||||||
{translate('::IslemBasarisiz')}
|
|
||||||
</Notification>,
|
|
||||||
{ placement: 'bottom-end' },
|
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ values, isSubmitting }) => (
|
{({ values, errors, touched, isSubmitting, setFieldValue }) => (
|
||||||
<Form>
|
<Form>
|
||||||
<FormContainer>
|
<FormContainer>
|
||||||
<FormItem label="Code *" className="mb-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4">
|
||||||
<Field
|
<FormItem
|
||||||
type="text"
|
label="Code *"
|
||||||
name="code"
|
className="mb-2"
|
||||||
component={Input}
|
invalid={Boolean(errors.code && touched.code)}
|
||||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
errorMessage={errors.code as string}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{({ field, form }: FieldProps<SelectBoxOption>) => (
|
<Field
|
||||||
<Select
|
autoFocus
|
||||||
field={field}
|
type="text"
|
||||||
form={form}
|
name="code"
|
||||||
isClearable={true}
|
component={Input}
|
||||||
options={permissions}
|
className={inputClass}
|
||||||
value={permissions?.filter(
|
disabled={modalMode === 'edit' && hasChildren}
|
||||||
(option) => option.value === values.requiredPermissionName,
|
/>
|
||||||
)}
|
</FormItem>
|
||||||
onChange={(option) => form.setFieldValue(field.name, option?.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</FormItem>
|
|
||||||
|
|
||||||
<FormItem label="Target" className="mb-2">
|
<FormItem
|
||||||
<Field
|
label="Display Name *"
|
||||||
type="text"
|
className="mb-2"
|
||||||
name="target"
|
invalid={Boolean(errors.displayName && touched.displayName)}
|
||||||
component={Input}
|
errorMessage={errors.displayName as string}
|
||||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
>
|
||||||
/>
|
<Field
|
||||||
</FormItem>
|
type="text"
|
||||||
|
name="displayName"
|
||||||
|
component={Input}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
<FormItem label="Element Id" className="mb-2">
|
<FormItem label="URL" className="mb-2">
|
||||||
<Field
|
<Field type="text" name="url" component={Input} className={inputClass} />
|
||||||
type="text"
|
</FormItem>
|
||||||
name="elementId"
|
|
||||||
component={Input}
|
|
||||||
className="h-8 text-sm px-2 dark:bg-gray-900 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
</FormItem>
|
|
||||||
|
|
||||||
<FormItem label="Is Disabled" className="mb-2">
|
<FormItem
|
||||||
<Field name="isDisabled" component={Switcher} />
|
label="Order *"
|
||||||
</FormItem>
|
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">
|
<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')}
|
{translate('::Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="solid" size="sm" loading={isSubmitting}>
|
<Button type="submit" variant="solid" size="sm" loading={isSubmitting}>
|
||||||
|
|
@ -418,3 +556,5 @@ export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MenuItemComponent = memo(MenuItemComponentBase)
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,96 @@
|
||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useMemo, 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 { Helmet } from 'react-helmet'
|
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 { 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 = () => {
|
export const MenuManager = () => {
|
||||||
const { menuItems, setMenuItems, loading, error, refetch, saveMenuData } = useMenuData()
|
const { menuItems, setMenuItems, loading, error, isDirty, refetch, saveMenuData } = useMenuData()
|
||||||
const [isDesignMode, setIsDesignMode] = useState(true)
|
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
|
||||||
const [expansionCommand, setExpansionCommand] = useState({ expanded: true, version: 0 })
|
|
||||||
const { translate } = useLocalization()
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!isDesignMode) return
|
if (!isDesignMode || isSaving) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
|
||||||
await saveMenuData(menuItems)
|
await saveMenuData(menuItems)
|
||||||
setIsDesignMode(false)
|
toast.push(
|
||||||
|
<Notification title={translate('::Success')} type="success">
|
||||||
|
{translate('::KayitGuncellendi')}
|
||||||
|
</Notification>,
|
||||||
|
{ placement: 'bottom-end' },
|
||||||
|
)
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleDesignMode = () => {
|
const handleToggleExpand = useCallback((id: string) => {
|
||||||
setIsDesignMode((current) => !current)
|
setCollapsedIds((current) => {
|
||||||
}
|
const next = new Set(current)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const setAllExpanded = (expanded: boolean) => {
|
const setAllExpanded = (expanded: boolean) =>
|
||||||
setExpansionCommand((current) => ({ expanded, version: current.version + 1 }))
|
setCollapsedIds(expanded ? new Set() : new Set(collectBranchIds(menuItems)))
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3 text-gray-600 dark:text-gray-300">
|
||||||
<FaSpinner className="animate-spin" />
|
<FaSpinner className="animate-spin" />
|
||||||
<span className="text-lg">Loading menu configuration...</span>
|
<span className="text-lg">{translate('::LoadingWithThreeDot')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -58,20 +98,15 @@ export const MenuManager = () => {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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="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">
|
<div className="flex items-center gap-3 text-red-600 dark:text-red-400 mb-4">
|
||||||
<FaRegBell size={24} />
|
<FaRegBell size={24} />
|
||||||
<h2 className="text-lg font-semibold">Error Loading Menu</h2>
|
<h2 className="text-lg font-semibold">{translate('::Error')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-6">{error}</p>
|
<p className="text-gray-600 dark:text-gray-300 mb-6 break-words">{error}</p>
|
||||||
<Button
|
<Button block variant="solid" icon={<FaSyncAlt />} onClick={refetch}>
|
||||||
block
|
{translate('::Refresh')}
|
||||||
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
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -82,30 +117,59 @@ export const MenuManager = () => {
|
||||||
<Container>
|
<Container>
|
||||||
<Helmet
|
<Helmet
|
||||||
titleTemplate={`%s | ${APP_NAME}`}
|
titleTemplate={`%s | ${APP_NAME}`}
|
||||||
title={translate('::' + 'App.Menus.Manager')}
|
title={translate('::App.Menus.Manager')}
|
||||||
defaultTitle={APP_NAME}
|
defaultTitle={APP_NAME}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded px-2 sm:px-2 lg:px-3 py-3">
|
<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-4">
|
<div className="flex items-center justify-between mb-2 flex-wrap gap-3">
|
||||||
{/* Sol kısım: Başlık */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FaBars size={20} className="text-gray-600 dark:text-gray-300" />
|
<FaBars size={20} className="text-gray-600 dark:text-gray-300" />
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Menu Manager
|
{translate('::App.Menus.Manager')}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
({menuItems.length} root items)
|
({menuItems.length} / {totalCount})
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sağ kısım: Design Mode + Save butonu */}
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-4">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-medium ${isDesignMode ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}`}
|
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>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -115,15 +179,15 @@ export const MenuManager = () => {
|
||||||
aria-checked={isDesignMode}
|
aria-checked={isDesignMode}
|
||||||
aria-label="Toggle design mode"
|
aria-label="Toggle design mode"
|
||||||
className={`
|
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
|
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'}
|
${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}
|
onClick={() => setIsDesignMode((current) => !current)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`
|
className={`
|
||||||
inline-block h-4 w-4 transform rounded-full bg-white dark:bg-gray-200 transition-transform duration-200 ease-in-out
|
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'}
|
${isDesignMode ? 'translate-x-6' : 'translate-x-1'}
|
||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -134,7 +198,7 @@ export const MenuManager = () => {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
className="!h-auto !rounded-lg !px-2 !py-1"
|
className="!h-auto !rounded-lg !px-2 !py-1"
|
||||||
icon={<FaChevronRight size={10} />}
|
icon={<FaChevronDown size={10} />}
|
||||||
onClick={() => setAllExpanded(true)}
|
onClick={() => setAllExpanded(true)}
|
||||||
>
|
>
|
||||||
{translate('::ListForms.ListFormEdit.ExpandAll')}
|
{translate('::ListForms.ListFormEdit.ExpandAll')}
|
||||||
|
|
@ -143,7 +207,7 @@ export const MenuManager = () => {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
className="!h-auto !rounded-lg !px-2 !py-1"
|
className="!h-auto !rounded-lg !px-2 !py-1"
|
||||||
icon={<FaChevronDown size={10} />}
|
icon={<FaChevronRight size={10} />}
|
||||||
onClick={() => setAllExpanded(false)}
|
onClick={() => setAllExpanded(false)}
|
||||||
>
|
>
|
||||||
{translate('::ListForms.ListFormEdit.CollapseAll')}
|
{translate('::ListForms.ListFormEdit.CollapseAll')}
|
||||||
|
|
@ -151,44 +215,50 @@ export const MenuManager = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<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"
|
size="sm"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
className={`
|
disabled={!isDesignMode || !isDirty || isSaving}
|
||||||
!inline-flex !h-auto !items-center !justify-center gap-2 !rounded-lg !px-2 !py-1 transition-colors
|
loading={isSaving}
|
||||||
${isDesignMode ? 'text-white' : 'cursor-not-allowed'}
|
icon={<FaRegSave size={10} />}
|
||||||
${isSaving ? 'opacity-50' : ''}
|
className="!h-auto !rounded-lg !px-2 !py-1"
|
||||||
`}
|
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
{isSaving ? (
|
{isSaving ? translate('::Saving') : translate('::App.SaveChanges')}
|
||||||
<>
|
|
||||||
<FaSpinner size={10} className="animate-spin" />
|
|
||||||
{translate('::Saving')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FaRegSave size={10} />
|
|
||||||
{translate('::App.SaveChanges')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<SortableMenuTree
|
||||||
expansionCommand={expansionCommand}
|
collapsedIds={collapsedIds}
|
||||||
items={menuItems}
|
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
|
isFiltered={isFiltered}
|
||||||
|
items={visibleItems}
|
||||||
|
matchedIds={matchedIds}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
|
sourceItems={menuItems}
|
||||||
onItemsChange={setMenuItems}
|
onItemsChange={setMenuItems}
|
||||||
|
onToggleExpand={handleToggleExpand}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
<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" />
|
<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-lg">{translate('::NoDataFound')}</p>
|
||||||
<p className="text-sm">Try refreshing the page or contact your administrator</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,248 +1,218 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
closestCenter,
|
DragEndEvent,
|
||||||
|
DragStartEvent,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
DragStartEvent,
|
|
||||||
DragEndEvent,
|
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable'
|
} 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 { MenuItem } from '@/proxy/menus/menu'
|
||||||
import { getPermissionsList } from '@/services/identity.service'
|
|
||||||
import { PermissionDefinitionRecord } from '@/proxy/admin/models'
|
import { PermissionDefinitionRecord } from '@/proxy/admin/models'
|
||||||
|
import { getPermissionsList } from '@/services/identity.service'
|
||||||
import { SelectBoxOption } from '@/types/shared'
|
import { SelectBoxOption } from '@/types/shared'
|
||||||
|
|
||||||
interface SortableMenuTreeProps {
|
interface SortableMenuTreeProps {
|
||||||
|
/** Ekranda gösterilen (filtrelenmiş olabilir) ağaç */
|
||||||
items: MenuItem[]
|
items: MenuItem[]
|
||||||
|
/** Taşıma işlemlerinin uygulanacağı tam ağaç */
|
||||||
|
sourceItems: MenuItem[]
|
||||||
onItemsChange: (items: MenuItem[]) => void
|
onItemsChange: (items: MenuItem[]) => void
|
||||||
isDesignMode: boolean
|
isDesignMode: boolean
|
||||||
|
isFiltered?: boolean
|
||||||
|
matchedIds?: Set<string>
|
||||||
|
collapsedIds: Set<string>
|
||||||
|
onToggleExpand: (id: string) => void
|
||||||
refetch: () => 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> = ({
|
export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
|
||||||
items,
|
items,
|
||||||
|
sourceItems,
|
||||||
onItemsChange,
|
onItemsChange,
|
||||||
isDesignMode,
|
isDesignMode,
|
||||||
|
isFiltered = false,
|
||||||
|
matchedIds,
|
||||||
|
collapsedIds,
|
||||||
|
onToggleExpand,
|
||||||
refetch,
|
refetch,
|
||||||
expansionCommand,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [permissions, setPermissions] = useState<SelectBoxOption[]>([])
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
activationConstraint: {
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
const fetchPermissions = async () => {
|
let cancelled = false
|
||||||
const response = await getPermissionsList()
|
|
||||||
if (response.data) {
|
|
||||||
setPermissions(
|
|
||||||
response.data.map((p: PermissionDefinitionRecord) => ({
|
|
||||||
value: p.name,
|
|
||||||
label: p.name,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div key={item.id} className="bg-white dark:bg-gray-800 rounded-md">
|
<div key={id || `${depth}-${index}`} className="rounded-md">
|
||||||
<MenuItemComponent
|
<SortableMenuRow
|
||||||
item={item}
|
canIndent={isDesignMode && !isFiltered && index > 0}
|
||||||
isDesignMode={isDesignMode}
|
canOutdent={isDesignMode && !isFiltered && depth > 0}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
refetch={refetch}
|
disabled={!dragEnabled}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isHighlighted={Boolean(matchedIds?.has(id))}
|
||||||
|
item={item}
|
||||||
permissions={permissions}
|
permissions={permissions}
|
||||||
expansionCommand={expansionCommand}
|
refetch={refetch}
|
||||||
|
onIndent={handleIndent}
|
||||||
|
onOutdent={handleOutdent}
|
||||||
|
onToggleExpand={onToggleExpand}
|
||||||
>
|
>
|
||||||
{Array.isArray(item.children) && item.children.length > 0 && (
|
{children.length > 0 && (
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={item.children
|
items={children.map((child) => child.id ?? '').filter(Boolean)}
|
||||||
.filter((child): child is MenuItem & { id: string } => !!child.id)
|
|
||||||
.map((child) => child.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="ml-4 border-gray-200 dark:border-gray-700">
|
<div className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-1">
|
||||||
{item.children.map((child) => renderMenuItem(child, depth + 1))}
|
{children.map((child, childIndex) =>
|
||||||
|
renderMenuItem(child, depth + 1, childIndex),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
)}
|
)}
|
||||||
</MenuItemComponent>
|
</SortableMenuRow>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootIds = useMemo(
|
||||||
|
() => items.map((item) => item.id ?? '').filter(Boolean),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
|
sensors={sensors}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
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>
|
<DragOverlay>
|
||||||
{activeItem ? (
|
{activeItem && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-md shadow-lg">
|
<div className="bg-white dark:bg-gray-800 rounded-md shadow-lg">
|
||||||
<MenuItemComponent
|
<MenuItemComponent
|
||||||
item={activeItem}
|
isDragOverlay
|
||||||
isDesignMode={isDesignMode}
|
|
||||||
depth={0}
|
depth={0}
|
||||||
isDragOverlay={true}
|
isDesignMode={isDesignMode}
|
||||||
refetch={refetch}
|
item={activeItem}
|
||||||
permissions={permissions}
|
permissions={permissions}
|
||||||
expansionCommand={expansionCommand}
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</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