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