sozsoft-platform/ui/src/views/menu/MenuItemComponent.tsx
2026-05-24 18:48:55 +03:00

360 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { 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 { 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(),
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 getCreateInitialValues = (): Partial<MenuDto> => ({
code: '',
displayName: '',
order: (item.children?.length || 0) + 1,
parentCode: item.code,
url: '',
icon: '',
cssClass: '',
requiredPermissionName: '',
target: '',
isDisabled: false,
elementId: '',
})
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())
const toggleExpanded = () => {
if (!isDesignMode) setIsExpanded(!isExpanded)
}
const openCreateModal = () => {
setModalMode('create')
setFormData(getCreateInitialValues())
setIsModalOpen(true)
}
const openEditModal = (event: React.MouseEvent) => {
if (!isDesignMode) return
event.stopPropagation()
setModalMode('edit')
setFormData(getEditInitialValues())
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()
}
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 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' : ''}
${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'}
`}
{...(isDesignMode ? { ...attributes, ...listeners } : { onClick: toggleExpanded })}
>
{isDesignMode && (
<div className="flex gap-2 items-center mr-2">
<button onClick={openCreateModal} title="New Item">
<FaPlus size={16} className="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300" />
</button>
<button onClick={handleDelete} title="Delete Item">
<FaTrashAlt size={16} className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300" />
</button>
</div>
)}
<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>
<button
type="button"
onClick={openEditModal}
className={`
truncate text-gray-800 dark:text-gray-100 leading-6 text-sm text-left
${item.children && item.children.length > 0 ? 'font-semibold' : 'font-normal'}
${isDesignMode ? 'hover:text-blue-600 dark:hover:text-blue-400' : ''}
`}
>
{translate('::' + item.displayName)}
</button>
{item.url && <FaExternalLinkAlt size={12} className="flex-shrink-0 text-gray-400 dark:text-gray-500" />}
</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>
)}
{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}
</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}
>
<h5 className="mb-4 dark:text-gray-100">
{modalMode === 'edit' ? translate('::Edit Menu Item') : translate('::New Item')}
</h5>
<Formik
validationSchema={validationSchema}
initialValues={formData}
enableReinitialize
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: 'top-end' },
)
setIsModalOpen(false)
refetch()
} catch (error) {
toast.push(
<Notification title="Hata" type="danger">
{translate('::IslemBasarisiz')}
</Notification>,
{ placement: 'top-end' },
)
} finally {
setSubmitting(false)
}
}}
>
{({ values, isSubmitting }) => (
<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"
>
{({ 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 dark:bg-gray-900 dark:text-gray-100"
/>
</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="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}>
{modalMode === 'edit' ? translate('::Save') : translate('::Create')}
</Button>
</div>
</FormContainer>
</Form>
)}
</Formik>
</Dialog>
)}
</div>
)
}