base create and list
This commit is contained in:
@@ -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[];
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user