create and list + delete brand

This commit is contained in:
hamid zarghami
2025-09-24 10:01:33 +03:30
parent 0dd1ab170d
commit a8e2cbb065
12 changed files with 381 additions and 16 deletions
+18
View File
@@ -39,6 +39,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@types/node": "^24.5.2",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
@@ -2387,6 +2388,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "24.5.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz",
"integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.12.0"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.12", "version": "19.1.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
@@ -5468,6 +5479,13 @@
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/undici-types": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+1
View File
@@ -41,6 +41,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@types/node": "^24.5.2",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
+2 -1
View File
@@ -56,7 +56,8 @@ const queryClient = new QueryClient({
try { try {
const refreshTokenValue = await getRefreshToken(); const refreshTokenValue = await getRefreshToken();
const { data } = await refreshToken({ refreshToken: refreshTokenValue || '' }); const response = await refreshToken({ token: refreshTokenValue || '' });
const data = response.results.data;
if (data?.accessToken?.token) { if (data?.accessToken?.token) {
// Save the new token // Save the new token
+5
View File
@@ -129,5 +129,10 @@ export const Pages = {
list: "/products/list", list: "/products/list",
variants: "/products/variants/", variants: "/products/variants/",
update: "/products/update/", update: "/products/update/",
brands: {
list: "/products/brands",
update: "/products/brands/update/",
create: "/products/brands/create",
},
}, },
}; };
+168
View File
@@ -0,0 +1,168 @@
import { type FC, useState } from 'react'
import { useGetCategories, type CategoryTreeNode } from '../category'
import { useFormik } from 'formik'
import { type CreateBrandType } from './types/Types'
import * as Yup from 'yup'
import { useCreateBrand } from './hooks/useBrandData'
import { useNavigate } from 'react-router-dom'
import Input from '../../components/Input'
import Select from '../../components/Select'
import Textarea from '../../components/Textarea'
import Button from '../../components/Button'
import { TickCircle } from 'iconsax-react'
import { useUploadMultiple, useUploadSingle } from '../uploader/hooks/useUploaderData'
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/helpers/utils'
const Create: FC = () => {
const navigate = useNavigate()
const { data: categoriesData } = useGetCategories()
const uploadSingle = useUploadSingle()
const uploadMultiple = useUploadMultiple()
const createBrandMutation = useCreateBrand()
const [logoFile, setLogoFile] = useState<File[]>([])
const [imageFiles, setImageFiles] = useState<File[]>([])
const formik = useFormik<CreateBrandType>({
initialValues: {
title_fa: '',
title_en: '',
category: '',
description: '',
images: [],
logoUrl: '',
},
validationSchema: Yup.object({
title_fa: Yup.string().required('عنوان فارسی برند الزامی است'),
title_en: Yup.string().required('عنوان انگلیسی برند الزامی است'),
category: Yup.string().required('دسته بندی برند الزامی است'),
description: Yup.string().required('توضیحات برند الزامی است'),
}),
onSubmit: async (values) => {
let logoUrl = values.logoUrl
let images = values.images
if (logoFile.length > 0) {
const logoResponse = await uploadSingle.mutateAsync(logoFile[0])
logoUrl = logoResponse.results?.url?.url || ''
}
if (imageFiles.length > 0) {
const imagesResponse = await uploadMultiple.mutateAsync(imageFiles)
images = imagesResponse.results?.urls?.map(file => file.url) || []
}
const submitData = {
...values,
logoUrl,
images
}
createBrandMutation.mutate(submitData, {
onSuccess: () => {
toast.success('برند با موفقیت ایجاد شد')
navigate('/brands')
},
onError: (error) => {
toast.error(extractErrorMessage(error))
}
})
},
})
const categoryOptions = categoriesData?.results?.data?.map((category: CategoryTreeNode) => ({
value: category._id,
label: category.title_fa
})) || []
return (
<div className='mt-4'>
<div className='flex justify-between items-center'>
<div className='flex items-center gap-3'>
<h1>ساخت برند جدید</h1>
</div>
<div>
<Button
className='px-6'
onClick={() => formik.handleSubmit()}
isLoading={createBrandMutation.isPending || uploadSingle.isPending || uploadMultiple.isPending}
disabled={!formik.isValid || createBrandMutation.isPending || uploadSingle.isPending || uploadMultiple.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='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='مثال: Samsung'
{...formik.getFieldProps('title_en')}
error_text={formik.touched.title_en && formik.errors.title_en ? formik.errors.title_en : ''}
/>
</div>
<Select
label='دسته‌بندی'
placeholder='انتخاب دسته‌بندی برند'
items={categoryOptions}
{...formik.getFieldProps('category')}
error_text={formik.touched.category && formik.errors.category ? formik.errors.category : ''}
/>
<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>
<UploadBoxDraggble
label='لوگو برند'
isMultiple={false}
onChange={(files) => setLogoFile(files)}
/>
</div>
<div>
<UploadBoxDraggble
label='تصاویر برند'
isMultiple={true}
onChange={(files) => setImageFiles(files)}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default Create
+123
View File
@@ -0,0 +1,123 @@
import { type FC, useState } from 'react'
import { useGetBrands, useDeleteBrand } from './hooks/useBrandData'
import { type BrandType } from './types/Types'
import PageLoading from '../../components/PageLoading'
import Error from '../../components/Error'
import PaginationUi from '../../components/PaginationUi'
import Td from '../../components/Td'
import TrashWithConfrim from '../../components/TrashWithConfrim'
import { Edit } from 'iconsax-react'
import { Link } from 'react-router-dom'
import { Pages } from '@/config/Pages'
import Button from '@/components/Button'
import { useNavigate } from 'react-router-dom'
const BrandList: FC = () => {
const [currentPage, setCurrentPage] = useState(1);
const { data: brandsData, isLoading, error, refetch } = useGetBrands(currentPage)
const deleteBrandMutation = useDeleteBrand();
const navigate = useNavigate();
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<PageLoading />
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<Error errorText="خطا در بارگذاری برندها" />
</div>
);
}
const handleDeleteBrand = async (brandId: string) => {
await deleteBrandMutation.mutateAsync(brandId);
refetch()
};
const brands = brandsData?.results?.brands || [];
const pager = brandsData?.results?.pager;
return (
<div>
<div className='flex justify-end mt-5'>
<Button
label='افزودن برند'
onClick={() => navigate(`${Pages.products.brands.create}`)}
className='w-fit'
/>
</div>
<div className='relative overflow-x-auto rounded-3xl mt-5 w-full'>
<table className='w-full text-sm'>
<thead className='thead'>
<tr>
<Td text={'لوگو'} />
<Td text={'عنوان فارسی'} />
<Td text={'عنوان انگلیسی'} />
<Td text={'دسته‌بندی'} />
<Td text={'وضعیت'} />
<Td text={'عملیات'} />
</tr>
</thead>
<tbody>
{brands.length === 0 ? (
<tr className='tr'>
<td colSpan={6} className="text-center py-8 text-gray-500">
هیچ برندی یافت نشد
</td>
</tr>
) : (
brands.map((brand: BrandType) => (
<tr key={brand._id} className='tr'>
<Td text="">
<div className="w-12 h-12 rounded-lg overflow-hidden">
<img
src={brand.logoUrl || '/placeholder-image.png'}
alt={brand.title_fa}
className="w-full h-full object-cover"
/>
</div>
</Td>
<Td text={brand.title_fa} />
<Td text={brand.title_en} />
<Td text={brand.category?.name || 'بدون دسته‌بندی'} />
<Td text="">
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded-full text-xs`}>
{brand.status}
</span>
</div>
</Td>
<Td text="">
<div className="flex items-center gap-2">
<Link to={`${Pages.products.brands.update}${brand._id}`}>
<Edit color='#8C90A3' size={16} className="cursor-pointer hover:text-blue-500" />
</Link>
<TrashWithConfrim
onDelete={() => handleDeleteBrand(brand._id)}
isLoading={deleteBrandMutation.isPending}
/>
</div>
</Td>
</tr>
))
)}
</tbody>
</table>
</div>
<PaginationUi
pager={pager}
onPageChange={(page) => {
setCurrentPage(page);
}}
/>
</div>
)
}
export default BrandList
+22 -5
View File
@@ -1,10 +1,27 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import * as api from "../service/BrandService"; import * as api from "../service/BrandService";
// Hook برای دریافت لیست برندها export const useGetBrands = (page: number = 1, limit: number = 10) => {
export const useGetBrands = () => {
return useQuery({ return useQuery({
queryKey: ["brands"], queryKey: ["brands", page, limit],
queryFn: api.getBrands, queryFn: () => api.getBrands(page, limit),
});
};
export const useDeleteBrand = () => {
return useMutation({
mutationFn: (id: string) => api.deleteBrand(id),
});
};
export const useCreateBrand = () => {
return useMutation({
mutationFn: api.createBrand,
});
};
export const useUpdateBrand = () => {
return useMutation({
mutationFn: api.updateBrand,
}); });
}; };
+20 -4
View File
@@ -1,8 +1,24 @@
import axios from "../../../config/axios"; import axios from "../../../config/axios";
import { type BrandsResponseType } from "../types/Types"; import { type BrandsResponseType, type CreateBrandType } from "../types/Types";
// دریافت لیست برندها export const getBrands = async (page: number = 1, limit: number = 10): Promise<BrandsResponseType> => {
export const getBrands = async (): Promise<BrandsResponseType> => { const { data } = await axios.get(`/brand`, {
const { data } = await axios.get(`/brand`); params: { page, limit }
});
return data;
};
export const deleteBrand = async (id: string) => {
const { data } = await axios.delete(`/admin/brand/${id}`);
return data;
};
export const createBrand = async (params: CreateBrandType) => {
const { data } = await axios.post(`/admin/brand`, params);
return data;
};
export const updateBrand = async (params: { id: string, params: CreateBrandType }) => {
const { data } = await axios.patch(`/admin/brand/${params.id}`, params.params);
return data; return data;
}; };
+16 -3
View File
@@ -4,7 +4,10 @@ export type BrandType = {
_id: string; _id: string;
title_fa: string; title_fa: string;
title_en: string; title_en: string;
category: string | null; category: {
_id: string;
name: string;
} | null;
status: string; status: string;
description: string; description: string;
logoUrl: string; logoUrl: string;
@@ -12,13 +15,14 @@ export type BrandType = {
deleted: boolean; deleted: boolean;
}; };
// تایپ‌های صفحه‌بندی
export type PagerType = { export type PagerType = {
page: number; page: number;
limit: number; limit: number;
totalItems: number; totalItems: number;
totalPages: number; totalPages: number;
prevPage: boolean | string; prevPage: boolean | null;
nextPage: string; nextPage: string | null;
}; };
export type BrandsResponseType = { export type BrandsResponseType = {
@@ -29,3 +33,12 @@ export type BrandsResponseType = {
pager: PagerType; pager: PagerType;
}; };
}; };
export type CreateBrandType = {
title_fa: string;
title_en: string;
category: string; // category ID
description: string;
logoUrl: string;
images: string[];
};
+4
View File
@@ -15,6 +15,8 @@ import CreateProduct from '../pages/products/Create'
import ListProduct from '../pages/products/List' import ListProduct from '../pages/products/List'
import UpdateProduct from '../pages/products/Update' import UpdateProduct from '../pages/products/Update'
import ProductVariant from '@/pages/products/ProductVariant' import ProductVariant from '@/pages/products/ProductVariant'
import BrandList from '@/pages/brand/List'
import BrandCreate from '@/pages/brand/Create'
const MainRouter: FC = () => { const MainRouter: FC = () => {
@@ -42,6 +44,8 @@ const MainRouter: FC = () => {
<Route path={Pages.products.list} element={<ListProduct />} /> <Route path={Pages.products.list} element={<ListProduct />} />
<Route path={`${Pages.products.update}:id`} element={<UpdateProduct />} /> <Route path={`${Pages.products.update}:id`} element={<UpdateProduct />} />
<Route path={`${Pages.products.variants}:id`} element={<ProductVariant />} /> <Route path={`${Pages.products.variants}:id`} element={<ProductVariant />} />
<Route path={Pages.products.brands.list} element={<BrandList />} />
<Route path={Pages.products.brands.create} element={<BrandCreate />} />
</Routes> </Routes>
</div> </div>
</div> </div>
+1 -1
View File
@@ -365,7 +365,7 @@ const ProductsSubMenu: FC = () => {
<SubMenuItem <SubMenuItem
title={'برند ها'} title={'برند ها'}
isActive={isActive('brands')} isActive={isActive('brands')}
link="/products/brands" link={Pages.products.brands.list}
/> />
<SubMenuItem <SubMenuItem
title={'تنوع ها'} title={'تنوع ها'}
+1 -2
View File
@@ -1,8 +1,7 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "path"; import { fileURLToPath } from "node:url";
import { fileURLToPath, URL } from "node:url";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({