From 87213732a23ba4f70083fda4633375a69c107424 Mon Sep 17 00:00:00 2001 From: hossein taromi Date: Sun, 27 Jul 2025 14:45:57 +0330 Subject: [PATCH] feat(products): implement comprehensive product management system - Add complete product models matching API structure exactly - Implement products list page with advanced filtering and search - Add comprehensive product form with all API fields: * Basic info (name, description, design_style, type, enabled) * Multi-category selection with search * Product options integration * Custom attributes management * Image upload with preview * Variants management with full specification - Include PRODUCT_TYPES constants and labels - Support for simple, variable, grouped and external products --- src/pages/products/core/_hooks.ts | 169 ++++++ src/pages/products/core/_models.ts | 188 ++++++ src/pages/products/core/_requests.ts | 146 +++++ .../products/product-form/ProductFormPage.tsx | 561 ++++++++++++++++++ .../products-list/ProductsListPage.tsx | 447 ++++++++++++++ 5 files changed, 1511 insertions(+) create mode 100644 src/pages/products/core/_hooks.ts create mode 100644 src/pages/products/core/_models.ts create mode 100644 src/pages/products/core/_requests.ts create mode 100644 src/pages/products/product-form/ProductFormPage.tsx create mode 100644 src/pages/products/products-list/ProductsListPage.tsx diff --git a/src/pages/products/core/_hooks.ts b/src/pages/products/core/_hooks.ts new file mode 100644 index 0000000..1c31e26 --- /dev/null +++ b/src/pages/products/core/_hooks.ts @@ -0,0 +1,169 @@ +import { QUERY_KEYS } from "@/utils/query-key"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getProducts, + getProduct, + createProduct, + updateProduct, + deleteProduct, + getProductVariants, + createProductVariant, + updateProductVariant, + deleteProductVariant, +} from "./_requests"; +import { + CreateProductRequest, + UpdateProductRequest, + CreateVariantRequest, + UpdateVariantRequest, + ProductFilters, +} from "./_models"; +import toast from "react-hot-toast"; + +// Products Hooks +export const useProducts = (filters?: ProductFilters) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_PRODUCTS, filters], + queryFn: () => getProducts(filters), + }); +}; + +export const useProduct = (id: string, enabled: boolean = true) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_PRODUCT, id], + queryFn: () => getProduct(id), + enabled: enabled && !!id, + }); +}; + +export const useCreateProduct = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [QUERY_KEYS.CREATE_PRODUCT], + mutationFn: (data: CreateProductRequest) => createProduct(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCTS] }); + toast.success("محصول با موفقیت ایجاد شد"); + }, + onError: (error: any) => { + console.error("Create product error:", error); + toast.error(error?.message || "خطا در ایجاد محصول"); + }, + }); +}; + +export const useUpdateProduct = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [QUERY_KEYS.UPDATE_PRODUCT], + mutationFn: (data: UpdateProductRequest) => updateProduct(data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCTS] }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_PRODUCT, variables.id.toString()], + }); + toast.success("محصول با موفقیت ویرایش شد"); + }, + onError: (error: any) => { + console.error("Update product error:", error); + toast.error(error?.message || "خطا در ویرایش محصول"); + }, + }); +}; + +export const useDeleteProduct = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [QUERY_KEYS.DELETE_PRODUCT], + mutationFn: (id: string) => deleteProduct(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCTS] }); + toast.success("محصول با موفقیت حذف شد"); + }, + onError: (error: any) => { + console.error("Delete product error:", error); + toast.error(error?.message || "خطا در حذف محصول"); + }, + }); +}; + +// Product Variants Hooks +export const useProductVariants = ( + productId: string, + enabled: boolean = true +) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_PRODUCT_VARIANTS, productId], + queryFn: () => getProductVariants(productId), + enabled: enabled && !!productId, + }); +}; + +export const useCreateProductVariant = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [QUERY_KEYS.CREATE_PRODUCT_VARIANT], + mutationFn: (data: CreateVariantRequest) => createProductVariant(data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: [ + QUERY_KEYS.GET_PRODUCT_VARIANTS, + variables.product_id.toString(), + ], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_PRODUCT, variables.product_id.toString()], + }); + toast.success("نسخه محصول با موفقیت ایجاد شد"); + }, + onError: (error: any) => { + console.error("Create variant error:", error); + toast.error(error?.message || "خطا در ایجاد نسخه محصول"); + }, + }); +}; + +export const useUpdateProductVariant = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [QUERY_KEYS.UPDATE_PRODUCT_VARIANT], + mutationFn: (data: UpdateVariantRequest) => updateProductVariant(data), + onSuccess: (result) => { + // We need to get product_id from somewhere, let's invalidate all variants queries + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_PRODUCT_VARIANTS], + }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCTS] }); + toast.success("نسخه محصول با موفقیت ویرایش شد"); + }, + onError: (error: any) => { + console.error("Update variant error:", error); + toast.error(error?.message || "خطا در ویرایش نسخه محصول"); + }, + }); +}; + +export const useDeleteProductVariant = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [QUERY_KEYS.DELETE_PRODUCT_VARIANT], + mutationFn: (variantId: string) => deleteProductVariant(variantId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_PRODUCT_VARIANTS], + }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCTS] }); + toast.success("نسخه محصول با موفقیت حذف شد"); + }, + onError: (error: any) => { + console.error("Delete variant error:", error); + toast.error(error?.message || "خطا در حذف نسخه محصول"); + }, + }); +}; diff --git a/src/pages/products/core/_models.ts b/src/pages/products/core/_models.ts new file mode 100644 index 0000000..0c5ea3b --- /dev/null +++ b/src/pages/products/core/_models.ts @@ -0,0 +1,188 @@ +import { Category } from "../../categories/core/_models"; +import { ProductOption } from "../../product-options/core/_models"; + +export interface ProductImage { + id: string; + url: string; + alt?: string; + order: number; +} + +export interface ProductVariant { + id?: number; + product_id?: number; + enabled: boolean; + fee_percentage: number; + profit_percentage: number; + stock_limit: number; + stock_managed: boolean; + stock_number: number; + weight: number; + attributes?: Record; + meta?: Record; + images?: ProductImage[]; + created_at?: string; + updated_at?: string; +} + +export interface Product { + id: number; + name: string; + description?: string; + design_style?: string; + enabled: boolean; + category_ids?: number[]; + categories?: Category[]; + product_option_id?: number; + product_option?: ProductOption; + total_sold: number; + type: number; + attributes?: Record; + images: ProductImage[]; + variants?: ProductVariant[]; + created_at: string; + updated_at: string; +} + +export interface ProductFormData { + name: string; + description: string; + design_style: string; + enabled: boolean; + category_ids: number[]; + product_option_id?: number; + total_sold: number; + type: number; + attributes: Record; + images: ProductImage[]; + variants: ProductVariantFormData[]; +} + +export interface ProductVariantFormData { + id?: number; + enabled: boolean; + fee_percentage: number; + profit_percentage: number; + stock_limit: number; + stock_managed: boolean; + stock_number: number; + weight: number; + attributes: Record; + meta: Record; + images: ProductImage[]; +} + +export interface ProductFilters { + search?: string; + category_id?: number; + min_price?: number; + max_price?: number; + page?: number; + limit?: number; +} + +export interface CreateProductRequest { + name: string; + description?: string; + design_style?: string; + enabled: boolean; + category_ids: number[]; + product_option_id?: number; + total_sold: number; + type: number; + attributes?: Record; + variants?: CreateVariantRequest[]; +} + +export interface UpdateProductRequest { + id: number; + name: string; + description?: string; + design_style?: string; + enabled: boolean; + category_ids: number[]; + product_option_id?: number; + total_sold: number; + type: number; + attributes?: Record; + variants?: UpdateVariantRequest[]; +} + +export interface CreateVariantRequest { + product_id?: number; + enabled: boolean; + fee_percentage: number; + profit_percentage: number; + stock_limit: number; + stock_managed: boolean; + stock_number: number; + weight: number; + attributes?: Record; + meta?: Record; +} + +export interface UpdateVariantRequest { + id: number; + enabled: boolean; + fee_percentage: number; + profit_percentage: number; + stock_limit: number; + stock_managed: boolean; + stock_number: number; + weight: number; + attributes?: Record; + meta?: Record; +} + +export interface ProductsResponse { + products: Product[] | null; + total?: number; + page?: number; + per_page?: number; +} + +export interface ProductResponse { + product: Product; +} + +export interface CreateProductResponse { + product: Product; +} + +export interface UpdateProductResponse { + product: Product; +} + +export interface DeleteProductResponse { + message: string; +} + +export interface ProductVariantsResponse { + variants: ProductVariant[] | null; +} + +export interface CreateVariantResponse { + variant: ProductVariant; +} + +export interface UpdateVariantResponse { + variant: ProductVariant; +} + +export interface DeleteVariantResponse { + message: string; +} + +export const PRODUCT_TYPES = { + SIMPLE: 0, + VARIABLE: 1, + GROUPED: 2, + EXTERNAL: 3, +} as const; + +export const PRODUCT_TYPE_LABELS = { + [PRODUCT_TYPES.SIMPLE]: "محصول ساده", + [PRODUCT_TYPES.VARIABLE]: "محصول متغیر", + [PRODUCT_TYPES.GROUPED]: "محصول گروهی", + [PRODUCT_TYPES.EXTERNAL]: "محصول خارجی", +} as const; diff --git a/src/pages/products/core/_requests.ts b/src/pages/products/core/_requests.ts new file mode 100644 index 0000000..341198f --- /dev/null +++ b/src/pages/products/core/_requests.ts @@ -0,0 +1,146 @@ +import { + httpGetRequest, + httpPostRequest, + httpPutRequest, + httpDeleteRequest, + APIUrlGenerator, +} from "@/utils/baseHttpService"; +import { API_ROUTES } from "@/constant/routes"; +import { + Product, + ProductVariant, + CreateProductRequest, + UpdateProductRequest, + CreateVariantRequest, + UpdateVariantRequest, + ProductsResponse, + ProductResponse, + CreateProductResponse, + UpdateProductResponse, + DeleteProductResponse, + ProductVariantsResponse, + CreateVariantResponse, + UpdateVariantResponse, + DeleteVariantResponse, + ProductFilters, +} from "./_models"; + +// Products API +export const getProducts = async (filters?: ProductFilters) => { + try { + const queryParams: Record = {}; + + if (filters?.search) queryParams.search = filters.search; + if (filters?.category_id) queryParams.category_id = filters.category_id; + if (filters?.status) queryParams.status = filters.status; + if (filters?.min_price) queryParams.min_price = filters.min_price; + if (filters?.max_price) queryParams.max_price = filters.max_price; + if (filters?.page) queryParams.page = filters.page; + if (filters?.limit) queryParams.limit = filters.limit; + + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.GET_PRODUCTS, queryParams) + ); + + console.log("Products API Response:", response); + + if ( + response.data && + response.data.products && + Array.isArray(response.data.products) + ) { + return { + products: response.data.products, + total: response.data.total, + page: response.data.page, + per_page: response.data.per_page, + }; + } + + console.warn("Products is null or not an array:", response.data); + return { products: [], total: 0, page: 1, per_page: 10 }; + } catch (error) { + console.error("Error fetching products:", error); + return { products: [], total: 0, page: 1, per_page: 10 }; + } +}; + +export const getProduct = async (id: string) => { + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.GET_PRODUCT(id)) + ); + return response.data.product; +}; + +export const createProduct = async (data: CreateProductRequest) => { + const response = await httpPostRequest( + APIUrlGenerator(API_ROUTES.CREATE_PRODUCT), + data + ); + return response.data.product; +}; + +export const updateProduct = async (data: UpdateProductRequest) => { + const response = await httpPutRequest( + APIUrlGenerator(API_ROUTES.UPDATE_PRODUCT(data.id.toString())), + data + ); + return response.data.product; +}; + +export const deleteProduct = async (id: string) => { + const response = await httpDeleteRequest( + APIUrlGenerator(API_ROUTES.DELETE_PRODUCT(id)) + ); + return response.data; +}; + +// Product Variants API +export const getProductVariants = async (productId: string) => { + try { + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.GET_PRODUCT_VARIANTS(productId)) + ); + + console.log("Product Variants API Response:", response); + + if ( + response.data && + response.data.variants && + Array.isArray(response.data.variants) + ) { + return response.data.variants; + } + + console.warn("Product variants is null or not an array:", response.data); + return []; + } catch (error) { + console.error("Error fetching product variants:", error); + return []; + } +}; + +export const createProductVariant = async (data: CreateVariantRequest) => { + const response = await httpPostRequest( + APIUrlGenerator( + API_ROUTES.CREATE_PRODUCT_VARIANT(data.product_id.toString()) + ), + data + ); + return response.data.variant; +}; + +export const updateProductVariant = async (data: UpdateVariantRequest) => { + const response = await httpPutRequest( + APIUrlGenerator(API_ROUTES.UPDATE_PRODUCT_VARIANT(data.id.toString())), + data + ); + return response.data.variant; +}; + +export const deleteProductVariant = async (variantId: string) => { + const response = await httpDeleteRequest( + APIUrlGenerator(API_ROUTES.DELETE_PRODUCT_VARIANT(variantId)) + ); + return response.data; +}; diff --git a/src/pages/products/product-form/ProductFormPage.tsx b/src/pages/products/product-form/ProductFormPage.tsx new file mode 100644 index 0000000..7d6ee34 --- /dev/null +++ b/src/pages/products/product-form/ProductFormPage.tsx @@ -0,0 +1,561 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { useProduct, useCreateProduct, useUpdateProduct } from '../core/_hooks'; +import { useCategories } from '../../categories/core/_hooks'; +import { useProductOptions } from '../../product-options/core/_hooks'; +import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload'; +import { ProductFormData, ProductImage, ProductVariantFormData, PRODUCT_TYPES, PRODUCT_TYPE_LABELS } from '../core/_models'; +import { MultiSelectAutocomplete } from "@/components/ui/MultiSelectAutocomplete"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { FileUploader } from "@/components/ui/FileUploader"; +import { VariantManager } from "@/components/ui/VariantManager"; +import { ArrowRight, Package, X, Plus, Trash2 } from "lucide-react"; + +const productSchema = yup.object({ + name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'), + description: yup.string().default(''), + design_style: yup.string().default(''), + enabled: yup.boolean().default(true), + total_sold: yup.number().min(0).default(0), + type: yup.number().oneOf([0, 1, 2, 3]).default(0), + category_ids: yup.array().of(yup.number()).default([]), + product_option_id: yup.number().optional().nullable(), + attributes: yup.object().default({}), + images: yup.array().of(yup.object()).default([]), + variants: yup.array().default([]), +}); + +const ProductFormPage = () => { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const isEdit = !!id; + + const [uploadedImages, setUploadedImages] = useState([]); + const [attributes, setAttributes] = useState>({}); + const [newAttributeKey, setNewAttributeKey] = useState(''); + const [newAttributeValue, setNewAttributeValue] = useState(''); + + const { data: product, isLoading: isLoadingProduct } = useProduct(id || '', isEdit); + const { data: categories, isLoading: isLoadingCategories } = useCategories(); + const { data: productOptions, isLoading: isLoadingProductOptions } = useProductOptions(); + const { mutate: createProduct, isPending: isCreating } = useCreateProduct(); + const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct(); + const { mutateAsync: uploadFile } = useFileUpload(); + const { mutate: deleteFile } = useFileDelete(); + + const isLoading = isCreating || isUpdating; + + const { + register, + handleSubmit, + formState: { errors, isValid, isDirty }, + setValue, + watch, + reset, + control + } = useForm({ + resolver: yupResolver(productSchema) as any, + mode: 'onChange', + defaultValues: { + name: '', + description: '', + design_style: '', + enabled: true, + total_sold: 0, + type: 0, + category_ids: [], + product_option_id: undefined, + attributes: {}, + images: [], + variants: [] + } + }); + + const formValues = watch(); + + useEffect(() => { + if (isEdit && product) { + reset({ + name: product.name, + description: product.description || '', + design_style: product.design_style || '', + enabled: product.enabled, + total_sold: product.total_sold || 0, + type: product.type || 0, + category_ids: product.category_ids || [], + product_option_id: product.product_option_id || undefined, + attributes: product.attributes || {}, + images: product.images || [], + variants: [] + }); + setUploadedImages(product.images || []); + setAttributes(product.attributes || {}); + } + }, [isEdit, product, reset]); + + const handleFileUpload = async (file: File) => { + try { + const result = await uploadFile(file); + const newImage: ProductImage = { + id: result.id, + url: result.url, + alt: file.name, + order: uploadedImages.length + }; + + const updatedImages = [...uploadedImages, newImage]; + setUploadedImages(updatedImages); + setValue('images', updatedImages, { shouldValidate: true, shouldDirty: true }); + + return result; + } catch (error) { + console.error('Upload error:', error); + throw error; + } + }; + + const handleFileRemove = (fileId: string) => { + const updatedImages = uploadedImages.filter(img => img.id !== fileId); + setUploadedImages(updatedImages); + setValue('images', updatedImages, { shouldValidate: true, shouldDirty: true }); + deleteFile(fileId); + }; + + const handleAddAttribute = () => { + if (newAttributeKey.trim() && newAttributeValue.trim()) { + const updatedAttributes = { + ...attributes, + [newAttributeKey.trim()]: newAttributeValue.trim() + }; + setAttributes(updatedAttributes); + setValue('attributes', updatedAttributes, { shouldValidate: true, shouldDirty: true }); + setNewAttributeKey(''); + setNewAttributeValue(''); + } + }; + + const handleRemoveAttribute = (key: string) => { + const updatedAttributes = { ...attributes }; + delete updatedAttributes[key]; + setAttributes(updatedAttributes); + setValue('attributes', updatedAttributes, { shouldValidate: true, shouldDirty: true }); + }; + + const onSubmit = (data: ProductFormData) => { + const submitData = { + ...data, + attributes, + category_ids: data.category_ids.length > 0 ? data.category_ids : [], + product_option_id: data.product_option_id || undefined, + variants: data.variants || [] + }; + + console.log('Submitting product data:', submitData); + + if (isEdit && id) { + updateProduct({ + id: parseInt(id), + ...submitData + }, { + onSuccess: () => { + navigate('/products'); + } + }); + } else { + createProduct(submitData, { + onSuccess: () => { + navigate('/products'); + } + }); + } + }; + + const handleBack = () => { + navigate('/products'); + }; + + if (isEdit && isLoadingProduct) { + return ( +
+ +
+ ); + } + + const categoryOptions = (categories || []).map(category => ({ + id: category.id, + title: category.name, + description: category.description + })); + + const productOptionOptions = (productOptions || []).map(option => ({ + id: option.id, + title: option.name, + description: `مقادیر: ${option.values.join(', ')}` + })); + + return ( +
+ {/* Header */} +
+ +
+

+ + {isEdit ? 'ویرایش محصول' : 'ایجاد محصول جدید'} +

+

+ {isEdit ? 'ویرایش اطلاعات محصول' : 'اطلاعات محصول جدید را وارد کنید'} +

+
+
+ + {/* Form */} +
+
+ {/* Basic Information */} +
+

+ اطلاعات پایه +

+
+
+ +
+ +
+ + +
+ +
+ + + {errors.type && ( +

{errors.type.message}

+ )} +
+ + + + + +
+ +