base create and list

This commit is contained in:
hamid zarghami
2025-08-31 12:26:26 +03:30
parent 54c3c59a12
commit 6191fb15a4
18 changed files with 1380 additions and 11 deletions
+26 -8
View File
@@ -6,24 +6,42 @@ import { clx } from '../helpers/utils'
type Props = {
className?: string;
isLoading?: boolean,
variant?: 'primary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
} & ButtonHTMLAttributes<HTMLButtonElement> &
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(
'flex rounded-xl items-center justify-center text-center h-10 text-sm bg-primary text-white w-full',
props.disabled && 'cursor-not-allowed opacity-60',
props.className
'flex rounded-xl items-center justify-center text-center font-medium transition-colors',
// Variant styles
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 (
<button disabled={props.isLoading} {...props} className={`${buttonClass} ${props.className}`} >
<button
disabled={restProps.isLoading}
{...restProps}
className={clx(buttonClass, className)}
>
{
props.isLoading ?
<MoonLoader color="white" size={16} />
restProps.isLoading ?
<MoonLoader color={variant === 'primary' || variant === 'danger' ? "white" : "#6B7280"} size={16} />
:
props.label || props.children
restProps.label || restProps.children
}
</button>
)
+50
View File
@@ -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
+21
View File
@@ -39,3 +39,24 @@ textarea::placeholder {
--width-sidebar: 300px;
--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;
}
+318
View File
@@ -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
+44
View File
@@ -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
+5
View File
@@ -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),
});
};
+5
View File
@@ -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;
};
+40
View File
@@ -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),
}));
},
}));
+68
View File
@@ -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
View File
@@ -1,10 +1,12 @@
import { type FC } from 'react'
import SideBar from '../shared/SideBar'
import Header from '../shared/Header'
import { Routes } from 'react-router-dom'
import { Route, Routes } from 'react-router-dom'
import Footer from '../shared/Footer'
import { clx } from '../helpers/utils'
import { useSharedStore } from '../shared/store/sharedStore'
import Create from '../pages/category/Create'
import List from '../pages/category/List'
const MainRouter: FC = () => {
@@ -23,7 +25,8 @@ const MainRouter: FC = () => {
)}>
<div className='pb-20'>
<Routes>
<Route path='/category/create' element={<Create />} />
<Route path='/category/list' element={<List />} />
</Routes>
</div>
</div>