base create and list
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
VITE_BASE_URL = 'https://api.shinan.ir'
|
VITE_BASE_URL = 'https://api.shinan.ir'
|
||||||
VITE_TOKEN_NAME = 'sh_admin_token'
|
VITE_TOKEN_NAME = 'sh_admin_token'
|
||||||
VITE_REFRESH_TOKEN_NAME = 'sh_admin_token'
|
VITE_REFRESH_TOKEN_NAME = 'sh_admin_refresh_token'
|
||||||
@@ -6,24 +6,42 @@ import { clx } from '../helpers/utils'
|
|||||||
type Props = {
|
type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoading?: boolean,
|
isLoading?: boolean,
|
||||||
|
variant?: 'primary' | 'outline' | 'ghost' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
} & ButtonHTMLAttributes<HTMLButtonElement> &
|
} & ButtonHTMLAttributes<HTMLButtonElement> &
|
||||||
XOR<{ children: ReactNode }, { label: string }>;
|
XOR<{ children: ReactNode }, { label: string }>;
|
||||||
|
|
||||||
const Button: FC<Props> = memo((props: Props) => {
|
const Button: FC<Props> = memo((props) => {
|
||||||
|
const { variant = 'primary', size = 'md', className, ...restProps } = props;
|
||||||
|
|
||||||
const buttonClass = clx(
|
const buttonClass = clx(
|
||||||
'flex rounded-xl items-center justify-center text-center h-10 text-sm bg-primary text-white w-full',
|
'flex rounded-xl items-center justify-center text-center font-medium transition-colors',
|
||||||
props.disabled && 'cursor-not-allowed opacity-60',
|
// Variant styles
|
||||||
props.className
|
variant === 'primary' && 'bg-primary text-white hover:bg-primary/90',
|
||||||
|
variant === 'outline' && 'bg-transparent border border-gray-300 text-gray-700 hover:bg-gray-50 hover:border-gray-400',
|
||||||
|
variant === 'ghost' && 'bg-transparent text-gray-700 hover:bg-gray-100',
|
||||||
|
variant === 'danger' && 'bg-red-500 text-white hover:bg-red-600',
|
||||||
|
// Size styles
|
||||||
|
size === 'sm' && 'h-8 px-3 text-xs',
|
||||||
|
size === 'md' && 'h-10 px-4 text-sm',
|
||||||
|
size === 'lg' && 'h-12 px-6 text-base',
|
||||||
|
// Disabled state
|
||||||
|
restProps.disabled && 'cursor-not-allowed opacity-60',
|
||||||
|
// Default width
|
||||||
|
'w-full'
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button disabled={props.isLoading} {...props} className={`${buttonClass} ${props.className}`} >
|
<button
|
||||||
|
disabled={restProps.isLoading}
|
||||||
|
{...restProps}
|
||||||
|
className={clx(buttonClass, className)}
|
||||||
|
>
|
||||||
{
|
{
|
||||||
props.isLoading ?
|
restProps.isLoading ?
|
||||||
<MoonLoader color="white" size={16} />
|
<MoonLoader color={variant === 'primary' || variant === 'danger' ? "white" : "#6B7280"} size={16} />
|
||||||
:
|
:
|
||||||
props.label || props.children
|
restProps.label || restProps.children
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Fragment } from 'react'
|
||||||
|
import { Switch } from '@headlessui/react'
|
||||||
|
import ReactLoading from 'react-spinners/MoonLoader'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
active: boolean,
|
||||||
|
onChange: (value: boolean) => void,
|
||||||
|
isLoading?: boolean,
|
||||||
|
label?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SwitchComponent = (props: Props) => {
|
||||||
|
|
||||||
|
const handleChange = (value: boolean) => {
|
||||||
|
props.onChange(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.isLoading) {
|
||||||
|
return (
|
||||||
|
<ReactLoading color='black' size={20} className='ml-4' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='dltr w-fit flex gap-2 items-center'>
|
||||||
|
<Switch checked={props.active} onChange={handleChange} as={Fragment}>
|
||||||
|
{({ checked }) => (
|
||||||
|
<button
|
||||||
|
className={`${checked ? 'bg-primary' : 'bg-gray-200'
|
||||||
|
} relative inline-flex h-6 w-11 items-center rounded-full`}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Enable notifications</span>
|
||||||
|
<span
|
||||||
|
className={`${checked ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
} inline-block h-4 w-4 transform rounded-full bg-white transition`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Switch>
|
||||||
|
{
|
||||||
|
props.label &&
|
||||||
|
<div className='text-sm'>
|
||||||
|
: {props.label}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SwitchComponent
|
||||||
@@ -39,3 +39,24 @@ textarea::placeholder {
|
|||||||
--width-sidebar: 300px;
|
--width-sidebar: 300px;
|
||||||
--border-radius-2.5: 10px;
|
--border-radius-2.5: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dltr {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowTwoInput {
|
||||||
|
@apply flex flex-col sm:flex-row sm:gap-5 gap-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowTwoInput {
|
||||||
|
@apply flex flex-col sm:flex-row sm:gap-5 gap-5;
|
||||||
|
}
|
||||||
|
.td {
|
||||||
|
@apply px-6 py-4 whitespace-nowrap text-xs;
|
||||||
|
}
|
||||||
|
.thead {
|
||||||
|
@apply h-[69px] bg-white text-sm text-[#8C90A3] rounded-3xl overflow-hidden;
|
||||||
|
}
|
||||||
|
.tr {
|
||||||
|
@apply w-full h-[74px] bg-white border-t border-[#EAEDF5] hover:bg-secondary;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
|
||||||
|
import { type FC, useState } from 'react'
|
||||||
|
import Button from '../../components/Button'
|
||||||
|
import { TickCircle, ArrowLeft } from 'iconsax-react'
|
||||||
|
import SwitchComponent from '../../components/Switch'
|
||||||
|
import Input from '../../components/Input'
|
||||||
|
import Textarea from '../../components/Textarea'
|
||||||
|
import UploadBox from '../../components/UploadBox'
|
||||||
|
import { type CategoryType, type CategoryTreeNode } from './types/Types'
|
||||||
|
import { useFormik } from 'formik'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
import { useCreateCategory, useGetCategories, useSingleUpload } from './hooks/useCategoryData'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
|
||||||
|
const CreateCategory: FC = () => {
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [iconFile, setIconFile] = useState<File>()
|
||||||
|
const [imageFile, setImageFile] = useState<File>()
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const singleUpload = useSingleUpload()
|
||||||
|
const [isActive, setIsActive] = useState(true)
|
||||||
|
|
||||||
|
const createCategoryMutation = useCreateCategory()
|
||||||
|
const getCategories = useGetCategories()
|
||||||
|
|
||||||
|
const formik = useFormik<CategoryType>({
|
||||||
|
initialValues: {
|
||||||
|
title_fa: '',
|
||||||
|
title_en: '',
|
||||||
|
icon: '',
|
||||||
|
imageUrl: '',
|
||||||
|
description: '',
|
||||||
|
parent: '',
|
||||||
|
},
|
||||||
|
validationSchema: Yup.object({
|
||||||
|
title_fa: Yup.string().required('عنوان فارسی دسته الزامی است'),
|
||||||
|
title_en: Yup.string().required('عنوان انگلیسی دسته الزامی است'),
|
||||||
|
description: Yup.string().required('توضیحات دسته الزامی است'),
|
||||||
|
parent: Yup.string().optional(),
|
||||||
|
}),
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
|
||||||
|
if (!iconFile || !imageFile) {
|
||||||
|
toast.error('آیکون و تصویر دسته الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconFile) {
|
||||||
|
const res = await singleUpload.mutateAsync(iconFile)
|
||||||
|
values.icon = res.results?.url?.url
|
||||||
|
|
||||||
|
}
|
||||||
|
if (imageFile) {
|
||||||
|
const response = await singleUpload.mutateAsync(imageFile)
|
||||||
|
values.imageUrl = response.results?.url?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
createCategoryMutation.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate('/admin/categories')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// تبدیل دادههای API به فرمت مورد نیاز
|
||||||
|
const categoriesData: CategoryTreeNode[] = getCategories.data?.results?.data || []
|
||||||
|
|
||||||
|
// فیلتر کردن دستهها بر اساس جستجو
|
||||||
|
const filteredCategories = categoriesData.filter(cat =>
|
||||||
|
cat.title_fa.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
cat.title_en.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleParentSelect = (category: CategoryTreeNode) => {
|
||||||
|
formik.setFieldValue('parent', category._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mt-4'>
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className='p-2 hover:bg-gray-100 rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h1 className=''>ساخت دسته جدید</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
className='px-6'
|
||||||
|
onClick={() => formik.handleSubmit()}
|
||||||
|
isLoading={createCategoryMutation.isPending}
|
||||||
|
disabled={!formik.isValid || createCategoryMutation.isPending}
|
||||||
|
>
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<TickCircle size={16} color='#fff' />
|
||||||
|
<div>ثبت دسته</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-6 xl:mt-8 mt-6'>
|
||||||
|
{/* اطلاعات اصلی */}
|
||||||
|
<div className='flex-1 text-sm bg-white py-8 xl:px-10 px-6 rounded-3xl'>
|
||||||
|
<h2 className='text-lg font-medium mb-6'>اطلاعات اصلی</h2>
|
||||||
|
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-gray-700'>وضعیت دسته</span>
|
||||||
|
<SwitchComponent
|
||||||
|
active={isActive}
|
||||||
|
onChange={setIsActive}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
|
||||||
|
<Input
|
||||||
|
label='عنوان فارسی دسته'
|
||||||
|
placeholder='مثال: الکترونیک'
|
||||||
|
{...formik.getFieldProps('title_fa')}
|
||||||
|
error_text={formik.touched.title_fa && formik.errors.title_fa ? formik.errors.title_fa : ''}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='عنوان انگلیسی دسته'
|
||||||
|
placeholder='مثال: Electronics'
|
||||||
|
{...formik.getFieldProps('title_en')}
|
||||||
|
error_text={formik.touched.title_en && formik.errors.title_en ? formik.errors.title_en : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label='توضیحات دسته'
|
||||||
|
placeholder='توضیحات کامل دسته را وارد کنید...'
|
||||||
|
{...formik.getFieldProps('description')}
|
||||||
|
error_text={formik.touched.description && formik.errors.description ? formik.errors.description : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ستون سمت راست */}
|
||||||
|
<div className='w-80 space-y-6'>
|
||||||
|
{/* آپلود فایلها */}
|
||||||
|
<div className='text-sm bg-white py-8 px-6 rounded-3xl'>
|
||||||
|
<h2 className='text-lg font-medium mb-6'>فایلها</h2>
|
||||||
|
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div>
|
||||||
|
<UploadBox
|
||||||
|
label='آیکون دسته'
|
||||||
|
onChange={(files) => setIconFile(files[0])}
|
||||||
|
/>
|
||||||
|
{formik.errors.icon && formik.touched.icon && (
|
||||||
|
<p className='text-red-500 text-xs mt-1'>{formik.errors.icon}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<UploadBox
|
||||||
|
label='تصویر دسته'
|
||||||
|
onChange={(files) => setImageFile(files[0])}
|
||||||
|
/>
|
||||||
|
{formik.errors.imageUrl && formik.touched.imageUrl && (
|
||||||
|
<p className='text-red-500 text-xs mt-1'>{formik.errors.imageUrl}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* انتخاب دسته والد */}
|
||||||
|
<div className='text-sm bg-white py-6 px-4 rounded-2xl'>
|
||||||
|
<h2 className='text-lg font-medium mb-4'>انتخاب دسته والد</h2>
|
||||||
|
|
||||||
|
{formik.values.parent && (
|
||||||
|
<div className='mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg'>
|
||||||
|
<div className='text-sm font-medium text-blue-700 mb-1'>دسته انتخاب شده:</div>
|
||||||
|
<div className='text-xs text-blue-600'>
|
||||||
|
{(() => {
|
||||||
|
const findCategory = (cats: CategoryTreeNode[], id: string): CategoryTreeNode | null => {
|
||||||
|
for (const cat of cats) {
|
||||||
|
if (cat._id === id) return cat
|
||||||
|
if (cat.children.length > 0) {
|
||||||
|
const found = findCategory(cat.children, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const selected = findCategory(categoriesData, formik.values.parent)
|
||||||
|
return selected ? selected.title_fa : 'نامشخص'
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* جستجو */}
|
||||||
|
<div className='mb-4'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='جستجو در دستهها...'
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className='w-full px-3 py-2.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white'
|
||||||
|
/>
|
||||||
|
<div className='absolute left-3 top-1/2 transform -translate-y-1/2'>
|
||||||
|
<svg className='w-4 h-4 text-gray-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||||
|
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* درخت دستهبندی */}
|
||||||
|
<div className='bg-gray-50 border border-gray-200 rounded-lg p-3 max-h-72 overflow-y-auto'>
|
||||||
|
{filteredCategories.length > 0 ? (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{filteredCategories.map((category: CategoryTreeNode) => (
|
||||||
|
<div key={category._id} className='space-y-1'>
|
||||||
|
{/* دسته اصلی */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 p-2.5 rounded-lg cursor-pointer transition-colors
|
||||||
|
${category._id === formik.values.parent
|
||||||
|
? 'bg-blue-100 border border-blue-300'
|
||||||
|
: 'hover:bg-gray-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => handleParentSelect(category)}
|
||||||
|
>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='flex items-center gap-2 mb-1'>
|
||||||
|
<span className={`text-sm font-medium ${category._id === formik.values.parent ? 'text-blue-700' : 'text-gray-900'}`}>
|
||||||
|
{category.title_fa}
|
||||||
|
</span>
|
||||||
|
{category.children.length > 0 && (
|
||||||
|
<span className='bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs'>
|
||||||
|
{category.children.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='text-xs text-gray-500'>{category.title_en}</div>
|
||||||
|
</div>
|
||||||
|
{category._id === formik.values.parent && (
|
||||||
|
<div className='flex-shrink-0'>
|
||||||
|
<div className='w-2 h-2 bg-blue-500 rounded-full'></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* زیردستهها - همیشه باز */}
|
||||||
|
{category.children.length > 0 && (
|
||||||
|
<div className='mr-4 space-y-1'>
|
||||||
|
{category.children.map((child: CategoryTreeNode) => (
|
||||||
|
<div
|
||||||
|
key={child._id}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 p-2.5 rounded-lg cursor-pointer transition-colors
|
||||||
|
${child._id === formik.values.parent
|
||||||
|
? 'bg-blue-100 border border-blue-300'
|
||||||
|
: 'hover:bg-gray-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => handleParentSelect(child)}
|
||||||
|
>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='flex items-center gap-2 mb-1'>
|
||||||
|
<span className={`text-sm font-medium ${child._id === formik.values.parent ? 'text-blue-700' : 'text-gray-800'}`}>
|
||||||
|
{child.title_fa}
|
||||||
|
</span>
|
||||||
|
{child.children.length > 0 && (
|
||||||
|
<span className='bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full text-xs'>
|
||||||
|
{child.children.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='text-xs text-gray-500'>{child.title_en}</div>
|
||||||
|
</div>
|
||||||
|
{child._id === formik.values.parent && (
|
||||||
|
<div className='flex-shrink-0'>
|
||||||
|
<div className='w-2 h-2 bg-blue-500 rounded-full'></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='text-center py-8 text-gray-500'>
|
||||||
|
<svg className='w-12 h-12 mx-auto mb-3 text-gray-300' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||||
|
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={1} d='M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z' />
|
||||||
|
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={1} d='M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z' />
|
||||||
|
</svg>
|
||||||
|
<p className='text-sm font-medium text-gray-400 mb-1'>
|
||||||
|
{searchTerm ? 'نتیجهای یافت نشد' : 'دستهای موجود نیست'}
|
||||||
|
</p>
|
||||||
|
<p className='text-xs text-gray-400'>
|
||||||
|
{searchTerm ? 'کلمه جستجو را تغییر دهید' : 'برای شروع، دستهای جدید ایجاد کنید'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateCategory
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import Td from '../../components/Td'
|
||||||
|
import { Eye } from 'iconsax-react'
|
||||||
|
import { Pages } from '../../config/Pages'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
const CategoryList: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className='relative overflow-x-auto rounded-3xl mt-9 w-full'>
|
||||||
|
<table className='w-full text-sm '>
|
||||||
|
<thead className='thead'>
|
||||||
|
<tr>
|
||||||
|
<Td text={'آیکون'} />
|
||||||
|
<Td text={'عنوان فارسی'} />
|
||||||
|
<Td text={'عنوان انگلیسی'} />
|
||||||
|
<Td text={'تصویر'} />
|
||||||
|
<Td text={'توضیحات'} />
|
||||||
|
<Td text={'والد'} />
|
||||||
|
<Td text={'عملیات'} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className='tr'>
|
||||||
|
<Td text={''} />
|
||||||
|
<Td text={'item.title_fa'} />
|
||||||
|
<Td text={'item.title_fa'} />
|
||||||
|
<Td text={'item.title_fa'} />
|
||||||
|
<Td text={'s'} />
|
||||||
|
<Td text={'asd'} />
|
||||||
|
<Td text={'asd'}>
|
||||||
|
<Link to={Pages.cardBank.detail}>
|
||||||
|
<Eye size={20} color='#8C90A3' />
|
||||||
|
</Link>
|
||||||
|
</Td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryList
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import { ArrowLeft2, Home } from 'iconsax-react'
|
||||||
|
import { type CategoryTreeNode } from '../types/Types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
category: CategoryTreeNode
|
||||||
|
categories: CategoryTreeNode[]
|
||||||
|
onNavigate?: (categoryId: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryBreadcrumb: FC<Props> = ({
|
||||||
|
category,
|
||||||
|
categories,
|
||||||
|
onNavigate,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
// پیدا کردن مسیر کامل دسته
|
||||||
|
const getCategoryPath = (cat: CategoryTreeNode): CategoryTreeNode[] => {
|
||||||
|
const path: CategoryTreeNode[] = []
|
||||||
|
let current: CategoryTreeNode | null = cat
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
path.unshift(current)
|
||||||
|
if (current.parent) {
|
||||||
|
const findParent = (cats: CategoryTreeNode[], id: string): CategoryTreeNode | null => {
|
||||||
|
for (const category of cats) {
|
||||||
|
if (category._id === id) return category
|
||||||
|
if (category.children.length > 0) {
|
||||||
|
const found = findParent(category.children, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
current = findParent(categories, current.parent)
|
||||||
|
} else {
|
||||||
|
current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = getCategoryPath(category)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-1 text-sm ${className}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate?.('')}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Home size={16} />
|
||||||
|
<span className="font-medium">خانه</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{path.map((cat, index) => (
|
||||||
|
<div key={cat._id} className="flex items-center gap-1">
|
||||||
|
<ArrowLeft2 size={14} className="text-gray-300" />
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate?.(cat._id)}
|
||||||
|
className={`px-3 py-2 rounded-lg transition-all duration-200 font-medium ${index === path.length - 1
|
||||||
|
? 'bg-blue-100 text-blue-700 border border-blue-200'
|
||||||
|
: 'text-gray-600 hover:text-gray-800 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.title_fa}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryBreadcrumb
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import { type CategoryTreeNode } from '../types/Types'
|
||||||
|
import CategoryBreadcrumb from './CategoryBreadcrumb'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
category: CategoryTreeNode | null
|
||||||
|
categories: CategoryTreeNode[]
|
||||||
|
onNavigate?: (categoryId: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryInfo: FC<Props> = ({
|
||||||
|
category,
|
||||||
|
categories,
|
||||||
|
onNavigate,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
if (!category) {
|
||||||
|
return (
|
||||||
|
<div className={`text-center py-12 text-gray-500 ${className}`}>
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="w-20 h-20 bg-gradient-to-br from-gray-100 to-gray-200 rounded-2xl mx-auto mb-4 flex items-center justify-center">
|
||||||
|
<span className="text-3xl">📁</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-600 mb-2">دستهای انتخاب نشده است</h3>
|
||||||
|
<p className="text-sm text-gray-400">برای مشاهده جزئیات، دستهای را انتخاب کنید</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-2xl p-6 border border-gray-200 shadow-sm ${className}`}>
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<CategoryBreadcrumb
|
||||||
|
category={category}
|
||||||
|
categories={categories}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
className="mb-6 pb-4 border-b border-gray-100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-5">
|
||||||
|
{/* آیکون دسته */}
|
||||||
|
{category.icon && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={category.icon}
|
||||||
|
alt={category.title_fa}
|
||||||
|
className="w-20 h-20 rounded-2xl object-cover border border-gray-200 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* اطلاعات دسته */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-1">
|
||||||
|
{category.title_fa}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 font-medium">
|
||||||
|
{category.title_en}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نشانگر نوع دسته */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-3 py-1.5 rounded-full text-xs font-medium ${category.leaf
|
||||||
|
? 'bg-green-100 text-green-700 border border-green-200'
|
||||||
|
: 'bg-blue-100 text-blue-700 border border-blue-200'
|
||||||
|
}`}>
|
||||||
|
{category.leaf ? 'دسته نهایی' : 'دسته والد'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* توضیحات */}
|
||||||
|
{category.description && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 mb-2">توضیحات</h4>
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||||
|
{category.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* آمار و اطلاعات */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
{category.children.length > 0 && (
|
||||||
|
<div className="bg-blue-50 p-3 rounded-xl border border-blue-100">
|
||||||
|
<div className="text-xs text-blue-600 font-medium mb-1">زیردستهها</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-700">{category.children.length}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{category.theme && (
|
||||||
|
<div className="bg-purple-50 p-3 rounded-xl border border-purple-100">
|
||||||
|
<div className="text-xs text-purple-600 font-medium mb-1">تم</div>
|
||||||
|
<div className="text-sm font-semibold text-purple-700">{category.theme}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* متغیرها */}
|
||||||
|
{category.variants.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 mb-3">متغیرها</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{category.variants.map((variant, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="bg-orange-100 text-orange-700 px-3 py-1.5 rounded-lg text-xs font-medium border border-orange-200"
|
||||||
|
>
|
||||||
|
{variant}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* اطلاعات فنی */}
|
||||||
|
<div className="pt-4 border-t border-gray-100">
|
||||||
|
<div className="grid grid-cols-1 gap-2 text-xs text-gray-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>شناسه:</span>
|
||||||
|
<span className="font-mono bg-gray-100 px-2 py-1 rounded text-gray-600">
|
||||||
|
{category._id.slice(-8)}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>تاریخ ایجاد:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{new Date(category.createdAt).toLocaleDateString('fa-IR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>آخرین بروزرسانی:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{new Date(category.updatedAt).toLocaleDateString('fa-IR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryInfo
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { type FC, useState, useRef, useEffect } from 'react'
|
||||||
|
import { SearchNormal, ArrowDown2, ArrowUp2, Folder2, FolderOpen, DocumentText } from 'iconsax-react'
|
||||||
|
import { clx } from '../../../helpers/utils'
|
||||||
|
import Error from '../../../components/Error'
|
||||||
|
import { type CategoryTreeNode } from '../types/Types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
error_text?: string
|
||||||
|
categories: CategoryTreeNode[]
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategorySelect: FC<Props> = ({
|
||||||
|
label,
|
||||||
|
placeholder = 'انتخاب دسته',
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error_text,
|
||||||
|
categories,
|
||||||
|
disabled = false
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<CategoryTreeNode | null>(null)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// پیدا کردن دسته انتخاب شده بر اساس value
|
||||||
|
useEffect(() => {
|
||||||
|
if (value && categories.length > 0) {
|
||||||
|
const findCategory = (cats: CategoryTreeNode[], id: string): CategoryTreeNode | null => {
|
||||||
|
for (const cat of cats) {
|
||||||
|
if (cat._id === id) return cat
|
||||||
|
if (cat.children.length > 0) {
|
||||||
|
const found = findCategory(cat.children, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const found = findCategory(categories, value)
|
||||||
|
setSelectedCategory(found)
|
||||||
|
} else {
|
||||||
|
setSelectedCategory(null)
|
||||||
|
}
|
||||||
|
}, [value, categories])
|
||||||
|
|
||||||
|
// بستن dropdown با کلیک خارج از آن
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// فیلتر کردن دستهها بر اساس جستجو
|
||||||
|
const filteredCategories = categories.filter(cat =>
|
||||||
|
cat.title_fa.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
cat.title_en.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
// نمایش مسیر کامل دسته
|
||||||
|
const getCategoryPath = (category: CategoryTreeNode): string => {
|
||||||
|
const path: string[] = []
|
||||||
|
let current: CategoryTreeNode | null = category
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
path.unshift(current.title_fa)
|
||||||
|
if (current.parent) {
|
||||||
|
const parent = categories.find(cat => cat._id === current?.parent)
|
||||||
|
if (parent) {
|
||||||
|
current = parent
|
||||||
|
} else {
|
||||||
|
// جستجو در children
|
||||||
|
const findParent = (cats: CategoryTreeNode[], id: string): CategoryTreeNode | null => {
|
||||||
|
for (const cat of cats) {
|
||||||
|
if (cat._id === id) return cat
|
||||||
|
if (cat.children.length > 0) {
|
||||||
|
const found = findParent(cat.children, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
current = findParent(categories, current.parent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(' > ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// رندر کردن دستهها به صورت درختی
|
||||||
|
const renderCategoryTree = (cats: CategoryTreeNode[], level: number = 0) => {
|
||||||
|
return cats.map(cat => (
|
||||||
|
<div key={cat._id}>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
'flex items-center gap-3 p-3 hover:bg-gray-50 cursor-pointer transition-colors rounded-lg',
|
||||||
|
level > 0 && 'mr-6',
|
||||||
|
cat._id === value && 'bg-blue-50 border border-blue-200'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(cat._id)
|
||||||
|
setIsOpen(false)
|
||||||
|
setSearchTerm('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
{/* آیکون دسته */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{cat.children.length > 0 ? (
|
||||||
|
<FolderOpen size={20} className="text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<DocumentText size={20} className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* اطلاعات دسته */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-900">{cat.title_fa}</span>
|
||||||
|
{cat.children.length > 0 && (
|
||||||
|
<span className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{cat.children.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{cat.title_en}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نشانگر انتخاب */}
|
||||||
|
{cat._id === value && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نمایش زیردستهها */}
|
||||||
|
{cat.children.length > 0 && level < 2 && (
|
||||||
|
<div className="border-r border-gray-200 mr-6">
|
||||||
|
{renderCategoryTree(cat.children, level + 1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full relative" ref={dropdownRef}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm text-gray-700 mb-2 block font-medium">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* دکمه انتخاب */}
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
'w-full border border-gray-300 rounded-xl bg-white cursor-pointer transition-all duration-200',
|
||||||
|
'hover:border-blue-400 hover:shadow-sm',
|
||||||
|
isOpen && 'border-blue-500 ring-2 ring-blue-100',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed bg-gray-50',
|
||||||
|
label && 'mt-1'
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{selectedCategory ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{selectedCategory.children.length > 0 ? (
|
||||||
|
<FolderOpen size={20} className="text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<DocumentText size={20} className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{selectedCategory.title_fa}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{getCategoryPath(selectedCategory)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Folder2 size={20} className="text-gray-400" />
|
||||||
|
<span className="text-gray-400">{placeholder}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!disabled && (
|
||||||
|
<div className="flex-shrink-0 ml-3">
|
||||||
|
{isOpen ? (
|
||||||
|
<ArrowUp2 size={16} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown2 size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && !disabled && (
|
||||||
|
<div className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-xl shadow-lg max-h-80 overflow-hidden">
|
||||||
|
{/* جستجو */}
|
||||||
|
<div className="p-4 border-b border-gray-100 bg-gray-50">
|
||||||
|
<div className="relative">
|
||||||
|
<SearchNormal
|
||||||
|
size={18}
|
||||||
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجو در دستهها..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pr-10 pl-3 py-2.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* لیست دستهها */}
|
||||||
|
<div className="max-h-64 overflow-y-auto p-2">
|
||||||
|
{filteredCategories.length > 0 ? (
|
||||||
|
renderCategoryTree(filteredCategories)
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Folder2 size={32} className="mx-auto mb-2 text-gray-300" />
|
||||||
|
<p>دستهای یافت نشد</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error_text && error_text !== '' && (
|
||||||
|
<Error errorText={error_text} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategorySelect
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import { Folder2, FolderOpen, DocumentText, Layer } from 'iconsax-react'
|
||||||
|
import { type CategoryTreeNode } from '../types/Types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
categories: CategoryTreeNode[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategorySummary: FC<Props> = ({ categories, className = '' }) => {
|
||||||
|
// محاسبه آمار
|
||||||
|
const getStats = () => {
|
||||||
|
let totalCategories = 0
|
||||||
|
let parentCategories = 0
|
||||||
|
let leafCategories = 0
|
||||||
|
let maxDepth = 0
|
||||||
|
|
||||||
|
const traverse = (cats: CategoryTreeNode[], depth: number = 0) => {
|
||||||
|
cats.forEach(cat => {
|
||||||
|
totalCategories++
|
||||||
|
maxDepth = Math.max(maxDepth, depth)
|
||||||
|
|
||||||
|
if (cat.children.length > 0) {
|
||||||
|
parentCategories++
|
||||||
|
traverse(cat.children, depth + 1)
|
||||||
|
} else {
|
||||||
|
leafCategories++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(categories)
|
||||||
|
return { totalCategories, parentCategories, leafCategories, maxDepth }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = getStats()
|
||||||
|
|
||||||
|
const summaryItems = [
|
||||||
|
{
|
||||||
|
icon: <Layer size={24} className="text-blue-500" />,
|
||||||
|
title: 'کل دستهها',
|
||||||
|
value: stats.totalCategories,
|
||||||
|
color: 'bg-blue-50 text-blue-700'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FolderOpen size={24} className="text-green-500" />,
|
||||||
|
title: 'دستههای والد',
|
||||||
|
value: stats.parentCategories,
|
||||||
|
color: 'bg-green-50 text-green-700'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <DocumentText size={24} className="text-purple-500" />,
|
||||||
|
title: 'دستههای نهایی',
|
||||||
|
value: stats.leafCategories,
|
||||||
|
color: 'bg-purple-50 text-purple-700'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Folder2 size={24} className="text-orange-500" />,
|
||||||
|
title: 'عمیقترین سطح',
|
||||||
|
value: stats.maxDepth + 1,
|
||||||
|
color: 'bg-orange-50 text-orange-700'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid grid-cols-2 lg:grid-cols-4 gap-4 ${className}`}>
|
||||||
|
{summaryItems.map((item, index) => (
|
||||||
|
<div key={index} className="bg-white rounded-2.5 p-4 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">{item.title}</p>
|
||||||
|
<p className={`text-2xl font-bold ${item.color}`}>
|
||||||
|
{item.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategorySummary
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { type FC, useState } from 'react'
|
||||||
|
import { ArrowDown2, ArrowRight2, Folder2, FolderOpen, DocumentText } from 'iconsax-react'
|
||||||
|
import { type CategoryTreeNode } from '../types/Types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
categories: CategoryTreeNode[]
|
||||||
|
onSelect?: (category: CategoryTreeNode) => void
|
||||||
|
selectedId?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryTree: FC<Props> = ({
|
||||||
|
categories,
|
||||||
|
onSelect,
|
||||||
|
selectedId,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const toggleNode = (nodeId: string) => {
|
||||||
|
const newExpanded = new Set(expandedNodes)
|
||||||
|
if (newExpanded.has(nodeId)) {
|
||||||
|
newExpanded.delete(nodeId)
|
||||||
|
} else {
|
||||||
|
newExpanded.add(nodeId)
|
||||||
|
}
|
||||||
|
setExpandedNodes(newExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCategoryNode = (category: CategoryTreeNode, level: number = 0) => {
|
||||||
|
const isExpanded = expandedNodes.has(category._id)
|
||||||
|
const hasChildren = category.children.length > 0
|
||||||
|
const isSelected = selectedId === category._id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category._id} className="select-none">
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all duration-200
|
||||||
|
hover:bg-gray-50 hover:shadow-sm
|
||||||
|
${isSelected ? 'bg-blue-50 border border-blue-200 shadow-sm' : ''}
|
||||||
|
${level > 0 ? 'mr-6' : ''}
|
||||||
|
`}
|
||||||
|
style={{ paddingLeft: `${level * 24 + 16}px` }}
|
||||||
|
onClick={() => onSelect?.(category)}
|
||||||
|
>
|
||||||
|
{/* دکمه گسترش */}
|
||||||
|
{hasChildren && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleNode(category._id)
|
||||||
|
}}
|
||||||
|
className="p-1.5 hover:bg-gray-200 rounded-lg transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ArrowDown2 size={16} className="text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<ArrowRight2 size={16} className="text-gray-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* فاصله برای دستههای بدون فرزند */}
|
||||||
|
{!hasChildren && <div className="w-8" />}
|
||||||
|
|
||||||
|
{/* آیکون دسته */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{hasChildren ? (
|
||||||
|
isExpanded ? (
|
||||||
|
<FolderOpen size={20} className="text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<Folder2 size={20} className="text-gray-500" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<DocumentText size={20} className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* اطلاعات دسته */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={`text-sm font-medium ${isSelected ? 'text-blue-700' : 'text-gray-900'}`}>
|
||||||
|
{category.title_fa}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<span className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium">
|
||||||
|
{category.children.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{category.leaf && (
|
||||||
|
<span className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs">
|
||||||
|
نهایی
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{category.title_en}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{category.description && (
|
||||||
|
<div className="text-xs text-gray-400 mt-1 line-clamp-1">
|
||||||
|
{category.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نشانگر انتخاب */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-3 h-3 bg-blue-500 rounded-full shadow-sm"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نمایش زیردستهها */}
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<div className="border-r-2 border-gray-200 mr-6">
|
||||||
|
{category.children.map(child => renderCategoryNode(child, level + 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={`text-center py-12 text-gray-500 ${className}`}>
|
||||||
|
<Folder2 size={48} className="mx-auto mb-4 text-gray-300" />
|
||||||
|
<p className="text-lg font-medium text-gray-400 mb-2">دستهای موجود نیست</p>
|
||||||
|
<p className="text-sm text-gray-400">برای شروع، دستهای جدید ایجاد کنید</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-2 ${className}`}>
|
||||||
|
{categories.map(category => renderCategoryNode(category))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryTree
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as CategorySelect } from "./CategorySelect";
|
||||||
|
export { default as CategoryTree } from "./CategoryTree";
|
||||||
|
export { default as CategoryInfo } from "./CategoryInfo";
|
||||||
|
export { default as CategoryBreadcrumb } from "./CategoryBreadcrumb";
|
||||||
|
export { default as CategorySummary } from "./CategorySummary";
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import * as api from '../service/CategoryService';
|
||||||
|
import { type CategoryType } from '../types/Types';
|
||||||
|
|
||||||
|
export const useCreateCategory = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CategoryType) => api.createCategory(variables),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCategories = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: api.getCategories,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCategoryById = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['category', id],
|
||||||
|
queryFn: () => api.getCategoryById(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateCategory = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<CategoryType> }) =>
|
||||||
|
api.updateCategory(id, data),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteCategory = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteCategory(id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSingleUpload = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: File) => api.singleUpload(variables),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as CreateCategory } from "./Create";
|
||||||
|
export * from "./types/Types";
|
||||||
|
export * from "./hooks/useCategoryData";
|
||||||
|
export * from "./store/CategoryStore";
|
||||||
|
export * from "./service/CategoryService";
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import axios from "../../../config/axios";
|
||||||
|
import { type CategoryType, type SingleUploadResponseType } from "../types/Types";
|
||||||
|
|
||||||
|
export const createCategory = async (params: CategoryType) => {
|
||||||
|
const { data } = await axios.post(`/admin/category`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCategories = async () => {
|
||||||
|
const { data } = await axios.get(`/category`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCategoryById = async (id: string) => {
|
||||||
|
const { data } = await axios.get(`/admin/category/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCategory = async (id: string, params: Partial<CategoryType>) => {
|
||||||
|
const { data } = await axios.put(`/admin/category/${id}`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCategory = async (id: string) => {
|
||||||
|
const { data } = await axios.delete(`/admin/category/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const singleUpload = async (params: File): Promise<SingleUploadResponseType> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', params);
|
||||||
|
const { data } = await axios.post(`/admin/medias/upload/single`, formData);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { type CategoryType, type CategoryTreeNode } from "../types/Types";
|
||||||
|
|
||||||
|
export type CategoryStoreType = {
|
||||||
|
selectedCategory: CategoryType | null;
|
||||||
|
categories: CategoryTreeNode[];
|
||||||
|
setSelectedCategory: (category: CategoryType | null) => void;
|
||||||
|
setCategories: (categories: CategoryTreeNode[]) => void;
|
||||||
|
addCategory: (category: CategoryTreeNode) => void;
|
||||||
|
updateCategory: (id: string, category: Partial<CategoryTreeNode>) => void;
|
||||||
|
removeCategory: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCategoryStore = create<CategoryStoreType>((set) => ({
|
||||||
|
selectedCategory: null,
|
||||||
|
categories: [],
|
||||||
|
setSelectedCategory(category) {
|
||||||
|
set({ selectedCategory: category });
|
||||||
|
},
|
||||||
|
setCategories(categories) {
|
||||||
|
set({ categories });
|
||||||
|
},
|
||||||
|
addCategory(category) {
|
||||||
|
set((state) => ({
|
||||||
|
categories: [...state.categories, category],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
updateCategory(id, category) {
|
||||||
|
set((state) => ({
|
||||||
|
categories: state.categories.map((cat) =>
|
||||||
|
cat._id === id ? { ...cat, ...category } : cat
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
removeCategory(id) {
|
||||||
|
set((state) => ({
|
||||||
|
categories: state.categories.filter((cat) => cat._id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
export type CategoryType = {
|
||||||
|
title_fa: string;
|
||||||
|
title_en: string;
|
||||||
|
icon: string;
|
||||||
|
imageUrl: string;
|
||||||
|
description: string;
|
||||||
|
parent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryResponseType = {
|
||||||
|
id: string;
|
||||||
|
title_fa: string;
|
||||||
|
title_en: string;
|
||||||
|
icon: string;
|
||||||
|
imageUrl: string;
|
||||||
|
description: string;
|
||||||
|
parent: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateCategoryRequestType = CategoryType;
|
||||||
|
|
||||||
|
export type UpdateCategoryRequestType = Partial<CategoryType>;
|
||||||
|
|
||||||
|
export type UploadFileResponseType = {
|
||||||
|
originalname: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SingleUploadResponseType = {
|
||||||
|
status: number;
|
||||||
|
success: boolean;
|
||||||
|
results: {
|
||||||
|
message: string;
|
||||||
|
url: UploadFileResponseType;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// تایپهای جدید برای ساختار درختی دستهبندیها
|
||||||
|
export type CategoryTreeNode = {
|
||||||
|
_id: string;
|
||||||
|
title_fa: string;
|
||||||
|
title_en: string;
|
||||||
|
icon: string;
|
||||||
|
imageUrl: string;
|
||||||
|
description: string;
|
||||||
|
variants: number[];
|
||||||
|
hierarchy: string[];
|
||||||
|
leaf: boolean;
|
||||||
|
parent: string | null;
|
||||||
|
deleted: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
url: string;
|
||||||
|
theme?: string;
|
||||||
|
children: CategoryTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoriesResponseType = {
|
||||||
|
status: number;
|
||||||
|
success: boolean;
|
||||||
|
results: {
|
||||||
|
data: CategoryTreeNode[];
|
||||||
|
};
|
||||||
|
};
|
||||||
+5
-2
@@ -1,10 +1,12 @@
|
|||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import SideBar from '../shared/SideBar'
|
import SideBar from '../shared/SideBar'
|
||||||
import Header from '../shared/Header'
|
import Header from '../shared/Header'
|
||||||
import { Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
import Footer from '../shared/Footer'
|
import Footer from '../shared/Footer'
|
||||||
import { clx } from '../helpers/utils'
|
import { clx } from '../helpers/utils'
|
||||||
import { useSharedStore } from '../shared/store/sharedStore'
|
import { useSharedStore } from '../shared/store/sharedStore'
|
||||||
|
import Create from '../pages/category/Create'
|
||||||
|
import List from '../pages/category/List'
|
||||||
|
|
||||||
const MainRouter: FC = () => {
|
const MainRouter: FC = () => {
|
||||||
|
|
||||||
@@ -23,7 +25,8 @@ const MainRouter: FC = () => {
|
|||||||
)}>
|
)}>
|
||||||
<div className='pb-20'>
|
<div className='pb-20'>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path='/category/create' element={<Create />} />
|
||||||
|
<Route path='/category/list' element={<List />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user