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

View file

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

View file

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

View file

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

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