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:
hossein taromi 2025-07-27 14:45:57 +03:30
parent 5dbe901ac7
commit 87213732a2
5 changed files with 1511 additions and 0 deletions

View File

@ -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 || "خطا در حذف نسخه محصول");
},
});
};

View File

@ -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;

View File

@ -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;
};

View File

@ -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;

View File

@ -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;