sozsoft-platform/ui/src/views/menu/MenuItemComponent.tsx

323 lines
11 KiB
TypeScript
Raw Normal View History

2026-02-24 20:44:16 +00:00
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 { 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)
const [formData, setFormData] = useState<Partial<MenuDto>>({
code: '',
displayName: '',
order: (item.children?.length || 0) + 1,
parentCode: item.code,
url: '',
icon: '',
cssClass: '',
requiredPermissionName: '',
target: '',
isDisabled: false,
elementId: '',
})
const toggleExpanded = () => {
if (!isDesignMode) setIsExpanded(!isExpanded)
}
const handleCreate = async () => {
const menuService = new MenuService()
await menuService.create(formData as MenuDto)
setIsModalOpen(false)
refetch()
}
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">
<button onClick={() => setIsModalOpen(true)} title="New Item">
<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>
<span
className={`
truncate text-gray-800 leading-6 text-sm
${item.children && item.children.length > 0 ? 'font-semibold' : 'font-normal'}
`}
>
{translate('::' + item.displayName)}
</span>
{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}
>
<h5 className="mb-4">{translate('::New Item')}</h5>
<Formik
validationSchema={validationSchema}
initialValues={formData}
enableReinitialize
onSubmit={async (values, { setSubmitting }) => {
try {
const menuService = new MenuService()
await menuService.create(values as MenuDto)
toast.push(
<Notification title="Başarılı" type="success">
{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, errors, touched, isSubmitting, handleChange }) => (
<Form>
<FormContainer>
<FormItem label="Code *" className="mb-2">
<Field
type="text"
au
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">
<Input disabled value={item.code} className="h-8 text-sm px-2 bg-gray-100" />
</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}>
{translate('::Create')}
</Button>
</div>
</FormContainer>
</Form>
)}
</Formik>
</Dialog>
)}
</div>
)
}