Claude güncellemesi MenuManager güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-11 13:29:41 +03:00
parent 3ecb4e2102
commit 5626c27a90
5 changed files with 1048 additions and 623 deletions

View file

@ -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,
}

View file

@ -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)

View file

@ -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>

View file

@ -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>
)

View 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('|')