2026-03-17 19:56:34 +00:00
|
|
|
|
import React, { useState } from 'react'
|
2026-02-24 20:44:16 +00:00
|
|
|
|
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 { FaQuestionCircle } from 'react-icons/fa'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
FormContainer,
|
|
|
|
|
|
FormItem,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Switcher,
|
|
|
|
|
|
Notification,
|
|
|
|
|
|
toast,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
} from '@/components/ui'
|
|
|
|
|
|
import { Field, FieldProps, Form, Formik } from 'formik'
|
|
|
|
|
|
import { SelectBoxOption } from '@/types/shared'
|
|
|
|
|
|
import * as Yup from 'yup'
|
|
|
|
|
|
import { FaExternalLinkAlt, FaPlus, FaTrashAlt } from 'react-icons/fa'
|
|
|
|
|
|
import { MenuDto } from '@/proxy/menus/models'
|
|
|
|
|
|
|
|
|
|
|
|
interface MenuItemComponentProps {
|
|
|
|
|
|
item: MenuItem
|
|
|
|
|
|
isDesignMode: boolean
|
|
|
|
|
|
depth: number
|
|
|
|
|
|
children?: React.ReactNode
|
|
|
|
|
|
isDragOverlay?: boolean
|
|
|
|
|
|
refetch: () => void
|
|
|
|
|
|
permissions: SelectBoxOption[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const MenuItemComponent: React.FC<MenuItemComponentProps> = ({
|
|
|
|
|
|
item,
|
|
|
|
|
|
isDesignMode,
|
|
|
|
|
|
depth,
|
|
|
|
|
|
children,
|
|
|
|
|
|
isDragOverlay = false,
|
|
|
|
|
|
refetch,
|
|
|
|
|
|
permissions,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
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('Code is required'),
|
|
|
|
|
|
displayName: Yup.string().required('Display Name is required'),
|
|
|
|
|
|
order: Yup.number().typeError('Order must be a number').required('Order is 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)
|
2026-03-17 19:56:34 +00:00
|
|
|
|
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create')
|
|
|
|
|
|
|
|
|
|
|
|
const getCreateInitialValues = (): Partial<MenuDto> => ({
|
2026-02-24 20:44:16 +00:00
|
|
|
|
code: '',
|
|
|
|
|
|
displayName: '',
|
|
|
|
|
|
order: (item.children?.length || 0) + 1,
|
|
|
|
|
|
parentCode: item.code,
|
|
|
|
|
|
url: '',
|
|
|
|
|
|
icon: '',
|
|
|
|
|
|
cssClass: '',
|
|
|
|
|
|
requiredPermissionName: '',
|
|
|
|
|
|
target: '',
|
|
|
|
|
|
isDisabled: false,
|
|
|
|
|
|
elementId: '',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-17 19:56:34 +00:00
|
|
|
|
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 [formData, setFormData] = useState<Partial<MenuDto>>(getCreateInitialValues())
|
|
|
|
|
|
|
2026-02-24 20:44:16 +00:00
|
|
|
|
const toggleExpanded = () => {
|
|
|
|
|
|
if (!isDesignMode) setIsExpanded(!isExpanded)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 19:56:34 +00:00
|
|
|
|
const openCreateModal = () => {
|
|
|
|
|
|
setModalMode('create')
|
|
|
|
|
|
setFormData(getCreateInitialValues())
|
|
|
|
|
|
setIsModalOpen(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openEditModal = (event: React.MouseEvent) => {
|
|
|
|
|
|
if (!isDesignMode) return
|
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
|
setModalMode('edit')
|
|
|
|
|
|
setFormData(getEditInitialValues())
|
|
|
|
|
|
setIsModalOpen(true)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = async () => {
|
|
|
|
|
|
const confirmed = window.confirm(`Delete "${item.displayName}"?`)
|
|
|
|
|
|
if (!confirmed) return
|
|
|
|
|
|
const menuService = new MenuService()
|
|
|
|
|
|
await menuService.delete(item.id!)
|
|
|
|
|
|
refetch()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="select-none">
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
|
style={style}
|
|
|
|
|
|
className={`
|
|
|
|
|
|
flex items-center gap-1 p-1 rounded-lg transition-all duration-200 group min-h-[30px]
|
|
|
|
|
|
${isDesignMode ? 'cursor-move hover:bg-blue-50 border border-transparent hover:border-blue-200' : 'cursor-pointer hover:bg-gray-50'}
|
|
|
|
|
|
${isDragOverlay ? 'shadow-lg bg-white border border-blue-300 z-50' : ''}
|
|
|
|
|
|
${isDragging ? 'opacity-50' : ''}
|
|
|
|
|
|
${item.children && item.children.length > 0 ? 'bg-blue-50' : depth === 0 ? 'bg-white' : 'bg-gray-50'}
|
|
|
|
|
|
`}
|
|
|
|
|
|
{...(isDesignMode ? { ...attributes, ...listeners } : { onClick: toggleExpanded })}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isDesignMode && (
|
|
|
|
|
|
<div className="flex gap-2 items-center mr-2">
|
2026-03-17 19:56:34 +00:00
|
|
|
|
<button onClick={openCreateModal} title="New Item">
|
2026-02-24 20:44:16 +00:00
|
|
|
|
<FaPlus size={16} className="text-green-600 hover:text-green-800" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onClick={handleDelete} title="Delete Item">
|
|
|
|
|
|
<FaTrashAlt size={16} className="text-red-600 hover:text-red-800" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex-shrink-0 text-gray-600 text-xl">
|
|
|
|
|
|
{navigationIcon[item.icon || ''] ? (
|
|
|
|
|
|
React.createElement(navigationIcon[item.icon || ''], { className: 'text-gray-400' })
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FaQuestionCircle className="text-gray-400" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-17 19:56:34 +00:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={openEditModal}
|
2026-02-24 20:44:16 +00:00
|
|
|
|
className={`
|
2026-03-17 19:56:34 +00:00
|
|
|
|
truncate text-gray-800 leading-6 text-sm text-left
|
2026-02-24 20:44:16 +00:00
|
|
|
|
${item.children && item.children.length > 0 ? 'font-semibold' : 'font-normal'}
|
2026-03-17 19:56:34 +00:00
|
|
|
|
${isDesignMode ? 'hover:text-blue-600' : ''}
|
2026-02-24 20:44:16 +00:00
|
|
|
|
`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{translate('::' + item.displayName)}
|
2026-03-17 19:56:34 +00:00
|
|
|
|
</button>
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
|
|
|
|
|
{item.url && <FaExternalLinkAlt size={12} className="flex-shrink-0 text-gray-400" />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
|
|
|
|
{isDesignMode && (
|
|
|
|
|
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|
|
|
|
|
<span className="bg-gray-200 px-2 py-1 rounded">#{item.order}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{item.children && item.children.length > 0 && (
|
|
|
|
|
|
<span className="text-xs text-gray-500 bg-blue-100 px-2 py-1 rounded-full">
|
|
|
|
|
|
{item.children.length}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{children && (!isDesignMode ? isExpanded : true) && <div className="mt-1">{children}</div>}
|
|
|
|
|
|
|
|
|
|
|
|
{isModalOpen && (
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
isOpen={isModalOpen}
|
|
|
|
|
|
onClose={() => setIsModalOpen(false)}
|
|
|
|
|
|
onRequestClose={() => setIsModalOpen(false)}
|
|
|
|
|
|
width={600}
|
|
|
|
|
|
>
|
2026-03-17 19:56:34 +00:00
|
|
|
|
<h5 className="mb-4">
|
|
|
|
|
|
{modalMode === 'edit' ? translate('::Edit Menu Item') : translate('::New Item')}
|
|
|
|
|
|
</h5>
|
2026-02-24 20:44:16 +00:00
|
|
|
|
<Formik
|
|
|
|
|
|
validationSchema={validationSchema}
|
|
|
|
|
|
initialValues={formData}
|
|
|
|
|
|
enableReinitialize
|
|
|
|
|
|
onSubmit={async (values, { setSubmitting }) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const menuService = new MenuService()
|
2026-03-17 19:56:34 +00:00
|
|
|
|
if (modalMode === 'edit' && item.id) {
|
|
|
|
|
|
await menuService.update(item.id, values as MenuDto)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await menuService.create(values as MenuDto)
|
|
|
|
|
|
}
|
2026-02-24 20:44:16 +00:00
|
|
|
|
toast.push(
|
|
|
|
|
|
<Notification title="Başarılı" type="success">
|
2026-03-17 19:56:34 +00:00
|
|
|
|
{modalMode === 'edit' ? translate('::KayitGuncellendi') : translate('::KayitEklendi')}
|
2026-02-24 20:44:16 +00:00
|
|
|
|
</Notification>,
|
|
|
|
|
|
{ placement: 'top-end' },
|
|
|
|
|
|
)
|
|
|
|
|
|
setIsModalOpen(false)
|
|
|
|
|
|
refetch()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.push(
|
|
|
|
|
|
<Notification title="Hata" type="danger">
|
|
|
|
|
|
{translate('::IslemBasarisiz')}
|
|
|
|
|
|
</Notification>,
|
|
|
|
|
|
{ placement: 'top-end' },
|
|
|
|
|
|
)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-03-17 19:56:34 +00:00
|
|
|
|
{({ values, isSubmitting }) => (
|
2026-02-24 20:44:16 +00:00
|
|
|
|
<Form>
|
|
|
|
|
|
<FormContainer>
|
|
|
|
|
|
<FormItem label="Code *" className="mb-2">
|
|
|
|
|
|
<Field
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
name="code"
|
|
|
|
|
|
component={Input}
|
|
|
|
|
|
className="h-8 text-sm px-2"
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormItem>
|
|
|
|
|
|
|
|
|
|
|
|
<FormItem label="Display Name *" className="mb-2">
|
|
|
|
|
|
<Field
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
name="displayName"
|
|
|
|
|
|
component={Input}
|
|
|
|
|
|
className="h-8 text-sm px-2"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormItem>
|
|
|
|
|
|
|
|
|
|
|
|
<FormItem label="Order *" className="mb-2">
|
|
|
|
|
|
<Field
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
name="order"
|
|
|
|
|
|
component={Input}
|
|
|
|
|
|
className="h-8 text-sm px-2"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormItem>
|
|
|
|
|
|
|
|
|
|
|
|
<FormItem label="URL" className="mb-2">
|
|
|
|
|
|
<Field type="text" name="url" component={Input} className="h-8 text-sm px-2" />
|
|
|
|
|
|
</FormItem>
|
|
|
|
|
|
|
|
|
|
|
|
<FormItem label="Icon" className="mb-2">
|
|
|
|
|
|
<Field type="text" name="icon" component={Input} className="h-8 text-sm px-2" />
|
|
|
|
|
|
</FormItem>
|
|
|
|
|
|
|
|
|
|
|
|
<FormItem label="Parent Code" className="mb-2">
|
2026-03-17 19:56:34 +00:00
|
|
|
|
<Input
|
|
|
|
|
|
disabled
|
|
|
|
|
|
value={values.parentCode || ''}
|
|
|
|
|
|
className="h-8 text-sm px-2 bg-gray-100"
|
|
|
|
|
|
/>
|
2026-02-24 20:44:16 +00:00
|
|
|
|
</FormItem>
|
|
|
|
|
|
|
|
|
|
|
|
<FormItem label="CSS Class" className="mb-2">
|
|
|
|
|
|
<Field
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
name="cssClass"
|
|
|
|
|
|
component={Input}
|
|
|
|
|
|
className="h-8 text-sm px-2"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormItem>
|
|
|
|
|
|
|
|
|
|
|
|
<FormItem label="Permission Name" className="mb-2">
|
|
|
|
|
|
<Field
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
|
name="requiredPermissionName"
|
|
|
|
|
|
className="h-8 text-sm px-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
{({ 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>
|
|
|
|
|
|
|
|
|
|
|
|
<FormItem label="Target" className="mb-2">
|
|
|
|
|
|
<Field
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
name="target"
|
|
|
|
|
|
component={Input}
|
|
|
|
|
|
className="h-8 text-sm px-2"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormItem>
|
|
|
|
|
|
|
|
|
|
|
|
<FormItem label="Element Id" className="mb-2">
|
|
|
|
|
|
<Field
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
name="elementId"
|
|
|
|
|
|
component={Input}
|
|
|
|
|
|
className="h-8 text-sm px-2"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FormItem>
|
|
|
|
|
|
|
|
|
|
|
|
<FormItem label="Is Disabled" className="mb-2">
|
|
|
|
|
|
<Field name="isDisabled" component={Switcher} />
|
|
|
|
|
|
</FormItem>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
|
|
|
|
<Button variant="plain" size="sm" onClick={() => setIsModalOpen(false)}>
|
|
|
|
|
|
{translate('::Cancel')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button type="submit" variant="solid" size="sm" loading={isSubmitting}>
|
2026-03-17 19:56:34 +00:00
|
|
|
|
{modalMode === 'edit' ? translate('::Save') : translate('::Create')}
|
2026-02-24 20:44:16 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FormContainer>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Formik>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|