feat(orders): enhance order detail and list pages with improved data handling and UI updates

This commit is contained in:
hosseintaromi 2025-09-26 12:16:58 +03:30
parent 07fd4e1d2d
commit dad0ff292d
13 changed files with 448 additions and 308 deletions

View File

@ -96,9 +96,9 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
column.align === 'center' && 'justify-center',
(!column.align || column.align === 'right') && 'justify-end'
)}>
<span>{column.label}</span>
<span style={{ width: '100%', textAlign: 'right' }}>{column.label}</span>
{column.sortable && (
<div className="flex flex-col">
<div className="flex flex-col ml-1">
<ChevronUp
className={clsx(
'h-3 w-3',

View File

@ -1,56 +1,57 @@
export const API_GATE_WAY = "https://apimznstg.aireview.ir";
export const ADMIN_API_PREFIX = "api/v1/admin";
export const REQUEST_TIMEOUT = 30000;
export const API_ROUTES = {
// Auth APIs
ADMIN_LOGIN: "api/v1/admin/auth/login",
ADMIN_LOGIN: "auth/login",
// Draft APIs
// Draft APIs (non-admin)
GET_DISCOUNT_DETAIL: (id: string) => `api/v1/discount-drafts/${id}`,
GET_DRAFT_DETAIL: (id: string) => `api/v1/drafts/${id}`,
// Admin Users APIs
GET_ADMIN_USERS: "api/v1/admin/admin-users",
GET_ADMIN_USER: (id: string) => `api/v1/admin/admin-users/${id}`,
CREATE_ADMIN_USER: "api/v1/admin/admin-users",
UPDATE_ADMIN_USER: (id: string) => `api/v1/admin/admin-users/${id}`,
DELETE_ADMIN_USER: (id: string) => `api/v1/admin/admin-users/${id}`,
GET_ADMIN_USERS: "admin-users",
GET_ADMIN_USER: (id: string) => `admin-users/${id}`,
CREATE_ADMIN_USER: "admin-users",
UPDATE_ADMIN_USER: (id: string) => `admin-users/${id}`,
DELETE_ADMIN_USER: (id: string) => `admin-users/${id}`,
// Roles APIs
GET_ROLES: "api/v1/admin/roles",
GET_ROLE: (id: string) => `api/v1/admin/roles/${id}`,
CREATE_ROLE: "api/v1/admin/roles",
UPDATE_ROLE: (id: string) => `api/v1/admin/roles/${id}`,
DELETE_ROLE: (id: string) => `api/v1/admin/roles/${id}`,
GET_ROLE_PERMISSIONS: (id: string) => `api/v1/admin/roles/${id}/permissions`,
GET_ROLES: "roles",
GET_ROLE: (id: string) => `roles/${id}`,
CREATE_ROLE: "roles",
UPDATE_ROLE: (id: string) => `roles/${id}`,
DELETE_ROLE: (id: string) => `roles/${id}`,
GET_ROLE_PERMISSIONS: (id: string) => `roles/${id}/permissions`,
ASSIGN_ROLE_PERMISSION: (roleId: string, permissionId: string) =>
`api/v1/admin/roles/${roleId}/permissions/${permissionId}`,
`roles/${roleId}/permissions/${permissionId}`,
REMOVE_ROLE_PERMISSION: (roleId: string, permissionId: string) =>
`api/v1/admin/roles/${roleId}/permissions/${permissionId}`,
`roles/${roleId}/permissions/${permissionId}`,
// Permissions APIs
GET_PERMISSIONS: "api/v1/admin/permissions",
GET_PERMISSION: (id: string) => `api/v1/admin/permissions/${id}`,
CREATE_PERMISSION: "api/v1/admin/permissions",
UPDATE_PERMISSION: (id: string) => `api/v1/admin/permissions/${id}`,
DELETE_PERMISSION: (id: string) => `api/v1/admin/permissions/${id}`,
GET_PERMISSIONS: "permissions",
GET_PERMISSION: (id: string) => `permissions/${id}`,
CREATE_PERMISSION: "permissions",
UPDATE_PERMISSION: (id: string) => `permissions/${id}`,
DELETE_PERMISSION: (id: string) => `permissions/${id}`,
// Product Options APIs
// Product Options APIs (non-admin)
GET_PRODUCT_OPTIONS: "api/v1/product-options",
GET_PRODUCT_OPTION: (id: string) => `api/v1/product-options/${id}`,
CREATE_PRODUCT_OPTION: "api/v1/product-options",
UPDATE_PRODUCT_OPTION: (id: string) => `api/v1/product-options/${id}`,
DELETE_PRODUCT_OPTION: (id: string) => `api/v1/product-options/${id}`,
// Categories APIs
// Categories APIs (non-admin)
GET_CATEGORIES: "api/v1/products/categories",
GET_CATEGORY: (id: string) => `api/v1/products/categories/${id}`,
CREATE_CATEGORY: "api/v1/products/categories",
UPDATE_CATEGORY: (id: string) => `api/v1/products/categories/${id}`,
DELETE_CATEGORY: (id: string) => `api/v1/products/categories/${id}`,
// Products APIs
// Products APIs (non-admin)
GET_PRODUCTS: "api/v1/products",
GET_PRODUCT: (id: string) => `api/v1/products/${id}`,
CREATE_PRODUCT: "api/v1/products",
@ -64,54 +65,52 @@ export const API_ROUTES = {
`api/v1/products/variants/${variantId}`,
// Files APIs
GET_FILES: "api/v1/admin/files",
UPLOAD_FILE: "api/v1/admin/files",
GET_FILE: (id: string) => `api/v1/admin/files/${id}`,
UPDATE_FILE: (id: string) => `api/v1/admin/files/${id}`,
DELETE_FILE: (id: string) => `api/v1/admin/files/${id}`,
DOWNLOAD_FILE: (serveKey: string) => `api/v1/files/${serveKey}`,
GET_FILES: "files",
UPLOAD_FILE: "files",
GET_FILE: (id: string) => `files/${id}`,
UPDATE_FILE: (id: string) => `files/${id}`,
DELETE_FILE: (id: string) => `files/${id}`,
DOWNLOAD_FILE: (serveKey: string) => `api/v1/files/${serveKey}`, // non-admin
// Images APIs
// Images APIs (non-admin)
GET_IMAGES: "api/v1/images",
CREATE_IMAGE: "api/v1/images",
UPDATE_IMAGE: (imageId: string) => `api/v1/products/images/${imageId}`,
DELETE_IMAGE: (imageId: string) => `api/v1/products/images/${imageId}`,
// Landing Hero APIs
GET_LANDING_HERO: "api/v1/settings/landing/hero",
UPDATE_LANDING_HERO: "api/v1/admin/settings/landing/hero",
GET_LANDING_HERO: "api/v1/settings/landing/hero", // non-admin
UPDATE_LANDING_HERO: "settings/landing/hero", // admin
// Discount Codes APIs
GET_DISCOUNT_CODES: "api/v1/admin/discount/",
GET_DISCOUNT_CODE: (id: string) => `api/v1/admin/discount/${id}/`,
CREATE_DISCOUNT_CODE: "api/v1/admin/discount/",
UPDATE_DISCOUNT_CODE: (id: string) => `api/v1/admin/discount/${id}/`,
DELETE_DISCOUNT_CODE: (id: string) => `api/v1/admin/discount/${id}/`,
GET_DISCOUNT_CODES: "discount/",
GET_DISCOUNT_CODE: (id: string) => `discount/${id}/`,
CREATE_DISCOUNT_CODE: "discount/",
UPDATE_DISCOUNT_CODE: (id: string) => `discount/${id}/`,
DELETE_DISCOUNT_CODE: (id: string) => `discount/${id}/`,
// Orders APIs
GET_ORDERS: "checkout/orders",
GET_ORDER: (id: string) => `checkout/orders/${id}`,
GET_ORDER_STATS: "checkout/orders/stats",
UPDATE_ORDER_STATUS: (id: string) => `checkout/orders/${id}/status`,
// Shipping Methods APIs
GET_SHIPPING_METHODS: "api/v1/admin/checkout/shipping-methods",
GET_SHIPPING_METHOD: (id: string) =>
`api/v1/admin/checkout/shipping-methods/${id}`,
CREATE_SHIPPING_METHOD: "api/v1/admin/checkout/shipping-methods",
UPDATE_SHIPPING_METHOD: (id: string) =>
`api/v1/admin/checkout/shipping-methods/${id}`,
DELETE_SHIPPING_METHOD: (id: string) =>
`api/v1/admin/checkout/shipping-methods/${id}`,
GET_SHIPPING_METHODS: "checkout/shipping-methods",
GET_SHIPPING_METHOD: (id: string) => `checkout/shipping-methods/${id}`,
CREATE_SHIPPING_METHOD: "checkout/shipping-methods",
UPDATE_SHIPPING_METHOD: (id: string) => `checkout/shipping-methods/${id}`,
DELETE_SHIPPING_METHOD: (id: string) => `checkout/shipping-methods/${id}`,
// User Admin APIs
GET_USERS: "api/v1/admin/users",
GET_USER: (id: string) => `api/v1/admin/users/${id}`,
SEARCH_USERS: "api/v1/admin/users/search",
CREATE_USER: "api/v1/admin/users",
UPDATE_USER: (id: string) => `api/v1/admin/users/${id}`,
UPDATE_USER_PROFILE: (id: string) => `api/v1/admin/users/${id}/profile`,
UPDATE_USER_AVATAR: (id: string) => `api/v1/admin/users/${id}/avatar`,
DELETE_USER: (id: string) => `api/v1/admin/users/${id}`,
VERIFY_USER: (id: string) => `api/v1/admin/users/${id}/verify`,
UNVERIFY_USER: (id: string) => `api/v1/admin/users/${id}/unverify`,
GET_USERS: "users",
GET_USER: (id: string) => `users/${id}`,
SEARCH_USERS: "users/search",
CREATE_USER: "users",
UPDATE_USER: (id: string) => `users/${id}`,
UPDATE_USER_PROFILE: (id: string) => `users/${id}/profile`,
UPDATE_USER_AVATAR: (id: string) => `users/${id}/avatar`,
DELETE_USER: (id: string) => `users/${id}`,
VERIFY_USER: (id: string) => `users/${id}/verify`,
UNVERIFY_USER: (id: string) => `users/${id}/unverify`,
};

View File

@ -27,7 +27,7 @@ export const getCategories = async (filters?: CategoryFilters) => {
if (filters?.limit) queryParams.limit = filters.limit;
const response = await httpGetRequest<CategoriesResponse>(
APIUrlGenerator(API_ROUTES.GET_CATEGORIES, queryParams)
APIUrlGenerator(API_ROUTES.GET_CATEGORIES, queryParams, undefined, false)
);
console.log("Categories API Response:", response);
@ -50,14 +50,14 @@ export const getCategories = async (filters?: CategoryFilters) => {
export const getCategory = async (id: string) => {
const response = await httpGetRequest<CategoryResponse>(
APIUrlGenerator(API_ROUTES.GET_CATEGORY(id))
APIUrlGenerator(API_ROUTES.GET_CATEGORY(id), undefined, undefined, false)
);
return response.data.category;
};
export const createCategory = async (data: CreateCategoryRequest) => {
const response = await httpPostRequest<CreateCategoryResponse>(
APIUrlGenerator(API_ROUTES.CREATE_CATEGORY),
APIUrlGenerator(API_ROUTES.CREATE_CATEGORY, undefined, undefined, false),
data
);
return response.data.category;
@ -65,7 +65,12 @@ export const createCategory = async (data: CreateCategoryRequest) => {
export const updateCategory = async (data: UpdateCategoryRequest) => {
const response = await httpPutRequest<UpdateCategoryResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_CATEGORY(data.id.toString())),
APIUrlGenerator(
API_ROUTES.UPDATE_CATEGORY(data.id.toString()),
undefined,
undefined,
false
),
data
);
return response.data.category;
@ -73,7 +78,7 @@ export const updateCategory = async (data: UpdateCategoryRequest) => {
export const deleteCategory = async (id: string) => {
const response = await httpDeleteRequest<DeleteCategoryResponse>(
APIUrlGenerator(API_ROUTES.DELETE_CATEGORY(id))
APIUrlGenerator(API_ROUTES.DELETE_CATEGORY(id), undefined, undefined, false)
);
return response.data;
};

View File

@ -62,25 +62,28 @@ export interface OrderCustomer {
}
export interface Order {
id: number;
order_number: string;
customer: OrderCustomer;
status: OrderStatus;
items: OrderItem[];
billing_address: OrderAddress;
shipping_address: OrderAddress;
payment: OrderPayment;
subtotal: number;
tax_amount: number;
shipping_amount: number;
discount_amount: number;
total_amount: number;
currency: string;
notes?: string;
tracking_number?: string;
estimated_delivery?: string;
created_at: string;
updated_at: string;
order: {
id: number;
order_number: string;
customer: OrderCustomer;
status: OrderStatus;
items: OrderItem[];
billing_address: OrderAddress;
shipping_address: OrderAddress;
payment: OrderPayment;
subtotal: number;
tax_amount: number;
shipping_amount: number;
discount_amount: number;
total_amount: number;
final_total: number;
currency: string;
notes?: string;
tracking_number?: string;
estimated_delivery?: string;
created_at: string;
updated_at: string;
};
}
export interface OrderFilters {
@ -88,7 +91,7 @@ export interface OrderFilters {
page?: number;
limit?: number;
offset?: number;
// Filter Parameters
user_id?: number;
invoice_id?: number;
@ -98,13 +101,13 @@ export interface OrderFilters {
order_number?: string;
transaction_id?: string;
discount_code?: string;
// Amount Range Parameters
min_total?: number;
max_total?: number;
min_amount?: number; // legacy support
max_amount?: number; // legacy support
// Date Range Parameters
created_from?: string;
created_to?: string;
@ -112,7 +115,7 @@ export interface OrderFilters {
updated_to?: string;
date_from?: string; // legacy support
date_to?: string; // legacy support
// Search Parameter
search?: string;
}

View File

@ -17,35 +17,46 @@ export const getOrders = async (filters?: OrderFilters) => {
const queryParams: Record<string, string | number | null> = {};
// Pagination
if (filters?.page) queryParams.page = filters.page;
if (filters?.limit) queryParams.limit = filters.limit;
if (filters?.offset) queryParams.offset = filters.offset;
// Prefer offset; compute from page when provided
if (filters?.offset !== undefined && filters.offset !== null) {
queryParams.offset = filters.offset;
} else if (filters?.page) {
const effectiveLimit = filters?.limit || 20;
queryParams.offset = (filters.page - 1) * effectiveLimit;
}
// Filter Parameters
if (filters?.user_id) queryParams.user_id = filters.user_id;
if (filters?.invoice_id) queryParams.invoice_id = filters.invoice_id;
if (filters?.status) queryParams.status = filters.status;
if (filters?.payment_status) queryParams.payment_status = filters.payment_status;
if (filters?.payment_status)
queryParams.payment_status = filters.payment_status;
if (filters?.customer_id) queryParams.customer_id = filters.customer_id;
if (filters?.order_number) queryParams.order_number = filters.order_number;
if (filters?.transaction_id) queryParams.transaction_id = filters.transaction_id;
if (filters?.transaction_id)
queryParams.transaction_id = filters.transaction_id;
if (filters?.discount_code) queryParams.discount_code = filters.discount_code;
// Amount Range Parameters (prefer new API naming)
if (filters?.min_total) queryParams.min_total = filters.min_total;
if (filters?.max_total) queryParams.max_total = filters.max_total;
if (filters?.min_amount && !filters?.min_total) queryParams.min_total = filters.min_amount;
if (filters?.max_amount && !filters?.max_total) queryParams.max_total = filters.max_amount;
if (filters?.min_amount && !filters?.min_total)
queryParams.min_total = filters.min_amount;
if (filters?.max_amount && !filters?.max_total)
queryParams.max_total = filters.max_amount;
// Date Range Parameters (prefer new API naming)
if (filters?.created_from) queryParams.created_from = filters.created_from;
if (filters?.created_to) queryParams.created_to = filters.created_to;
if (filters?.updated_from) queryParams.updated_from = filters.updated_from;
if (filters?.updated_to) queryParams.updated_to = filters.updated_to;
if (filters?.date_from && !filters?.created_from) queryParams.created_from = filters.date_from;
if (filters?.date_to && !filters?.created_to) queryParams.created_to = filters.date_to;
// Search Parameter
if (filters?.date_from && !filters?.created_from)
queryParams.created_from = filters.date_from;
if (filters?.date_to && !filters?.created_to)
queryParams.created_to = filters.date_to;
// Search Parameter (per API spec)
if (filters?.search) queryParams.search = filters.search;
const response = await httpGetRequest<PaginatedOrdersResponse>(
@ -75,28 +86,10 @@ export const updateOrderStatus = async (
export const getOrderStats = async (): Promise<OrderStats> => {
try {
const ordersResponse = await getOrders({ limit: 20 });
const stats: OrderStats = {
total_orders: ordersResponse.total,
total_revenue: ordersResponse.orders.reduce(
(sum, order) => sum + order.total_amount,
0
),
orders_by_status: ordersResponse.orders.reduce((acc, order) => {
acc[order.status] = (acc[order.status] || 0) + 1;
return acc;
}, {} as Record<OrderStatus, number>),
avg_order_value:
ordersResponse.orders.length > 0
? ordersResponse.orders.reduce(
(sum, order) => sum + order.total_amount,
0
) / ordersResponse.orders.length
: 0,
};
return stats;
const response = await httpGetRequest<OrderStats>(
APIUrlGenerator(API_ROUTES.GET_ORDER_STATS)
);
return response.data;
} catch (error) {
console.error("Error fetching order stats:", error);
throw error;

View File

@ -19,6 +19,7 @@ import {
Mail,
FileText
} from 'lucide-react';
import { englishToPersian } from '@/utils/numberUtils';
const getStatusColor = (status: OrderStatus) => {
const colors = {
@ -64,9 +65,9 @@ const OrderDetailPage = () => {
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
const [newStatus, setNewStatus] = useState<OrderStatus>('processing');
const { data: order, isLoading, error } = useOrder(id || '');
const { data, isLoading, error } = useOrder(id || '');
const { mutate: updateStatus, isPending: isUpdating } = useUpdateOrderStatus();
const order = data?.order;
const handleStatusUpdate = () => {
if (id) {
updateStatus(
@ -78,13 +79,13 @@ const OrderDetailPage = () => {
const handleUpdateStatusClick = () => {
if (order) {
setNewStatus(order.status);
setNewStatus(order?.status || 'pending');
setStatusUpdateOpen(true);
}
};
if (isLoading) return <LoadingSpinner />;
console.log(order)
if (error || !order) {
return (
<PageContainer>
@ -103,9 +104,9 @@ const OrderDetailPage = () => {
{/* هدر صفحه */}
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<PageTitle>سفارش #{order.order_number}</PageTitle>
<PageTitle>سفارش #{order?.order_number || 'نامشخص'}</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
تاریخ ثبت: {formatDate(order.created_at)}
تاریخ ثبت: {order?.created_at ? formatDate(order.created_at) : 'نامشخص'}
</p>
</div>
<div className="flex gap-3">
@ -141,35 +142,51 @@ const OrderDetailPage = () => {
</div>
<SectionTitle>اطلاعات سفارش</SectionTitle>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(order.status)}`}>
{getStatusText(order.status)}
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(order?.status || 'pending')}`}>
{getStatusText(order?.status || 'pending')}
</span>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">شماره سفارش</h4>
<p className="text-gray-600 dark:text-gray-400">#{order.order_number}</p>
<p className="text-gray-600 dark:text-gray-400">#{order?.order_number || 'نامشخص'}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ ثبت</h4>
<p className="text-gray-600 dark:text-gray-400">{formatDate(order.created_at)}</p>
<p className="text-gray-600 dark:text-gray-400">{order?.created_at ? formatDate(order.created_at) : 'نامشخص'}</p>
</div>
{order.tracking_number && (
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">شناسه فاکتور</h4>
<p className="text-gray-600 dark:text-gray-400 font-mono">{order?.invoice_id || 'نامشخص'}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">شناسه کاربر</h4>
<p className="text-gray-600 dark:text-gray-400">{order?.user_id || 'نامشخص'}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ آخرین بروزرسانی</h4>
<p className="text-gray-600 dark:text-gray-400">{order?.updated_at ? formatDate(order.updated_at) : 'نامشخص'}</p>
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">روش حمل و نقل</h4>
<p className="text-gray-600 dark:text-gray-400">{order?.shipping_method_id || 'تعریف نشده'}</p>
</div>
{order?.tracking_number && (
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">کد رهگیری</h4>
<p className="text-gray-600 dark:text-gray-400 font-mono">{order.tracking_number}</p>
</div>
)}
{order.estimated_delivery && (
{order?.estimated_delivery && (
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ تحویل تخمینی</h4>
<p className="text-gray-600 dark:text-gray-400">{formatDate(order.estimated_delivery)}</p>
</div>
)}
</div>
{order.notes && (
{order?.notes && (
<div className="mt-6">
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">یادداشت</h4>
<p className="text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-700 p-3 rounded-lg">
@ -192,7 +209,7 @@ const OrderDetailPage = () => {
</div>
<div className="p-6">
<div className="space-y-4">
{order.items.map((item) => (
{order?.items && order.items.length > 0 ? order.items.map((item) => (
<div key={item.id} className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
{item.product_image && (
<img
@ -202,26 +219,43 @@ const OrderDetailPage = () => {
/>
)}
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-gray-100">{item.product_name}</h4>
{item.variant_name && (
<p className="text-sm text-gray-500 dark:text-gray-400">نوع: {item.variant_name}</p>
)}
<h4 className="font-medium text-gray-900 dark:text-gray-100">
{item.product_name || `محصول شناسه: ${item.product_id}`}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{item.product_variant_name || `واریانت شناسه: ${item.product_variant_id}`}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
شناسه آیتم: {item.id}
</p>
<div className="flex items-center gap-4 mt-2">
<span className="text-sm text-gray-600 dark:text-gray-400">
تعداد: {item.quantity}
</span>
<span className="text-sm text-gray-600 dark:text-gray-400">
قیمت واحد: {formatCurrency(item.unit_price)}
قیمت واحد: {formatCurrency(item.unit_price || 0)}
</span>
<span className="text-sm text-gray-600 dark:text-gray-400">
وزن: {item.weight || 0} کگ
</span>
{item.final_weight && item.final_weight !== item.weight && (
<span className="text-sm text-gray-600 dark:text-gray-400">
وزن نهایی: {item.final_weight} کگ
</span>
)}
</div>
</div>
<div className="text-right">
<p className="font-medium text-gray-900 dark:text-gray-100">
{formatCurrency(item.total_price)}
{formatCurrency(item.total_price || 0)}
</p>
</div>
</div>
))}
)) : (
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
محصولی در این سفارش یافت نشد
</p>
)}
</div>
</div>
</div>
@ -229,6 +263,51 @@ const OrderDetailPage = () => {
{/* ستون جانبی */}
<div className="space-y-8">
{/* اطلاعات کاربر سفارش‌دهنده */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="bg-gradient-to-r from-indigo-50 to-blue-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 dark:bg-indigo-900 rounded-lg">
<User className="h-5 w-5 text-indigo-600 dark:text-indigo-300" />
</div>
<SectionTitle>کاربر سفارشدهنده</SectionTitle>
</div>
</div>
<div className="p-6">
{order?.user ? (
<div className="space-y-4">
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-1">نام</h4>
<p className="text-gray-600 dark:text-gray-400">
{(order.user.first_name || 'نامشخص') + ' ' + (order.user.last_name || '')}
</p>
</div>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-gray-400" />
<p className="text-gray-600 dark:text-gray-400">{order.user.email || 'ایمیل نامشخص'}</p>
</div>
{order.user.phone_number && (
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-gray-400" />
<p className="text-gray-600 dark:text-gray-400 font-mono" dir="ltr" style={{ direction: 'ltr' }}>
{englishToPersian(order.user.phone_number)}
</p>
</div>
)}
<div className="flex items-center gap-2">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${order.user.verified
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'}`}
>
{order.user.verified ? 'تأیید شده' : 'تأیید نشده'}
</span>
</div>
</div>
) : (
<p className="text-gray-500 dark:text-gray-400">اطلاعات کاربر در دسترس نیست</p>
)}
</div>
</div>
{/* اطلاعات مشتری */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
@ -240,24 +319,28 @@ const OrderDetailPage = () => {
</div>
</div>
<div className="p-6">
<div className="space-y-4">
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-1">نام</h4>
<p className="text-gray-600 dark:text-gray-400">
{order.customer.first_name} {order.customer.last_name}
</p>
</div>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-gray-400" />
<p className="text-gray-600 dark:text-gray-400">{order.customer.email}</p>
</div>
{order.customer.phone && (
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-gray-400" />
<p className="text-gray-600 dark:text-gray-400" dir="ltr">{order.customer.phone}</p>
{order?.customer ? (
<div className="space-y-4">
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-1">نام</h4>
<p className="text-gray-600 dark:text-gray-400">
{order.customer.first_name || 'نامشخص'} {order.customer.last_name || ''}
</p>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-gray-400" />
<p className="text-gray-600 dark:text-gray-400">{order.customer.email || 'ایمیل نامشخص'}</p>
</div>
{order.customer.phone && (
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-gray-400" />
<p className="text-gray-600 dark:text-gray-400" dir="ltr">{order.customer.phone}</p>
</div>
)}
</div>
) : (
<p className="text-gray-500 dark:text-gray-400">اطلاعات مشتری در دسترس نیست</p>
)}
</div>
</div>
@ -275,23 +358,18 @@ const OrderDetailPage = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-3">آدرس ارسال</h4>
<div className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<p>{order.shipping_address.first_name} {order.shipping_address.last_name}</p>
<p>{order.shipping_address.address_line_1}</p>
{order.shipping_address.address_line_2 && <p>{order.shipping_address.address_line_2}</p>}
<p>{order.shipping_address.city}, {order.shipping_address.state}</p>
<p>کد پستی: {order.shipping_address.postal_code}</p>
{order.shipping_address.phone && <p>تلفن: {order.shipping_address.phone}</p>}
</div>
</div>
<hr className="border-gray-200 dark:border-gray-700" />
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-3">آدرس صورتحساب</h4>
<div className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<p>{order.billing_address.first_name} {order.billing_address.last_name}</p>
<p>{order.billing_address.address_line_1}</p>
{order.billing_address.address_line_2 && <p>{order.billing_address.address_line_2}</p>}
<p>{order.billing_address.city}, {order.billing_address.state}</p>
<p>کد پستی: {order.billing_address.postal_code}</p>
<p><strong>نام:</strong> {order?.shipping_address?.name || 'نام نامشخص'}</p>
<p><strong>آدرس:</strong> {order?.shipping_address?.address || 'آدرس نامشخص'}</p>
<p><strong>شهر:</strong> {order?.shipping_address?.city || 'شهر نامشخص'}, <strong>استان:</strong> {order?.shipping_address?.state || 'استان نامشخص'}</p>
<p><strong>کشور:</strong> {order?.shipping_address?.country || 'کشور نامشخص'}</p>
<p><strong>منطقه:</strong> {order?.shipping_address?.region || 'منطقه نامشخص'}</p>
<p><strong>کد پستی:</strong> {order?.shipping_address?.postal_code || 'نامشخص'}</p>
{order?.shipping_address?.plaque && (
<p><strong>پلاک:</strong> {order.shipping_address.plaque}, <strong>واحد:</strong> {order.shipping_address.unit || 'ندارد'}</p>
)}
{order?.shipping_address?.receiving_address && (
<p><strong>آدرس تحویل:</strong> {order.shipping_address.receiving_address}</p>
)}
</div>
</div>
</div>
@ -310,48 +388,46 @@ const OrderDetailPage = () => {
<div className="p-6 space-y-4">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">جمع فرعی</span>
<span className="font-medium">{formatCurrency(order.subtotal)}</span>
<span className="font-medium">{formatCurrency(order?.net_total || 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">مالیات</span>
<span className="font-medium">{formatCurrency(order.tax_amount)}</span>
<span className="font-medium">{formatCurrency(order?.vat_total || 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">هزینه ارسال</span>
<span className="font-medium">{formatCurrency(order.shipping_amount)}</span>
<span className="font-medium">{formatCurrency(order?.shipping_total || 0)}</span>
</div>
{order.discount_amount > 0 && (
{(order?.discount_total || 0) > 0 && (
<div className="flex justify-between text-green-600 dark:text-green-400">
<span>تخفیف</span>
<span className="font-medium">-{formatCurrency(order.discount_amount)}</span>
<span className="font-medium">-{formatCurrency(order.discount_total)}</span>
</div>
)}
<hr className="border-gray-200 dark:border-gray-700" />
<div className="flex justify-between text-lg font-bold">
<span>مجموع</span>
<span>{formatCurrency(order.total_amount)}</span>
<span>مجموع نهایی</span>
<span>{formatCurrency(order?.final_total || 0)}</span>
</div>
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">روش پرداخت</span>
<span className="text-sm font-medium">{order.payment.payment_method}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">وضعیت پرداخت</span>
<span className={`text-sm font-medium ${order.payment.payment_status === 'paid'
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}`}>
{order.payment.payment_status === 'paid' ? 'پرداخت شده' : 'در انتظار پرداخت'}
</span>
</div>
{order.payment.transaction_id && (
<div className="flex justify-between mt-2">
<span className="text-sm text-gray-600 dark:text-gray-400">شماره تراکنش</span>
<span className="text-sm font-mono">{order.payment.transaction_id}</span>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">وضعیت پرداخت</span>
<span className={`text-sm font-medium px-2 py-1 rounded-full ${order?.payment_status === 'paid'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}>
{order?.payment_status === 'paid' ? 'پرداخت شده' : 'در انتظار پرداخت'}
</span>
</div>
)}
{order?.invoice_id && (
<div className="flex justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">شماره فاکتور</span>
<span className="text-sm font-mono text-gray-900 dark:text-gray-100">{order.invoice_id}</span>
</div>
)}
</div>
</div>
</div>
</div>

View File

@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react';
import { englishToPersian } from '@/utils/numberUtils';
import { useNavigate } from 'react-router-dom';
import { useOrders, useOrderStats, useUpdateOrderStatus } from '../core/_hooks';
import { Order, OrderFilters, OrderStatus } from '../core/_models';
@ -72,29 +73,39 @@ const OrdersListPage = () => {
});
const { data: ordersData, isLoading, error } = useOrders(filters);
const { data: stats, isLoading: statsLoading } = useOrderStats(!isLoading);
// Temporarily disabled stats API
// const { data: stats, isLoading: statsLoading } = useOrderStats(!isLoading);
const stats = null;
const statsLoading = false;
const { mutate: updateStatus, isPending: isUpdating } = useUpdateOrderStatus();
const columns: TableColumn[] = useMemo(() => [
{ key: 'order_number', label: 'شماره سفارش', sortable: true, render: (v: string) => `#${v}` },
{ key: 'order_number', label: 'شماره سفارش', sortable: true, align: 'right', render: (v: string) => `#${v}` },
{
key: 'customer',
label: 'مشتری',
align: 'right',
render: (_val, row: any) => (
<div>
<div className="font-medium">{row.customer.first_name} {row.customer.last_name}</div>
<div className="text-gray-500 dark:text-gray-400">{row.customer.email}</div>
<div className="text-right">
<div className="font-medium">
{(row.user?.first_name || row.customer?.first_name || 'نامشخص')} {(row.user?.last_name || row.customer?.last_name || '')}
</div>
<div className="text-gray-500 dark:text-gray-400" dir="ltr" style={{ direction: 'ltr' }}>
{row.user?.phone_number ? englishToPersian(row.user.phone_number) : '-'}
</div>
</div>
)
},
{ key: 'total_amount', label: 'مبلغ', sortable: true, render: (v: number) => formatCurrency(v) },
{ key: 'status', label: 'وضعیت', render: (v: OrderStatus) => (<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(v)}`}>{getStatusText(v)}</span>) },
{ key: 'created_at', label: 'تاریخ', sortable: true, render: (v: string) => formatDate(v) },
{ key: 'final_total', label: 'مبلغ نهایی', sortable: true, align: 'right', render: (v: number, row: any) => formatCurrency(row.final_total || row.total_amount || 0) },
{ key: 'status', label: 'وضعیت', align: 'right', render: (v: OrderStatus) => (<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(v)}`}>{getStatusText(v)}</span>) },
{ key: 'created_at', label: 'تاریخ', sortable: true, align: 'right', render: (v: string) => formatDate(v) },
{
key: 'actions',
label: 'عملیات',
align: 'right',
render: (_val, row: any) => (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 justify-end">
<button
onClick={() => handleViewOrder(row.id)}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
@ -170,7 +181,7 @@ const OrdersListPage = () => {
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل سفارشات</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{statsLoading ? '...' : stats?.total_orders?.toLocaleString('fa-IR') || '0'}
--
</p>
</div>
</div>
@ -184,7 +195,7 @@ const OrdersListPage = () => {
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل فروش</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{statsLoading ? '...' : formatCurrency(stats?.total_revenue || 0)}
--
</p>
</div>
</div>
@ -198,7 +209,7 @@ const OrdersListPage = () => {
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">در انتظار</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{statsLoading ? '...' : (stats?.orders_by_status?.pending || 0)}
--
</p>
</div>
</div>
@ -212,7 +223,7 @@ const OrdersListPage = () => {
<div className="mr-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">میانگین سفارش</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{statsLoading ? '...' : formatCurrency(stats?.avg_order_value || 0)}
--
</p>
</div>
</div>
@ -222,14 +233,14 @@ const OrdersListPage = () => {
{/* فیلترها */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="relative">
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<div className="relative col-span-1 md:col-span-2 lg:col-span-4 order-first">
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 dark:text-gray-300" />
<input
type="text"
placeholder="جستجو عمومی (شماره سفارش، کد تراکنش، کد تخفیف)..."
value={filters.search || ''}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value, page: 1 }))}
className="w-full pr-10 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
className="w-full pr-10 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-300"
/>
</div>

View File

@ -39,7 +39,7 @@ export const getProducts = async (filters?: ProductFilters) => {
if (filters?.limit) queryParams.limit = filters.limit;
const response = await httpGetRequest<ProductsResponse>(
APIUrlGenerator(API_ROUTES.GET_PRODUCTS, queryParams)
APIUrlGenerator(API_ROUTES.GET_PRODUCTS, queryParams, undefined, false)
);
console.log("Products API Response:", response);
@ -87,14 +87,14 @@ export const getProducts = async (filters?: ProductFilters) => {
export const getProduct = async (id: string) => {
const response = await httpGetRequest<ProductResponse>(
APIUrlGenerator(API_ROUTES.GET_PRODUCT(id))
APIUrlGenerator(API_ROUTES.GET_PRODUCT(id), undefined, undefined, false)
);
return response.data.product;
};
export const createProduct = async (data: CreateProductRequest) => {
const response = await httpPostRequest<CreateProductResponse>(
APIUrlGenerator(API_ROUTES.CREATE_PRODUCT),
APIUrlGenerator(API_ROUTES.CREATE_PRODUCT, undefined, undefined, false),
data
);
return response.data.product;
@ -102,7 +102,12 @@ export const createProduct = async (data: CreateProductRequest) => {
export const updateProduct = async (data: UpdateProductRequest) => {
const response = await httpPutRequest<UpdateProductResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_PRODUCT(data.id.toString())),
APIUrlGenerator(
API_ROUTES.UPDATE_PRODUCT(data.id.toString()),
undefined,
undefined,
false
),
data
);
return response.data.product;
@ -110,7 +115,7 @@ export const updateProduct = async (data: UpdateProductRequest) => {
export const deleteProduct = async (id: string) => {
const response = await httpDeleteRequest<DeleteProductResponse>(
APIUrlGenerator(API_ROUTES.DELETE_PRODUCT(id))
APIUrlGenerator(API_ROUTES.DELETE_PRODUCT(id), undefined, undefined, false)
);
return response.data;
};
@ -119,7 +124,12 @@ export const deleteProduct = async (id: string) => {
export const getProductVariants = async (productId: string) => {
try {
const response = await httpGetRequest<ProductVariantsResponse>(
APIUrlGenerator(API_ROUTES.GET_PRODUCT_VARIANTS(productId))
APIUrlGenerator(
API_ROUTES.GET_PRODUCT_VARIANTS(productId),
undefined,
undefined,
false
)
);
console.log("Product Variants API Response:", response);
@ -143,7 +153,10 @@ export const getProductVariants = async (productId: string) => {
export const createProductVariant = async (data: CreateVariantRequest) => {
const response = await httpPostRequest<CreateVariantResponse>(
APIUrlGenerator(
API_ROUTES.CREATE_PRODUCT_VARIANT(data.product_id?.toString() || "")
API_ROUTES.CREATE_PRODUCT_VARIANT(data.product_id?.toString() || ""),
undefined,
undefined,
false
),
data
);
@ -152,7 +165,12 @@ export const createProductVariant = async (data: CreateVariantRequest) => {
export const updateProductVariant = async (data: UpdateVariantRequest) => {
const response = await httpPutRequest<UpdateVariantResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_PRODUCT_VARIANT(data.id.toString())),
APIUrlGenerator(
API_ROUTES.UPDATE_PRODUCT_VARIANT(data.id.toString()),
undefined,
undefined,
false
),
data
);
return response.data.variant;
@ -160,7 +178,12 @@ export const updateProductVariant = async (data: UpdateVariantRequest) => {
export const deleteProductVariant = async (variantId: string) => {
const response = await httpDeleteRequest<DeleteVariantResponse>(
APIUrlGenerator(API_ROUTES.DELETE_PRODUCT_VARIANT(variantId))
APIUrlGenerator(
API_ROUTES.DELETE_PRODUCT_VARIANT(variantId),
undefined,
undefined,
false
)
);
return response.data;
};

View File

@ -39,7 +39,9 @@ export const getUsers = async (filters?: UserFilters): Promise<User[]> => {
// Get user by ID
export const getUser = async (id: string): Promise<User> => {
const response = await httpGetRequest<User>(API_ROUTES.GET_USER(id));
const response = await httpGetRequest<User>(
APIUrlGenerator(API_ROUTES.GET_USER(id))
);
return response.data;
};
@ -70,7 +72,7 @@ export const createUser = async (
userData: CreateUserRequest
): Promise<User> => {
const response = await httpPostRequest<User>(
API_ROUTES.CREATE_USER,
APIUrlGenerator(API_ROUTES.CREATE_USER),
userData
);
return response.data;
@ -82,7 +84,7 @@ export const updateUser = async (
userData: UpdateUserRequest
): Promise<User> => {
const response = await httpPutRequest<User>(
API_ROUTES.UPDATE_USER(id),
APIUrlGenerator(API_ROUTES.UPDATE_USER(id)),
userData
);
return response.data;
@ -94,7 +96,7 @@ export const updateUserProfile = async (
userData: UpdateUserProfileRequest
): Promise<User> => {
const response = await httpPutRequest<User>(
API_ROUTES.UPDATE_USER_PROFILE(id),
APIUrlGenerator(API_ROUTES.UPDATE_USER_PROFILE(id)),
userData
);
return response.data;
@ -106,7 +108,7 @@ export const updateUserAvatar = async (
avatarData: UpdateUserAvatarRequest
): Promise<UserActionResponse> => {
const response = await httpPutRequest<UserActionResponse>(
API_ROUTES.UPDATE_USER_AVATAR(id),
APIUrlGenerator(API_ROUTES.UPDATE_USER_AVATAR(id)),
avatarData
);
return response.data;
@ -115,7 +117,7 @@ export const updateUserAvatar = async (
// Delete user
export const deleteUser = async (id: string): Promise<UserActionResponse> => {
const response = await httpDeleteRequest<UserActionResponse>(
API_ROUTES.DELETE_USER(id)
APIUrlGenerator(API_ROUTES.DELETE_USER(id))
);
return response.data;
};
@ -123,7 +125,7 @@ export const deleteUser = async (id: string): Promise<UserActionResponse> => {
// Verify user
export const verifyUser = async (id: string): Promise<UserActionResponse> => {
const response = await httpPostRequest<UserActionResponse>(
API_ROUTES.VERIFY_USER(id),
APIUrlGenerator(API_ROUTES.VERIFY_USER(id)),
{}
);
return response.data;
@ -132,7 +134,7 @@ export const verifyUser = async (id: string): Promise<UserActionResponse> => {
// Unverify user
export const unverifyUser = async (id: string): Promise<UserActionResponse> => {
const response = await httpPostRequest<UserActionResponse>(
API_ROUTES.UNVERIFY_USER(id),
APIUrlGenerator(API_ROUTES.UNVERIFY_USER(id)),
{}
);
return response.data;

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { User, Edit, UserCheck, UserX, Trash2, ArrowLeft, Phone, Mail, CreditCard, Calendar } from 'lucide-react';
import { useUser, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks';
import { englishToPersian } from '../../../utils/numberUtils';
import { PageContainer } from '../../../components/ui/Typography';
import { Button } from '../../../components/ui/Button';
import { Modal } from '../../../components/ui/Modal';
@ -110,7 +111,7 @@ const UserAdminDetailPage: React.FC = () => {
</Button>
<Button
variant={user.verified ? "secondary" : "primary"}
onClick={handleVerifyToggle}
onClick={() => setDeleteModal(true)}
loading={verifyUserMutation.isPending || unverifyUserMutation.isPending}
className="flex items-center gap-2"
data-testid="verify-toggle-button"
@ -195,7 +196,7 @@ const UserAdminDetailPage: React.FC = () => {
<Phone className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">شماره تلفن</p>
<p className="text-gray-900 dark:text-gray-100">{user.phone_number}</p>
<p className="text-gray-900 dark:text-gray-100" dir="ltr" style={{ direction: 'ltr' }}>{englishToPersian(user.phone_number)}</p>
</div>
</div>
@ -264,57 +265,18 @@ const UserAdminDetailPage: React.FC = () => {
</div>
</div>
{/* Actions Section */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
عملیات سریع
</h3>
<div className="flex flex-wrap gap-3">
<Button
onClick={handleEdit}
className="flex items-center gap-2"
data-testid="quick-edit-button"
>
<Edit className="h-4 w-4" />
ویرایش اطلاعات
</Button>
<Button
variant={user.verified ? "secondary" : "primary"}
onClick={handleVerifyToggle}
loading={verifyUserMutation.isPending || unverifyUserMutation.isPending}
className="flex items-center gap-2"
data-testid="quick-verify-button"
>
{user.verified ? (
<>
<UserX className="h-4 w-4" />
لغو تأیید حساب
</>
) : (
<>
<UserCheck className="h-4 w-4" />
تأیید حساب کاربر
</>
)}
</Button>
</div>
</div>
{/* Delete Confirmation Modal */}
<Modal
isOpen={deleteModal}
onClose={() => setDeleteModal(false)}
title="حذف کاربر"
title={user.verified ? 'لغو تأیید کاربر' : 'تأیید کاربر'}
>
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-400">
آیا از حذف کاربر "{user.first_name} {user.last_name}" اطمینان دارید؟
{user.verified ? `آیا از لغو تأیید «${user.first_name} ${user.last_name}» مطمئن هستید؟` : `آیا از تأیید «${user.first_name} ${user.last_name}» مطمئن هستید؟`}
</p>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-sm text-red-600 dark:text-red-400">
<strong>هشدار:</strong> این عمل غیرقابل بازگشت است و تمام اطلاعات کاربر به طور کامل حذف خواهد شد.
</p>
</div>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
@ -324,12 +286,14 @@ const UserAdminDetailPage: React.FC = () => {
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={deleteUserMutation.isPending}
data-testid="confirm-delete-button"
variant={user.verified ? 'danger' : 'primary'}
onClick={() => {
handleVerifyToggle();
setDeleteModal(false);
}}
loading={verifyUserMutation.isPending || unverifyUserMutation.isPending}
>
حذف کاربر
تایید
</Button>
</div>
</div>

View File

@ -1,7 +1,7 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Plus, Search, Filter, UserCheck, UserX, Edit, Trash2, Eye, User as UserIcon } from 'lucide-react';
import { useUsers, useUserStats, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks';
import { useSearchUsers, useUserStats, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks';
import { User, UserFilters } from '../core/_models';
import { PageContainer } from '../../../components/ui/Typography';
import { Button } from '../../../components/ui/Button';
@ -11,6 +11,7 @@ import { Pagination } from '../../../components/ui/Pagination';
import { StatsCard } from '../../../components/dashboard/StatsCard';
import { Table } from '../../../components/ui/Table';
import { TableColumn } from '../../../types';
import { englishToPersian, persianToEnglish } from '../../../utils/numberUtils';
const UsersAdminListPage: React.FC = () => {
const navigate = useNavigate();
@ -26,18 +27,23 @@ const UsersAdminListPage: React.FC = () => {
isOpen: false,
user: null
});
const [verifyModal, setVerifyModal] = useState<{ isOpen: boolean; user: User | null; action: 'verify' | 'unverify' }>({
isOpen: false,
user: null,
action: 'verify'
});
// Hooks
const { data: users = [], isLoading, error } = useUsers(filters);
const { data: searchResult, isLoading, error } = useSearchUsers(filters);
const users: any[] = searchResult?.users || [];
const totalMatched: number = searchResult?.total || 0;
const { data: stats } = useUserStats();
const verifyUserMutation = useVerifyUser();
const unverifyUserMutation = useUnverifyUser();
const deleteUserMutation = useDeleteUser();
// Handlers
const handleCreate = () => {
navigate('/users-admin/create');
};
// Creation disabled per request
const handleEdit = (user: User) => {
navigate(`/users-admin/${user.id}/edit`);
@ -54,7 +60,7 @@ const UsersAdminListPage: React.FC = () => {
};
if (searchTerm.trim()) {
newFilters.search_text = searchTerm.trim();
newFilters.search_text = persianToEnglish(searchTerm.trim());
} else {
delete newFilters.search_text;
}
@ -78,10 +84,20 @@ const UsersAdminListPage: React.FC = () => {
};
const handleVerifyToggle = (user: User) => {
if (user.verified) {
unverifyUserMutation.mutate(user.id.toString());
setVerifyModal({ isOpen: true, user, action: user.verified ? 'unverify' : 'verify' });
};
const handleVerifyConfirm = () => {
if (!verifyModal.user) return;
const user = verifyModal.user;
if (verifyModal.action === 'unverify') {
unverifyUserMutation.mutate(user.id.toString(), {
onSettled: () => setVerifyModal({ isOpen: false, user: null, action: 'verify' })
});
} else {
verifyUserMutation.mutate(user.id.toString());
verifyUserMutation.mutate(user.id.toString(), {
onSettled: () => setVerifyModal({ isOpen: false, user: null, action: 'verify' })
});
}
};
@ -133,7 +149,15 @@ const UsersAdminListPage: React.FC = () => {
</div>
)
},
{ key: 'phone_number', label: 'شماره تلفن', align: 'left' },
{
key: 'phone_number',
label: 'شماره تلفن',
align: 'left',
render: (v: string) => {
const display = v ? englishToPersian(v) : '-';
return <span dir="ltr" style={{ direction: 'ltr' }}>{display}</span>;
}
},
{ key: 'email', label: 'ایمیل', align: 'left', render: (v: string) => v || '-' },
{
key: 'verified',
@ -216,14 +240,7 @@ const UsersAdminListPage: React.FC = () => {
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">مشاهده و مدیریت کاربران سیستم</p>
</div>
<Button
onClick={handleCreate}
className="flex items-center gap-2"
data-testid="create-user-button"
>
<Plus className="h-4 w-4" />
کاربر جدید
</Button>
</div>
{/* Stats Cards */}
@ -309,9 +326,9 @@ const UsersAdminListPage: React.FC = () => {
هیچ کاربری یافت نشد
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
برای شروع یک کاربر ایجاد کنید
نتیجهای یافت نشد
</p>
<Button onClick={handleCreate}>ایجاد کاربر جدید</Button>
</div>
) : (
<Table columns={columns} data={users as any[]} />
@ -322,10 +339,10 @@ const UsersAdminListPage: React.FC = () => {
{users.length > 0 && (
<Pagination
currentPage={Math.floor((filters.offset || 0) / (filters.limit || 20)) + 1}
totalPages={Math.ceil((stats?.total_users || 0) / (filters.limit || 20))}
totalPages={Math.ceil((totalMatched || 0) / (filters.limit || 20))}
onPageChange={(page) => handlePageChange((page - 1) * (filters.limit || 20))}
itemsPerPage={filters.limit || 20}
totalItems={stats?.total_users || users.length}
totalItems={totalMatched || users.length}
/>
)}
@ -359,6 +376,36 @@ const UsersAdminListPage: React.FC = () => {
</div>
</div>
</Modal>
{/* Verify/Unverify Confirmation Modal */}
<Modal
isOpen={verifyModal.isOpen}
onClose={() => setVerifyModal({ isOpen: false, user: null, action: 'verify' })}
title={verifyModal.action === 'unverify' ? 'لغو تأیید کاربر' : 'تأیید کاربر'}
>
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-400">
{verifyModal.action === 'unverify'
? `آیا از لغو تأیید «${verifyModal.user?.first_name} ${verifyModal.user?.last_name}» مطمئن هستید؟`
: `آیا از تأیید «${verifyModal.user?.first_name} ${verifyModal.user?.last_name}» مطمئن هستید؟`}
</p>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setVerifyModal({ isOpen: false, user: null, action: 'verify' })}
>
انصراف
</Button>
<Button
variant={verifyModal.action === 'unverify' ? 'danger' : 'primary'}
onClick={handleVerifyConfirm}
loading={verifyUserMutation.isPending || unverifyUserMutation.isPending}
>
تایید
</Button>
</div>
</div>
</Modal>
</div>
</PageContainer>
);

View File

@ -1,5 +1,9 @@
import axios, { AxiosRequestConfig } from "axios";
import { API_GATE_WAY, REQUEST_TIMEOUT } from "@/constant/routes";
import {
API_GATE_WAY,
ADMIN_API_PREFIX,
REQUEST_TIMEOUT,
} from "@/constant/routes";
import { getAuth } from "@/pages/auth";
import { pageSize } from "@/constant/generalVariables";
import Cookies from "js-cookie";
@ -144,11 +148,13 @@ export const calculateTotalPages = (totalItemsCount: number | undefined) => {
export function APIUrlGenerator(
route: string,
qry?: Record<string, string | number | null>,
baseUrl?: string
baseUrl?: string,
useAdminPrefix: boolean = true
): string {
const query = qry || {};
const queryKeys = Object.keys(query);
let apiUrl = `${baseUrl || API_GATE_WAY}/${route}`;
const prefix = useAdminPrefix ? `${ADMIN_API_PREFIX}/` : "";
let apiUrl = `${baseUrl || API_GATE_WAY}/${prefix}${route}`;
queryKeys.forEach((item, index) => {
if (index === 0) {
apiUrl += "?";

View File

@ -102,3 +102,14 @@ export const parseFormattedNumber = (value: any): number | undefined => {
const num = Number(cleaned);
return isNaN(num) ? undefined : num;
};
export const englishToPersian = (str: string | number): string => {
if (str === null || str === undefined) return "";
const en = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
const fa = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
let s = String(str);
for (let i = 0; i < 10; i++) {
s = s.replace(new RegExp(en[i], "g"), fa[i]);
}
return s;
};