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
This commit is contained in:
parent
5dbe901ac7
commit
87213732a2
|
|
@ -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 || "خطا در حذف نسخه محصول");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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<string, any>;
|
||||
meta?: Record<string, any>;
|
||||
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<string, any>;
|
||||
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<string, any>;
|
||||
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<string, any>;
|
||||
meta: Record<string, any>;
|
||||
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<string, any>;
|
||||
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<string, any>;
|
||||
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<string, any>;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
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<string, any>;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -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<string, string | number | null> = {};
|
||||
|
||||
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<ProductsResponse>(
|
||||
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<ProductResponse>(
|
||||
APIUrlGenerator(API_ROUTES.GET_PRODUCT(id))
|
||||
);
|
||||
return response.data.product;
|
||||
};
|
||||
|
||||
export const createProduct = async (data: CreateProductRequest) => {
|
||||
const response = await httpPostRequest<CreateProductResponse>(
|
||||
APIUrlGenerator(API_ROUTES.CREATE_PRODUCT),
|
||||
data
|
||||
);
|
||||
return response.data.product;
|
||||
};
|
||||
|
||||
export const updateProduct = async (data: UpdateProductRequest) => {
|
||||
const response = await httpPutRequest<UpdateProductResponse>(
|
||||
APIUrlGenerator(API_ROUTES.UPDATE_PRODUCT(data.id.toString())),
|
||||
data
|
||||
);
|
||||
return response.data.product;
|
||||
};
|
||||
|
||||
export const deleteProduct = async (id: string) => {
|
||||
const response = await httpDeleteRequest<DeleteProductResponse>(
|
||||
APIUrlGenerator(API_ROUTES.DELETE_PRODUCT(id))
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Product Variants API
|
||||
export const getProductVariants = async (productId: string) => {
|
||||
try {
|
||||
const response = await httpGetRequest<ProductVariantsResponse>(
|
||||
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<CreateVariantResponse>(
|
||||
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<UpdateVariantResponse>(
|
||||
APIUrlGenerator(API_ROUTES.UPDATE_PRODUCT_VARIANT(data.id.toString())),
|
||||
data
|
||||
);
|
||||
return response.data.variant;
|
||||
};
|
||||
|
||||
export const deleteProductVariant = async (variantId: string) => {
|
||||
const response = await httpDeleteRequest<DeleteVariantResponse>(
|
||||
APIUrlGenerator(API_ROUTES.DELETE_PRODUCT_VARIANT(variantId))
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -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<ProductImage[]>([]);
|
||||
const [attributes, setAttributes] = useState<Record<string, any>>({});
|
||||
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<ProductFormData>({
|
||||
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 (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
بازگشت
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Package className="h-6 w-6" />
|
||||
{isEdit ? 'ویرایش محصول' : 'ایجاد محصول جدید'}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{isEdit ? 'ویرایش اطلاعات محصول' : 'اطلاعات محصول جدید را وارد کنید'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* Basic Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
اطلاعات پایه
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label="نام محصول"
|
||||
{...register('name')}
|
||||
error={errors.name?.message}
|
||||
placeholder="نام محصول را وارد کنید"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 space-x-reverse">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('enabled')}
|
||||
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
محصول فعال باشد
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
نوع محصول
|
||||
</label>
|
||||
<select
|
||||
{...register('type')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
{Object.entries(PRODUCT_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.type && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.type.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="تعداد فروخته شده"
|
||||
type="number"
|
||||
{...register('total_sold')}
|
||||
error={errors.total_sold?.message}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="استایل طراحی"
|
||||
{...register('design_style')}
|
||||
error={errors.design_style?.message}
|
||||
placeholder="مدرن، کلاسیک، مینیمال..."
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
توضیحات
|
||||
</label>
|
||||
<textarea
|
||||
{...register('description')}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="توضیحات کامل محصول را وارد کنید..."
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories and Product Options */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
دستهبندی و گزینهها
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<MultiSelectAutocomplete
|
||||
label="دستهبندیها"
|
||||
options={categoryOptions}
|
||||
selectedValues={watch('category_ids') || []}
|
||||
onChange={(values) => setValue('category_ids', values, { shouldValidate: true, shouldDirty: true })}
|
||||
placeholder="دستهبندیها را انتخاب کنید..."
|
||||
isLoading={isLoadingCategories}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
گزینه محصول
|
||||
</label>
|
||||
<select
|
||||
{...register('product_option_id')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
<option value="">بدون گزینه</option>
|
||||
{(productOptions || []).map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name} ({option.values.join(', ')})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.product_option_id && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.product_option_id.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
تصاویر محصول
|
||||
</h3>
|
||||
|
||||
<FileUploader
|
||||
onUpload={handleFileUpload}
|
||||
onRemove={handleFileRemove}
|
||||
acceptedTypes={['image/*']}
|
||||
maxFileSize={5 * 1024 * 1024}
|
||||
maxFiles={10}
|
||||
label=""
|
||||
description="تصاویر محصول را اینجا بکشید یا کلیک کنید"
|
||||
/>
|
||||
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
تصاویر آپلود شده ({uploadedImages.length})
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{uploadedImages.map((image, index) => (
|
||||
<div key={image.id} className="relative group">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt || `تصویر ${index + 1}`}
|
||||
className="w-full h-24 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileRemove(image.id)}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
{index === 0 && (
|
||||
<div className="absolute bottom-1 left-1 bg-primary-500 text-white text-xs px-1 py-0.5 rounded">
|
||||
اصلی
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variants Management */}
|
||||
<div>
|
||||
<VariantManager
|
||||
variants={watch('variants') || []}
|
||||
onChange={(variants) => setValue('variants', variants, { shouldValidate: true, shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Attributes */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
ویژگیهای سفارشی
|
||||
</h3>
|
||||
|
||||
{/* Add New Attribute */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newAttributeKey}
|
||||
onChange={(e) => setNewAttributeKey(e.target.value)}
|
||||
placeholder="نام ویژگی (مثل: رنگ، سایز)"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newAttributeValue}
|
||||
onChange={(e) => setNewAttributeValue(e.target.value)}
|
||||
placeholder="مقدار (مثل: قرمز، بزرگ)"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleAddAttribute}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
افزودن
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Current Attributes */}
|
||||
{Object.keys(attributes).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
ویژگیهای فعلی:
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Object.entries(attributes).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between bg-gray-50 dark:bg-gray-700 px-3 py-2 rounded-md">
|
||||
<span className="text-sm">
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAttribute(key)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{formValues.name && (
|
||||
<div className="border border-gray-200 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
پیشنمایش محصول
|
||||
</h3>
|
||||
<div className="flex gap-4">
|
||||
{uploadedImages.length > 0 && (
|
||||
<img
|
||||
src={uploadedImages[0].url}
|
||||
alt={formValues.name}
|
||||
className="w-20 h-20 object-cover rounded-lg border"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>نام:</strong> {formValues.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>نوع:</strong> {PRODUCT_TYPE_LABELS[formValues.type as keyof typeof PRODUCT_TYPE_LABELS]}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>تعداد فروخته شده:</strong> {formValues.total_sold}
|
||||
</div>
|
||||
{formValues.design_style && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>استایل:</strong> {formValues.design_style}
|
||||
</div>
|
||||
)}
|
||||
{formValues.category_ids && formValues.category_ids.length > 0 && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>دستهبندیها:</strong> {
|
||||
categories?.filter(c => formValues.category_ids.includes(c.id))
|
||||
.map(c => c.name).join(', ')
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{formValues.variants && formValues.variants.length > 0 && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>تعداد Variants:</strong> {formValues.variants.length} نوع
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(attributes).length > 0 && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>ویژگیها:</strong> {Object.keys(attributes).length} مورد
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{formValues.enabled && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
فعال
|
||||
</span>
|
||||
)}
|
||||
{!formValues.enabled && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
غیرفعال
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
disabled={isLoading}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
disabled={!isValid || isLoading}
|
||||
>
|
||||
{isEdit ? 'بهروزرسانی' : 'ایجاد محصول'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
راهنما
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• نام محصول باید واضح و جذاب باشد</li>
|
||||
<li>• میتوانید چندین دستهبندی برای محصول انتخاب کنید</li>
|
||||
<li>• گزینه محصول برای محصولات متغیر (با رنگ، سایز و...) استفاده میشود</li>
|
||||
<li>• ویژگیهای سفارشی برای اطلاعات اضافی محصول مفید هستند</li>
|
||||
<li>• Variants برای انواع مختلف محصول استفاده میشود</li>
|
||||
<li>• اولین تصویر به عنوان تصویر اصلی محصول استفاده میشود</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductFormPage;
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useProducts, useDeleteProduct } from '../core/_hooks';
|
||||
import { useCategories } from '../../categories/core/_hooks';
|
||||
import { Product } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { Trash2, Edit3, Plus, Package, Eye, Image } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
|
||||
const ProductsTableSkeleton = () => (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
محصول
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
قیمت
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
دستهبندی
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
وضعیت
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
عملیات
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProductsListPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [deleteProductId, setDeleteProductId] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
category_id: '',
|
||||
status: '',
|
||||
min_price: '',
|
||||
max_price: ''
|
||||
});
|
||||
|
||||
const { data: productsData, isLoading, error } = useProducts(filters);
|
||||
const { data: categories } = useCategories();
|
||||
const { mutate: deleteProduct, isPending: isDeleting } = useDeleteProduct();
|
||||
|
||||
const products = productsData?.products || [];
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate('/products/create');
|
||||
};
|
||||
|
||||
const handleEdit = (productId: number) => {
|
||||
navigate(`/products/${productId}/edit`);
|
||||
};
|
||||
|
||||
const handleView = (productId: number) => {
|
||||
navigate(`/products/${productId}`);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteProductId) {
|
||||
deleteProduct(deleteProductId, {
|
||||
onSuccess: () => {
|
||||
setDeleteProductId(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFilters(prev => ({ ...prev, search: e.target.value }));
|
||||
};
|
||||
|
||||
const handleCategoryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setFilters(prev => ({ ...prev, category_id: e.target.value }));
|
||||
};
|
||||
|
||||
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setFilters(prev => ({ ...prev, status: e.target.value }));
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">فعال</span>;
|
||||
case 'inactive':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">غیرفعال</span>;
|
||||
case 'draft':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">پیشنویس</span>;
|
||||
default:
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری محصولات</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Package className="h-6 w-6" />
|
||||
مدیریت محصولات
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
مدیریت محصولات، قیمتها و موجودی
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
محصول جدید
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
جستجو
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در نام محصول..."
|
||||
value={filters.search}
|
||||
onChange={handleSearchChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
دستهبندی
|
||||
</label>
|
||||
<select
|
||||
value={filters.category_id}
|
||||
onChange={handleCategoryChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
<option value="">همه دستهبندیها</option>
|
||||
{(categories || []).map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
وضعیت
|
||||
</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={handleStatusChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
<option value="">همه وضعیتها</option>
|
||||
<option value="active">فعال</option>
|
||||
<option value="inactive">غیرفعال</option>
|
||||
<option value="draft">پیشنویس</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
محدوده قیمت
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="حداقل"
|
||||
value={filters.min_price}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, min_price: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="حداکثر"
|
||||
value={filters.max_price}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, max_price: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products Table */}
|
||||
{isLoading ? (
|
||||
<ProductsTableSkeleton />
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
محصول
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
قیمت
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
دستهبندی
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
وضعیت
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
عملیات
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{products.map((product: Product) => (
|
||||
<tr key={product.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<img
|
||||
src={product.images[0].url}
|
||||
alt={product.name}
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
|
||||
<Image className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{product.name}
|
||||
</div>
|
||||
{product.sku && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
SKU: {product.sku}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatPrice(product.price)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{product.category?.name || 'بدون دستهبندی'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(product.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleView(product.id)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title="مشاهده"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(product.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteProductId(product.id.toString())}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="md:hidden p-4 space-y-4">
|
||||
{products.map((product: Product) => (
|
||||
<div key={product.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex gap-3 mb-3">
|
||||
<div className="flex-shrink-0">
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<img
|
||||
src={product.images[0].url}
|
||||
alt={product.name}
|
||||
className="w-12 h-12 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
|
||||
<Image className="h-6 w-6 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatPrice(product.price)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{getStatusBadge(product.status)}
|
||||
{product.category && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{product.category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleView(product.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
مشاهده
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(product.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteProductId(product.id.toString())}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{(!products || products.length === 0) && !isLoading && (
|
||||
<div className="text-center py-12">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
محصولی موجود نیست
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
برای شروع، اولین محصول خود را ایجاد کنید.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2 mx-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
ایجاد محصول جدید
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={!!deleteProductId}
|
||||
onClose={() => setDeleteProductId(null)}
|
||||
title="حذف محصول"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
آیا از حذف این محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و تمام اطلاعات مربوط به محصول از جمله نسخهها و تصاویر حذف خواهد شد.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDeleteProductId(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteConfirm}
|
||||
loading={isDeleting}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductsListPage;
|
||||
Loading…
Reference in New Issue