refactor: کامپوننت‌سازی پروژه و حذف کدهای تکراری

- ایجاد کامپوننت‌های قابل استفاده مجدد: PageHeader, FiltersSection, TableSkeleton, EmptyState, DeleteConfirmModal, ActionButtons, StatusBadge
- ایجاد کامپوننت‌های فرم: FormSection, FormActions
- ایجاد utility functions: formatters.ts, statusUtils.ts
- Refactor صفحات لیست: ProductsListPage, CategoriesListPage, DiscountCodesListPage, UsersAdminListPage, RolesListPage, OrdersListPage
- Refactor صفحات فرم: ProductFormPage
- بهبود maintainability و کاهش code duplication
This commit is contained in:
hosseintaromi 2026-01-08 17:46:40 +03:30
parent bfd1ea72a5
commit 8538d4282e
21 changed files with 1346 additions and 919 deletions

View File

@ -88,16 +88,12 @@ const ShipmentsByMethodReportPage = lazy(() => import('./pages/reports/shipment-
// Product Comments Page
const ProductCommentsListPage = lazy(() => import('./pages/products/comments/comments-list/ProductCommentsListPage'));
const ProtectedRoute = ({ children }: { children: any }) => {
const ProtectedRoute = ({ children }: { children: React.ReactElement }) => {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<Layout>
<div className="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
</Layout>
<Layout />
);
}
@ -208,11 +204,9 @@ const App = () => {
<AuthProvider>
<Router>
<Suspense fallback={
<Layout>
<div className="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
</Layout>
}>
<AppRoutes />
</Suspense>

View File

@ -0,0 +1,91 @@
import React from 'react';
import { Eye, Edit3, Trash2, LucideIcon } from 'lucide-react';
interface ActionButtonsProps {
onView?: () => void;
onEdit?: () => void;
onDelete?: () => void;
viewTitle?: string;
editTitle?: string;
deleteTitle?: string;
className?: string;
size?: 'sm' | 'md' | 'lg';
showLabels?: boolean;
}
const getSizeClasses = (size: 'sm' | 'md' | 'lg') => {
switch (size) {
case 'sm':
return 'h-3 w-3';
case 'md':
return 'h-4 w-4';
case 'lg':
return 'h-5 w-5';
default:
return 'h-4 w-4';
}
};
const getTextSizeClasses = (size: 'sm' | 'md' | 'lg') => {
switch (size) {
case 'sm':
return 'text-xs';
case 'md':
return 'text-xs';
case 'lg':
return 'text-sm';
default:
return 'text-xs';
}
};
export const ActionButtons: React.FC<ActionButtonsProps> = ({
onView,
onEdit,
onDelete,
viewTitle = 'مشاهده',
editTitle = 'ویرایش',
deleteTitle = 'حذف',
className = '',
size = 'md',
showLabels = false,
}) => {
const iconSize = getSizeClasses(size);
const textSize = getTextSizeClasses(size);
return (
<div className={`flex items-center gap-2 ${className}`}>
{onView && (
<button
onClick={onView}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 flex items-center gap-1"
title={viewTitle}
>
<Eye className={iconSize} />
{showLabels && <span className={textSize}>{viewTitle}</span>}
</button>
)}
{onEdit && (
<button
onClick={onEdit}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 flex items-center gap-1"
title={editTitle}
>
<Edit3 className={iconSize} />
{showLabels && <span className={textSize}>{editTitle}</span>}
</button>
)}
{onDelete && (
<button
onClick={onDelete}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-1"
title={deleteTitle}
>
<Trash2 className={iconSize} />
{showLabels && <span className={textSize}>{deleteTitle}</span>}
</button>
)}
</div>
);
};

View File

@ -0,0 +1,61 @@
import React from 'react';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
interface DeleteConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message?: string;
warningMessage?: string;
isLoading?: boolean;
itemName?: string;
}
export const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
isOpen,
onClose,
onConfirm,
title = 'حذف',
message,
warningMessage,
isLoading = false,
itemName,
}) => {
const defaultMessage = itemName
? `آیا از حذف "${itemName}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`
: 'آیا از حذف این مورد اطمینان دارید؟ این عمل قابل بازگشت نیست.';
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-400">
{message || defaultMessage}
</p>
{warningMessage && (
<p className="text-sm text-red-600 dark:text-red-400">
{warningMessage}
</p>
)}
<div className="flex justify-end space-x-2 space-x-reverse">
<Button
variant="secondary"
onClick={onClose}
disabled={isLoading}
>
انصراف
</Button>
<Button
variant="danger"
onClick={onConfirm}
loading={isLoading}
>
حذف
</Button>
</div>
</div>
</Modal>
);
};

View File

@ -0,0 +1,46 @@
import React, { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
import { Button } from '../ui/Button';
interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
actionLabel?: ReactNode;
onAction?: () => void;
className?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({
icon: Icon,
title,
description,
actionLabel,
onAction,
className = '',
}) => {
return (
<div className={`text-center py-12 ${className}`}>
{Icon && (
<Icon className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
)}
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{title}
</h3>
{description && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
{actionLabel && onAction && (
<div className="mt-6">
<Button onClick={onAction} className="flex items-center gap-2 mx-auto">
{actionLabel}
</Button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,42 @@
import React, { ReactNode } from 'react';
interface FiltersSectionProps {
children: ReactNode;
isLoading?: boolean;
columns?: 1 | 2 | 3 | 4;
className?: string;
}
export const FiltersSection: React.FC<FiltersSectionProps> = ({
children,
isLoading = false,
columns = 4,
className = '',
}) => {
const gridCols = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-4',
};
return (
<div className={`bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 ${className}`}>
{isLoading ? (
<div className={`grid ${gridCols[columns]} gap-4 animate-pulse`}>
{[...Array(columns)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
</div>
))}
</div>
) : (
<div className={`grid ${gridCols[columns]} gap-4`}>
{children}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,80 @@
import React from 'react';
interface TableSkeletonProps {
columns?: number;
rows?: number;
showMobileCards?: boolean;
className?: string;
}
export const TableSkeleton: React.FC<TableSkeletonProps> = ({
columns = 5,
rows = 5,
showMobileCards = true,
className = '',
}) => {
return (
<div className={`bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden ${className}`}>
{/* Desktop Table Skeleton */}
<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>
{[...Array(columns)].map((_, i) => (
<th
key={i}
className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-24 animate-pulse"></div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(rows)].map((_, rowIndex) => (
<tr key={rowIndex}>
{[...Array(columns)].map((_, colIndex) => (
<td key={colIndex} className="px-6 py-4 whitespace-nowrap">
{colIndex === columns - 1 ? (
<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>
) : (
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse w-32"></div>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile Cards Skeleton */}
{showMobileCards && (
<div className="md:hidden p-4 space-y-4">
{[...Array(Math.min(rows, 3))].map((_, index) => (
<div
key={index}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse"
>
<div className="space-y-3">
<div className="h-5 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-full"></div>
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/3"></div>
<div className="flex gap-2 pt-2">
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Button } from '../ui/Button';
interface FormActionsProps {
onCancel?: () => void;
cancelLabel?: string;
submitLabel?: string;
isLoading?: boolean;
isDisabled?: boolean;
className?: string;
}
export const FormActions: React.FC<FormActionsProps> = ({
onCancel,
cancelLabel = 'انصراف',
submitLabel = 'ذخیره',
isLoading = false,
isDisabled = false,
className = '',
}) => {
return (
<div className={`flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600 ${className}`}>
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={isLoading}
>
{cancelLabel}
</Button>
)}
<Button
type="submit"
loading={isLoading}
disabled={isDisabled || isLoading}
>
{submitLabel}
</Button>
</div>
);
};

View File

@ -0,0 +1,26 @@
import React, { ReactNode } from 'react';
import { SectionTitle } from '../ui/Typography';
interface FormSectionProps {
title: string;
children: ReactNode;
className?: string;
titleClassName?: string;
}
export const FormSection: React.FC<FormSectionProps> = ({
title,
children,
className = '',
titleClassName = '',
}) => {
return (
<div className={className}>
<SectionTitle className={`mb-4 ${titleClassName}`}>
{title}
</SectionTitle>
{children}
</div>
);
};

View File

@ -0,0 +1,40 @@
import React, { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
interface PageHeaderProps {
title: string;
subtitle?: string;
icon?: LucideIcon;
actions?: ReactNode;
className?: string;
}
export const PageHeader: React.FC<PageHeaderProps> = ({
title,
subtitle,
icon: Icon,
actions,
className = '',
}) => {
return (
<div className={`flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 ${className}`}>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
{Icon && <Icon className="h-6 w-6" />}
{title}
</h1>
{subtitle && (
<p className="text-gray-600 dark:text-gray-400 mt-1">
{subtitle}
</p>
)}
</div>
{actions && (
<div className="flex-shrink-0">
{actions}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,215 @@
import React from 'react';
export type StatusType = 'product' | 'order' | 'user' | 'discount' | 'comment' | 'generic';
export type ProductStatus = 'active' | 'inactive' | 'draft';
export type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
export type UserStatus = 'verified' | 'unverified' | boolean;
export type DiscountStatus = 'active' | 'inactive';
export type CommentStatus = 'approved' | 'rejected' | 'pending';
export type StatusValue = ProductStatus | OrderStatus | UserStatus | DiscountStatus | CommentStatus | string;
interface StatusBadgeProps {
status: StatusValue;
type?: StatusType;
className?: string;
size?: 'sm' | 'md' | 'lg';
}
const getStatusConfig = (status: StatusValue, type?: StatusType) => {
// Handle boolean status (for verified/unverified)
if (typeof status === 'boolean') {
return {
color: status
? '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',
text: status ? 'تأیید شده' : 'تأیید نشده',
};
}
const statusStr = String(status).toLowerCase();
switch (type) {
case 'product':
switch (statusStr) {
case 'active':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'فعال',
};
case 'inactive':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
text: 'غیرفعال',
};
case 'draft':
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: 'پیش‌نویس',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
case 'order':
switch (statusStr) {
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
text: 'در انتظار',
};
case 'processing':
return {
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
text: 'در حال پردازش',
};
case 'shipped':
return {
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
text: 'ارسال شده',
};
case 'delivered':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'تحویل شده',
};
case 'cancelled':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
text: 'لغو شده',
};
case 'refunded':
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: 'مرجوع شده',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
case 'user':
switch (statusStr) {
case 'verified':
case 'true':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'تأیید شده',
};
case 'unverified':
case 'false':
return {
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
text: 'تأیید نشده',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
case 'discount':
switch (statusStr) {
case 'active':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'فعال',
};
case 'inactive':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
text: 'غیرفعال',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
case 'comment':
switch (statusStr) {
case 'approved':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'تأیید شده',
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
text: 'رد شده',
};
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
text: 'در انتظار',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
default:
// Generic status handling
switch (statusStr) {
case 'active':
case 'true':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'فعال',
};
case 'inactive':
case 'false':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
text: 'غیرفعال',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
}
};
const getSizeClasses = (size: 'sm' | 'md' | 'lg') => {
switch (size) {
case 'sm':
return 'px-2 py-0.5 text-xs';
case 'md':
return 'px-2.5 py-0.5 text-xs';
case 'lg':
return 'px-3 py-1 text-sm';
default:
return 'px-2.5 py-0.5 text-xs';
}
};
export const StatusBadge: React.FC<StatusBadgeProps> = ({
status,
type = 'generic',
className = '',
size = 'md',
}) => {
const config = getStatusConfig(status, type);
const sizeClasses = getSizeClasses(size);
return (
<span
className={`inline-flex items-center rounded-full font-medium ${config.color} ${sizeClasses} ${className}`}
>
{config.text}
</span>
);
};

View File

@ -3,58 +3,15 @@ import { useNavigate } from 'react-router-dom';
import { useCategories, useDeleteCategory } from '../core/_hooks';
import { Category } from '../core/_models';
import { Button } from "@/components/ui/Button";
import { Trash2, Edit3, Plus, FolderOpen, Folder } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { PageContainer, PageTitle, SectionSubtitle } from "../../../components/ui/Typography";
const CategoriesTableSkeleton = () => (
<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>
</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="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>
);
import { Plus, FolderOpen, Folder } from "lucide-react";
import { PageContainer } from "../../../components/ui/Typography";
import { PageHeader } from "@/components/layout/PageHeader";
import { FiltersSection } from "@/components/common/FiltersSection";
import { TableSkeleton } from "@/components/common/TableSkeleton";
import { EmptyState } from "@/components/common/EmptyState";
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
import { ActionButtons } from "@/components/common/ActionButtons";
import { formatDate } from "@/utils/formatters";
const CategoriesListPage = () => {
const navigate = useNavigate();
@ -98,18 +55,7 @@ const CategoriesListPage = () => {
);
}
return (
<PageContainer>
{/* Header */}
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<div className="flex items-center gap-2 mb-2">
<FolderOpen className="h-6 w-6" />
<PageTitle>مدیریت دستهبندیها</PageTitle>
</div>
<p className="text-gray-600 dark:text-gray-400">مدیریت دستهبندیهای محصولات</p>
</div>
const createButton = (
<button
onClick={handleCreate}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
@ -117,11 +63,18 @@ const CategoriesListPage = () => {
>
<Plus className="h-5 w-5" />
</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-2 gap-4">
return (
<PageContainer>
<PageHeader
title="مدیریت دسته‌بندی‌ها"
subtitle="مدیریت دسته‌بندی‌های محصولات"
icon={FolderOpen}
actions={createButton}
/>
<FiltersSection isLoading={isLoading} columns={2}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
@ -134,12 +87,25 @@ const CategoriesListPage = () => {
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>
</FiltersSection>
{/* Categories Table */}
{isLoading ? (
<CategoriesTableSkeleton />
<TableSkeleton columns={4} rows={5} />
) : (!categories || categories.length === 0) ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<EmptyState
icon={FolderOpen}
title="دسته‌بندی‌ای موجود نیست"
description="برای شروع، اولین دسته‌بندی محصولات خود را ایجاد کنید."
actionLabel={
<>
<Plus className="h-4 w-4" />
ایجاد دستهبندی جدید
</>
}
onAction={handleCreate}
/>
</div>
) : (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Desktop Table */}
@ -177,25 +143,13 @@ const CategoriesListPage = () => {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{new Date(category.created_at).toLocaleDateString('fa-IR')}
{formatDate(category.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(category.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={() => setDeleteCategoryId(category.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>
<ActionButtons
onEdit={() => handleEdit(category.id)}
onDelete={() => setDeleteCategoryId(category.id.toString())}
/>
</td>
</tr>
))}
@ -220,77 +174,28 @@ const CategoriesListPage = () => {
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
تاریخ ایجاد: {new Date(category.created_at).toLocaleDateString('fa-IR')}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(category.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={() => setDeleteCategoryId(category.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>
تاریخ ایجاد: {formatDate(category.created_at)}
</div>
<ActionButtons
onEdit={() => handleEdit(category.id)}
onDelete={() => setDeleteCategoryId(category.id.toString())}
showLabels={true}
size="sm"
/>
</div>
))}
</div>
{/* Empty State */}
{(!categories || categories.length === 0) && !isLoading && (
<div className="text-center py-12">
<FolderOpen 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
<DeleteConfirmModal
isOpen={!!deleteCategoryId}
onClose={() => setDeleteCategoryId(null)}
onConfirm={handleDeleteConfirm}
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={() => setDeleteCategoryId(null)}
disabled={isDeleting}
>
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={isDeleting}
>
حذف
</Button>
</div>
</div>
</Modal>
message="آیا از حذف این دسته‌بندی اطمینان دارید؟ این عمل قابل بازگشت نیست و ممکن است بر محصولاتی که در این دسته‌بندی قرار دارند تأثیر بگذارد."
isLoading={isDeleting}
/>
</PageContainer>
);
};

View File

@ -2,11 +2,17 @@ import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDiscountCodes, useDeleteDiscountCode } from '../core/_hooks';
import { DiscountCode } from '../core/_models';
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { Table } from "@/components/ui/Table";
import { TableColumn } from "@/types";
import { Percent, BadgePercent, Trash2, Edit3, Plus, Ticket } from 'lucide-react';
import { BadgePercent, Plus, Ticket } from 'lucide-react';
import { PageContainer } from "@/components/ui/Typography";
import { PageHeader } from "@/components/layout/PageHeader";
import { FiltersSection } from "@/components/common/FiltersSection";
import { EmptyState } from "@/components/common/EmptyState";
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
import { ActionButtons } from "@/components/common/ActionButtons";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { formatDate } from "@/utils/formatters";
const DiscountCodesListPage = () => {
const navigate = useNavigate();
@ -41,18 +47,14 @@ const DiscountCodesListPage = () => {
{
key: 'status',
label: 'وضعیت',
render: (val: string) => (
<span className={`px-2 py-1 rounded-full text-xs ${val === 'active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'}`}>
{val === 'active' ? 'فعال' : 'غیرفعال'}
</span>
)
render: (val: string) => <StatusBadge status={val} type="discount" />
},
{
key: 'period',
label: 'بازه زمانی',
render: (_val, row: any) => (
<span>
{row.valid_from ? new Date(row.valid_from).toLocaleDateString('fa-IR') : '-'} تا {row.valid_to ? new Date(row.valid_to).toLocaleDateString('fa-IR') : '-'}
{row.valid_from ? formatDate(row.valid_from) : '-'} تا {row.valid_to ? formatDate(row.valid_to) : '-'}
</span>
)
},
@ -60,22 +62,10 @@ const DiscountCodesListPage = () => {
key: 'actions',
label: 'عملیات',
render: (_val, row: any) => (
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(row.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={() => setDeleteId(row.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>
<ActionButtons
onEdit={() => handleEdit(row.id)}
onDelete={() => setDeleteId(row.id.toString())}
/>
)
}
], [navigate]);
@ -90,16 +80,7 @@ const DiscountCodesListPage = () => {
);
}
return (
<div className="p-6 space-y-6">
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<BadgePercent className="h-6 w-6" />
مدیریت کدهای تخفیف
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">ایجاد و مدیریت کدهای تخفیف</p>
</div>
const createButton = (
<button
onClick={handleCreate}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
@ -108,10 +89,19 @@ const DiscountCodesListPage = () => {
>
<Plus className="h-5 w-5" />
</button>
</div>
);
<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-3 gap-4">
return (
<PageContainer>
<div className="space-y-6">
<PageHeader
title="مدیریت کدهای تخفیف"
subtitle="ایجاد و مدیریت کدهای تخفیف"
icon={BadgePercent}
actions={createButton}
/>
<FiltersSection isLoading={isLoading} columns={3}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">کد</label>
<input
@ -122,37 +112,39 @@ const DiscountCodesListPage = () => {
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>
</FiltersSection>
{isLoading ? (
<Table columns={columns} data={Array.isArray(discountCodes) ? (discountCodes as any[]) : []} loading={true} />
) : !discountCodes || discountCodes.length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="text-center py-12">
<Ticket className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">هیچ کد تخفیفی یافت نشد</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">برای شروع یک کد تخفیف ایجاد کنید</p>
<Button onClick={handleCreate} className="flex items-center gap-2">
<EmptyState
icon={Ticket}
title="هیچ کد تخفیفی یافت نشد"
description="برای شروع یک کد تخفیف ایجاد کنید"
actionLabel={
<>
<Plus className="h-4 w-4 ml-2" />
ایجاد کد تخفیف
</Button>
</div>
</>
}
onAction={handleCreate}
/>
</div>
) : (
<Table columns={columns} data={discountCodes as any[]} />
)}
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(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={() => setDeleteId(null)} disabled={isDeleting}>انصراف</Button>
<Button variant="danger" onClick={handleDeleteConfirm} loading={isDeleting}>حذف</Button>
</div>
</div>
</Modal>
<DeleteConfirmModal
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDeleteConfirm}
title="حذف کد تخفیف"
message="آیا از حذف این کد تخفیف اطمینان دارید؟ این عمل قابل بازگشت نیست."
isLoading={isDeleting}
/>
</div>
</PageContainer>
);
};

View File

@ -6,7 +6,7 @@ import { OrderFilters, OrderStatus } from '../core/_models';
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { Pagination } from "@/components/ui/Pagination";
import { PageContainer, PageTitle } from "@/components/ui/Typography";
import { PageContainer } from "@/components/ui/Typography";
import { Table } from "@/components/ui/Table";
import { TableColumn } from "@/types";
import { StatsCard } from '@/components/dashboard/StatsCard';
@ -20,46 +20,16 @@ import {
Clock,
Search,
Filter,
Eye,
Edit3,
TrendingUp
} from 'lucide-react';
import { PageHeader } from '@/components/layout/PageHeader';
import { FiltersSection } from '@/components/common/FiltersSection';
import { EmptyState } from '@/components/common/EmptyState';
import { ActionButtons } from '@/components/common/ActionButtons';
import { StatusBadge } from '@/components/ui/StatusBadge';
import { formatCurrency, formatDate } from '@/utils/formatters';
const getStatusColor = (status: OrderStatus) => {
const colors = {
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
processing: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
shipped: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
delivered: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
cancelled: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
refunded: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
};
return colors[status] || colors.pending;
};
const getStatusText = (status: OrderStatus) => {
const text = {
pending: 'در انتظار',
processing: 'در حال پردازش',
shipped: 'ارسال شده',
delivered: 'تحویل شده',
cancelled: 'لغو شده',
refunded: 'مرجوع شده',
};
return text[status] || status;
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('fa-IR').format(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR');
};
const ListSkeleton = () => (
<Table columns={[]} data={[]} loading={true} />
);
const getDefaultFilters = (): OrderFilters => ({
page: 1,
@ -219,7 +189,7 @@ const OrdersListPage = () => {
)
},
{ 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: 'status', label: 'وضعیت', align: 'right', render: (v: OrderStatus) => <StatusBadge status={v} type="order" /> },
{ key: 'created_at', label: 'تاریخ', sortable: true, align: 'right', render: (v: string) => formatDate(v) },
{
key: 'actions',
@ -227,13 +197,9 @@ const OrdersListPage = () => {
align: 'right',
render: (_val, row: any) => (
<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"
title="مشاهده جزئیات"
>
<Eye className="h-4 w-4" />
</button>
<ActionButtons
onView={() => handleViewOrder(row.id)}
/>
<button
onClick={() => handleUpdateStatus(row.id, row.status)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
@ -280,17 +246,11 @@ const OrdersListPage = () => {
return (
<PageContainer>
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<PageTitle className="flex items-center gap-2">
<ShoppingCart className="h-6 w-6" />
مدیریت سفارشات
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{ordersData?.total || 0} سفارش یافت شد
</p>
</div>
</div>
<PageHeader
title="مدیریت سفارشات"
subtitle={`${ordersData?.total || 0} سفارش یافت شد`}
icon={ShoppingCart}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 lg:gap-6">
{statsLoading ? (
@ -468,14 +428,14 @@ const OrdersListPage = () => {
{/* جدول سفارشات */}
{isLoading ? (
<ListSkeleton />
<Table columns={columns} data={[]} loading={true} />
) : !ordersData?.orders || ordersData.orders.length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="text-center py-12">
<ShoppingCart className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">هیچ سفارشی یافت نشد</h3>
<p className="text-gray-600 dark:text-gray-400">با تغییر فیلترها جستجو کنید</p>
</div>
<EmptyState
icon={ShoppingCart}
title="هیچ سفارشی یافت نشد"
description="با تغییر فیلترها جستجو کنید"
/>
</div>
) : (
<>

View File

@ -14,7 +14,9 @@ import { Input } from "@/components/ui/Input";
import { FileUploader } from "@/components/ui/FileUploader";
import { VariantManager } from "@/components/ui/VariantManager";
import { ArrowRight, X } from "lucide-react";
import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
import { FormSection } from '@/components/forms/FormSection';
import { FormActions } from '@/components/forms/FormActions';
import { createNumberTransform, createOptionalNumberTransform, convertPersianNumbersInObject } from '../../../utils/numberUtils';
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
import { toast } from "react-hot-toast";
@ -471,11 +473,7 @@ const ProductFormPage = () => {
{/* 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>
<FormSection title="اطلاعات پایه">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="md:col-span-2">
<Input
@ -536,13 +534,9 @@ const ProductFormPage = () => {
)}
</div>
</div>
</div>
</FormSection>
{/* Categories and Product Options */}
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
دستهبندی و گزینهها
</h3>
<FormSection title="دسته‌بندی و گزینه‌ها">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<MultiSelectAutocomplete
label="دسته‌بندی‌ها"
@ -582,13 +576,9 @@ const ProductFormPage = () => {
)}
</div>
</div>
</div>
</FormSection>
{/* Images */}
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
تصاویر محصول
</h3>
<FormSection title="تصاویر محصول">
<FileUploader
onUpload={handleFileUpload}
@ -641,12 +631,9 @@ const ProductFormPage = () => {
</div>
</div>
)}
</div>
</FormSection>
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
فایلهای Explorer
</h3>
<FormSection title="فایل‌های Explorer">
<FileUploader
onUpload={handleExplorerUpload}
onRemove={handleExplorerRemove}
@ -709,19 +696,16 @@ const ProductFormPage = () => {
</span>
</div>
)}
</div>
</FormSection>
{/* Variants Management */}
<div>
<FormSection title="مدیریت Variants">
<VariantManager
variants={watch('variants') || []}
onChange={(variants) => setValue('variants', variants, { shouldValidate: true, shouldDirty: true })}
productOptions={productOptionOptions}
variantAttributeName={watch('variant_attribute_name')}
/>
</div>
</FormSection>
{/* Preview */}
{formValues.name && (
@ -797,24 +781,13 @@ const ProductFormPage = () => {
</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 || isUploading || isExplorerUploading}
>
{isEdit ? 'به‌روزرسانی' : 'ایجاد محصول'}
</Button>
</div>
<FormActions
onCancel={handleBack}
cancelLabel="انصراف"
submitLabel={isEdit ? 'به‌روزرسانی' : 'ایجاد محصول'}
isLoading={isLoading}
isDisabled={!isValid || isLoading || isUploading || isExplorerUploading}
/>
</form>
</div>

View File

@ -5,64 +5,17 @@ import { useCategories } from '../../categories/core/_hooks';
import { Product } from '../core/_models';
import { Button } from "@/components/ui/Button";
import { PageContainer } from "@/components/ui/Typography";
import { Trash2, Edit3, Plus, Package, Eye, Image } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { Plus, Package, Image } from "lucide-react";
import { persianToEnglish } from '../../../utils/numberUtils';
import { Pagination } from "@/components/ui/Pagination";
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>
);
import { PageHeader } from "@/components/layout/PageHeader";
import { FiltersSection } from "@/components/common/FiltersSection";
import { TableSkeleton } from "@/components/common/TableSkeleton";
import { EmptyState } from "@/components/common/EmptyState";
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
import { ActionButtons } from "@/components/common/ActionButtons";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { formatPrice } from "@/utils/formatters";
const ProductsListPage = () => {
const navigate = useNavigate();
@ -122,29 +75,12 @@ const ProductsListPage = () => {
setFilters(prev => ({ ...prev, status: e.target.value, page: 1 }));
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
};
const getFirstImageUrl = (p: any): string | null => {
if (p.file_ids && p.file_ids.length > 0) return p.file_ids[0].url;
if (p.files && p.files.length > 0) return p.files[0].url;
return null;
};
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>;
}
};
const total = productsData?.total || 0;
const currentPage = productsData?.page || filters.page;
const perPage = productsData?.per_page || filters.limit;
@ -160,20 +96,7 @@ const ProductsListPage = () => {
);
}
return (
<PageContainer>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<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>
const createButton = (
<button
onClick={handleCreate}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
@ -181,28 +104,19 @@ const ProductsListPage = () => {
>
<Plus className="h-5 w-5" />
</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">
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 animate-pulse">
{[...Array(4)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
{i === 3 ? (
<div className="flex gap-2">
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg flex-1"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg flex-1"></div>
</div>
) : (
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
)}
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
return (
<PageContainer>
<div className="space-y-6">
<PageHeader
title="مدیریت محصولات"
subtitle="مدیریت محصولات، قیمت‌ها و موجودی"
icon={Package}
actions={createButton}
/>
<FiltersSection isLoading={isLoading} columns={4}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
@ -276,13 +190,25 @@ const ProductsListPage = () => {
/>
</div>
</div>
</div>
)}
</div>
</FiltersSection>
{/* Products Table */}
{isLoading ? (
<ProductsTableSkeleton />
<TableSkeleton columns={5} rows={5} />
) : products.length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<EmptyState
icon={Package}
title="محصولی موجود نیست"
description="برای شروع، اولین محصول خود را ایجاد کنید."
actionLabel={
<>
<Plus className="h-4 w-4" />
ایجاد محصول جدید
</>
}
onAction={handleCreate}
/>
</div>
) : (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Desktop Table */}
@ -345,32 +271,14 @@ const ProductsListPage = () => {
{product.category?.name || 'بدون دسته‌بندی'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(product.status || '')}
<StatusBadge status={product.status || ''} type="product" />
</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>
<ActionButtons
onView={() => handleView(product.id)}
onEdit={() => handleEdit(product.id)}
onDelete={() => setDeleteProductId(product.id.toString())}
/>
</td>
</tr>
))}
@ -405,7 +313,7 @@ const ProductsListPage = () => {
{formatPrice(product.price || 0)}
</p>
<div className="flex items-center gap-2 mt-1">
{getStatusBadge(product.status || '')}
<StatusBadge status={product.status || ''} type="product" size="sm" />
{product.category && (
<span className="text-xs text-gray-500">
{product.category.name}
@ -414,51 +322,16 @@ const ProductsListPage = () => {
</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>
<ActionButtons
onView={() => handleView(product.id)}
onEdit={() => handleEdit(product.id)}
onDelete={() => setDeleteProductId(product.id.toString())}
showLabels={true}
size="sm"
/>
</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>
)}
@ -471,34 +344,14 @@ const ProductsListPage = () => {
onPageChange={(page) => setFilters(prev => ({ ...prev, page }))}
/>
{/* Delete Confirmation Modal */}
<Modal
<DeleteConfirmModal
isOpen={!!deleteProductId}
onClose={() => setDeleteProductId(null)}
onConfirm={handleDeleteConfirm}
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>
message="آیا از حذف این محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و تمام اطلاعات مربوط به محصول از جمله نسخه‌ها و تصاویر حذف خواهد شد."
isLoading={isDeleting}
/>
</div>
</PageContainer>
);

View File

@ -59,7 +59,7 @@ const RoleDetailPage = () => {
if (!role) return <div>نقش یافت نشد</div>;
return (
<div className="p-6">
<PageContainer>
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">

View File

@ -3,79 +3,15 @@ import { useNavigate } from 'react-router-dom';
import { useRoles, useDeleteRole } from '../core/_hooks';
import { Role } from '@/types/auth';
import { Button } from "@/components/ui/Button";
import { Trash2, Edit3, Plus, UserCog, Shield, Eye, Settings } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { PageContainer, PageTitle, SectionSubtitle } from '../../../components/ui/Typography';
// Skeleton Loading Component
const RolesTableSkeleton = () => (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Desktop Table Skeleton */}
<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>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, index) => (
<tr key={index} className="animate-pulse">
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-32"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-48"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-20"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex gap-2">
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile Cards Skeleton */}
<div className="md:hidden p-4 space-y-4">
{[...Array(3)].map((_, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="space-y-3">
<div className="h-5 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-full"></div>
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/3"></div>
<div className="flex gap-2 pt-2">
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
</div>
</div>
</div>
))}
</div>
</div>
);
import { Plus, Shield, Eye, Settings } from "lucide-react";
import { PageContainer } from '../../../components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader';
import { FiltersSection } from '@/components/common/FiltersSection';
import { TableSkeleton } from '@/components/common/TableSkeleton';
import { EmptyState } from '@/components/common/EmptyState';
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
import { ActionButtons } from '@/components/common/ActionButtons';
import { formatDate } from '@/utils/formatters';
const RolesListPage = () => {
const navigate = useNavigate();
@ -135,18 +71,7 @@ const RolesListPage = () => {
);
}
return (
<PageContainer>
{/* Header */}
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<div className="flex items-center gap-2 mb-2">
<UserCog className="h-6 w-6" />
<PageTitle>مدیریت نقشها</PageTitle>
</div>
<p className="text-gray-600 dark:text-gray-400">مدیریت نقشها و دسترسیهای سیستم</p>
</div>
const createButton = (
<button
onClick={handleCreate}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
@ -154,11 +79,18 @@ const RolesListPage = () => {
>
<Plus className="h-5 w-5" />
</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-2 gap-4">
return (
<PageContainer>
<PageHeader
title="مدیریت نقش‌ها"
subtitle="مدیریت نقش‌ها و دسترسی‌های سیستم"
icon={Shield}
actions={createButton}
/>
<FiltersSection isLoading={isLoading} columns={2}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
@ -171,30 +103,26 @@ const RolesListPage = () => {
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>
</FiltersSection>
{/* Roles Table */}
{isLoading ? (
<RolesTableSkeleton />
<TableSkeleton columns={4} rows={5} />
) : (roles || []).length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="text-center py-12">
<UserCog className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
هیچ نقش یافت نشد
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{filters.search
<EmptyState
icon={Shield}
title="هیچ نقش یافت نشد"
description={filters.search
? "نتیجه‌ای برای جستجوی شما یافت نشد"
: "شما هنوز هیچ نقش ایجاد نکرده‌اید"
}
</p>
<Button onClick={handleCreate} className="flex items-center gap-2">
: "شما هنوز هیچ نقش ایجاد نکرده‌اید"}
actionLabel={
<>
<Plus className="h-4 w-4 ml-2" />
اولین نقش را ایجاد کنید
</Button>
</div>
</>
}
onAction={handleCreate}
/>
</div>
) : (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
@ -228,24 +156,15 @@ const RolesListPage = () => {
{role.description}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{new Date(role.created_at).toLocaleDateString('fa-IR')}
{formatDate(role.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => handleView(role.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(role.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>
<ActionButtons
onView={() => handleView(role.id)}
onEdit={() => handleEdit(role.id)}
onDelete={() => setDeleteRoleId(role.id.toString())}
/>
<button
onClick={() => handlePermissions(role.id)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
@ -253,13 +172,6 @@ const RolesListPage = () => {
>
<Settings className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteRoleId(role.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>
@ -284,23 +196,16 @@ const RolesListPage = () => {
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
تاریخ ایجاد: {new Date(role.created_at).toLocaleDateString('fa-IR')}
تاریخ ایجاد: {formatDate(role.created_at)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleView(role.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(role.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>
<ActionButtons
onView={() => handleView(role.id)}
onEdit={() => handleEdit(role.id)}
onDelete={() => setDeleteRoleId(role.id.toString())}
showLabels={true}
size="sm"
/>
<button
onClick={() => handlePermissions(role.id)}
className="flex items-center gap-1 px-2 py-1 text-xs text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
@ -308,13 +213,6 @@ const RolesListPage = () => {
<Settings className="h-3 w-3" />
دسترسیها
</button>
<button
onClick={() => setDeleteRoleId(role.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>
))}
@ -322,31 +220,14 @@ const RolesListPage = () => {
</div>
)}
{/* Delete Confirmation Modal */}
<Modal
<DeleteConfirmModal
isOpen={!!deleteRoleId}
onClose={cancelDelete}
onConfirm={handleDeleteConfirm}
title="تأیید حذف"
size="sm"
>
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-300">
آیا از حذف این نقش اطمینان دارید؟ این عمل قابل بازگشت نیست.
</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={cancelDelete}>
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={isDeleting}
>
حذف
</Button>
</div>
</div>
</Modal>
message="آیا از حذف این نقش اطمینان دارید؟ این عمل قابل بازگشت نیست."
isLoading={isDeleting}
/>
</PageContainer>
);
};

View File

@ -5,6 +5,7 @@ import { ShippingOpenHour } from '../core/_models';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TagInput } from '@/components/ui/TagInput';
import { PageContainer } from '@/components/ui/Typography';
import { Truck } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';

View File

@ -1,6 +1,6 @@
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 { Users, Plus, Search, Filter, UserCheck, UserX, Eye, Edit, Trash2, User as UserIcon } from 'lucide-react';
import { useSearchUsers, useUserStats, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks';
import { User, UserFilters } from '../core/_models';
import { PageContainer } from '../../../components/ui/Typography';
@ -12,6 +12,10 @@ import { StatsCard } from '../../../components/dashboard/StatsCard';
import { Table } from '../../../components/ui/Table';
import { TableColumn } from '../../../types';
import { englishToPersian, persianToEnglish } from '../../../utils/numberUtils';
import { PageHeader } from '@/components/layout/PageHeader';
import { FiltersSection } from '@/components/common/FiltersSection';
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
import { StatusBadge } from '@/components/ui/StatusBadge';
const UsersAdminListPage: React.FC = () => {
const navigate = useNavigate();
@ -163,14 +167,7 @@ const UsersAdminListPage: React.FC = () => {
key: 'verified',
label: 'وضعیت',
align: 'center',
render: (v: boolean) => (
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${v
? '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'
}`}>
{v ? 'تأیید شده' : 'تأیید نشده'}
</span>
)
render: (v: boolean) => <StatusBadge status={v} type="user" />
},
{
key: 'actions',
@ -231,17 +228,11 @@ const UsersAdminListPage: React.FC = () => {
return (
<PageContainer>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Users className="h-6 w-6" />
مدیریت کاربران
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">مشاهده و مدیریت کاربران سیستم</p>
</div>
</div>
<PageHeader
title="مدیریت کاربران"
subtitle="مشاهده و مدیریت کاربران سیستم"
icon={Users}
/>
{/* Stats Cards */}
{stats && (
@ -273,9 +264,7 @@ const UsersAdminListPage: React.FC = () => {
</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-3 gap-4">
<FiltersSection isLoading={false} columns={3}>
<Input
placeholder="جستجو بر اساس نام، شماره تلفن یا ایمیل..."
value={searchTerm}
@ -312,8 +301,7 @@ const UsersAdminListPage: React.FC = () => {
پاک کردن فیلترها
</Button>
</div>
</div>
</div>
</FiltersSection>
{/* Users Table */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
@ -346,36 +334,15 @@ const UsersAdminListPage: React.FC = () => {
/>
)}
{/* Delete Confirmation Modal */}
<Modal
<DeleteConfirmModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, user: null })}
onConfirm={handleDeleteConfirm}
title="حذف کاربر"
>
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-400">
آیا از حذف کاربر "{deleteModal.user?.first_name} {deleteModal.user?.last_name}" اطمینان دارید؟
</p>
<p className="text-sm text-red-600 dark:text-red-400">
این عمل غیرقابل بازگشت است.
</p>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setDeleteModal({ isOpen: false, user: null })}
>
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={deleteUserMutation.isPending}
>
حذف
</Button>
</div>
</div>
</Modal>
message={`آیا از حذف کاربر "${deleteModal.user?.first_name} ${deleteModal.user?.last_name}" اطمینان دارید؟`}
warningMessage="این عمل غیرقابل بازگشت است."
isLoading={deleteUserMutation.isPending}
/>
{/* Verify/Unverify Confirmation Modal */}
<Modal

72
src/utils/formatters.ts Normal file
View File

@ -0,0 +1,72 @@
/**
* Utility functions for formatting data
*/
/**
* Format price with Persian number formatting and تومان suffix
*/
export const formatPrice = (price: number): string => {
if (price === null || price === undefined || isNaN(price)) {
return '0 تومان';
}
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
};
/**
* Format currency amount with Persian number formatting and تومان suffix
*/
export const formatCurrency = (amount: number): string => {
if (amount === null || amount === undefined || isNaN(amount)) {
return '0 تومان';
}
return new Intl.NumberFormat('fa-IR').format(amount) + ' تومان';
};
/**
* Format date string to Persian locale
*/
export const formatDate = (dateString: string | Date): string => {
if (!dateString) return '-';
try {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
if (isNaN(date.getTime())) return '-';
return date.toLocaleDateString('fa-IR');
} catch {
return '-';
}
};
/**
* Format date and time string to Persian locale
*/
export const formatDateTime = (dateString: string | Date): string => {
if (!dateString) return '-';
try {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
if (isNaN(date.getTime())) return '-';
return date.toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '-';
}
};
/**
* Format number with thousands separator (Persian)
*/
export const formatNumber = (num: number): string => {
if (num === null || num === undefined || isNaN(num)) {
return '0';
}
return new Intl.NumberFormat('fa-IR').format(num);
};

185
src/utils/statusUtils.ts Normal file
View File

@ -0,0 +1,185 @@
import React from 'react';
import { StatusBadge as StatusBadgeComponent, StatusType, StatusValue } from '../components/ui/StatusBadge';
/**
* Get status badge color classes
*/
export const getStatusColor = (status: string, type?: StatusType): string => {
const statusStr = String(status).toLowerCase();
switch (type) {
case 'order':
switch (statusStr) {
case 'pending':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
case 'processing':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
case 'shipped':
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
case 'delivered':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'cancelled':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
case 'refunded':
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
case 'product':
switch (statusStr) {
case 'active':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'inactive':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
case 'draft':
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
case 'user':
switch (statusStr) {
case 'verified':
case 'true':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'unverified':
case 'false':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
case 'discount':
switch (statusStr) {
case 'active':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'inactive':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
case 'comment':
switch (statusStr) {
case 'approved':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'rejected':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
default:
switch (statusStr) {
case 'active':
case 'true':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'inactive':
case 'false':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
}
};
/**
* Get status text in Persian
*/
export const getStatusText = (status: string, type?: StatusType): string => {
const statusStr = String(status).toLowerCase();
switch (type) {
case 'order':
switch (statusStr) {
case 'pending':
return 'در انتظار';
case 'processing':
return 'در حال پردازش';
case 'shipped':
return 'ارسال شده';
case 'delivered':
return 'تحویل شده';
case 'cancelled':
return 'لغو شده';
case 'refunded':
return 'مرجوع شده';
default:
return statusStr;
}
case 'product':
switch (statusStr) {
case 'active':
return 'فعال';
case 'inactive':
return 'غیرفعال';
case 'draft':
return 'پیش‌نویس';
default:
return statusStr;
}
case 'user':
switch (statusStr) {
case 'verified':
case 'true':
return 'تأیید شده';
case 'unverified':
case 'false':
return 'تأیید نشده';
default:
return statusStr;
}
case 'discount':
switch (statusStr) {
case 'active':
return 'فعال';
case 'inactive':
return 'غیرفعال';
default:
return statusStr;
}
case 'comment':
switch (statusStr) {
case 'approved':
return 'تأیید شده';
case 'rejected':
return 'رد شده';
case 'pending':
return 'در انتظار';
default:
return statusStr;
}
default:
switch (statusStr) {
case 'active':
case 'true':
return 'فعال';
case 'inactive':
case 'false':
return 'غیرفعال';
default:
return statusStr;
}
}
};
/**
* Get status badge React component
*/
export const getStatusBadge = (
status: StatusValue,
type?: StatusType,
className?: string,
size?: 'sm' | 'md' | 'lg'
): React.ReactElement => {
return React.createElement(StatusBadgeComponent, { status, type, className, size });
};