Refactor: Consolidate duplicate code patterns across 20+ pages

- Replace local ToggleSwitch components with shared component (3 pages)
- Consolidate formatDate/formatCurrency functions to formatters.ts (11 pages)
- Replace direct toLocaleDateString calls with formatDate utility (5 pages)
- Standardize PageHeader usage across 8 pages
- Extract ReportSkeleton component for Reports pages (4 pages)

All changes maintain existing functionality and Persian locale formatting.
This commit is contained in:
hosseintaromi 2026-01-08 18:08:25 +03:30
parent 6dd2429920
commit c46fd2ba0e
30 changed files with 499 additions and 784 deletions

View File

@ -204,9 +204,9 @@ const App = () => {
<AuthProvider>
<Router>
<Suspense fallback={
<div className="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
<div className="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
}>
<AppRoutes />
</Suspense>

View File

@ -0,0 +1,126 @@
import React from 'react';
interface ReportSkeletonProps {
summaryCardCount?: number;
tableColumnCount?: number;
tableRowCount?: number;
showMethodSummaries?: boolean;
showChart?: boolean;
showPaymentTypeCards?: boolean;
}
export const ReportSkeleton: React.FC<ReportSkeletonProps> = ({
summaryCardCount = 4,
tableColumnCount = 7,
tableRowCount = 5,
showMethodSummaries = false,
showChart = false,
showPaymentTypeCards = false,
}) => {
return (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[...Array(summaryCardCount)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
</div>
</div>
))}
</div>
{/* Method Summaries Skeleton */}
{showMethodSummaries && (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="space-y-1">
{[...Array(6)].map((_, j) => (
<div key={j} className="flex justify-between">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Pie Chart and Total Amount Skeleton */}
{showChart && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div className="lg:col-span-2 bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
<div className="h-16 w-16 bg-gray-200 dark:bg-gray-700 rounded-full mx-auto mb-4"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto mb-2"></div>
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-40 mx-auto"></div>
</div>
</div>
)}
{/* Payment Type Cards Skeleton */}
{showPaymentTypeCards && (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="border-2 border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-700/50">
<div className="h-5 bg-gray-200 dark:bg-gray-600 rounded w-32 mb-4"></div>
<div className="space-y-2.5">
{[...Array(5)].map((_, j) => (
<div key={j} className="flex justify-between">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-16"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-12"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Table Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<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(tableColumnCount)].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-20"></div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(tableRowCount)].map((_, i) => (
<tr key={i} className="animate-pulse">
{[...Array(tableColumnCount)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,41 @@
import React from 'react';
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
checked,
onChange,
disabled = false,
className = '',
}) => {
return (
<label className={`flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only"
/>
<div
className={`relative w-11 h-6 rounded-full transition-colors ${
checked
? 'bg-primary-600'
: 'bg-gray-300 dark:bg-gray-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</label>
);
};

View File

@ -4,6 +4,7 @@ import { yupResolver } from '@hookform/resolvers/yup';
import { Settings as SettingsIcon, Save, Globe, Mail } from 'lucide-react';
import { Input } from '../components/ui/Input';
import { Button } from '../components/ui/Button';
import { PageHeader } from '../components/layout/PageHeader';
import { settingsSchema, SettingsFormData } from '../utils/validationSchemas';
export const Settings = () => {
@ -43,15 +44,11 @@ export const Settings = () => {
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center">
<SettingsIcon className="h-6 w-6 ml-3" />
تنظیمات سیستم
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
تنظیمات کلی سیستم را اینجا مدیریت کنید
</p>
</div>
<PageHeader
title="تنظیمات سیستم"
subtitle="تنظیمات کلی سیستم را اینجا مدیریت کنید"
icon={SettingsIcon}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">

View File

@ -8,6 +8,7 @@ import { UserForm } from '../components/forms/UserForm';
import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { TableColumn } from '../types';
import { UserFormData } from '../utils/validationSchemas';
import { formatDate } from '../utils/formatters';
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers';
import { useFilters } from '../stores/useAppStore';
@ -58,7 +59,7 @@ const Users = () => {
key: 'createdAt',
label: 'تاریخ عضویت',
sortable: true,
render: (value) => new Date(value).toLocaleDateString('fa-IR')
render: (value) => formatDate(value)
},
{
key: 'actions',

View File

@ -4,6 +4,7 @@ import { Button } from '../../../components/ui/Button';
import { useAdminUser } from '../core/_hooks';
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
import { formatDate } from '../../../utils/formatters';
const AdminUserDetailPage = () => {
const navigate = useNavigate();
@ -55,10 +56,6 @@ const AdminUserDetailPage = () => {
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات کاربر</div>;
if (!user) return <div>کاربر یافت نشد</div>;
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR');
};
const getStatusBadge = (status: string) => {
const isActive = status === 'active';
return (

View File

@ -56,13 +56,13 @@ const CategoriesListPage = () => {
}
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"
title="دسته‌بندی جدید"
>
<Plus className="h-5 w-5" />
</button>
<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"
title="دسته‌بندی جدید"
>
<Plus className="h-5 w-5" />
</button>
);
return (
@ -75,18 +75,18 @@ const CategoriesListPage = () => {
/>
<FiltersSection isLoading={isLoading} columns={2}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در نام دسته‌بندی..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در نام دسته‌بندی..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
</FiltersSection>
{isLoading ? (

View File

@ -16,6 +16,7 @@ import { ArrowRight, BadgePercent, Calendar, Settings, Users, Tag, Info } from '
import { useUsers, useSearchUsers } from '../../users-admin/core/_hooks';
import { useSearchProducts } from '../../products/core/_hooks';
import { useSearchCategories } from '../../categories/core/_hooks';
import { formatDateTimeLocal } from '../../../utils/formatters';
const schema = yup.object({
code: yup.string().min(3, 'کد باید حداقل ۳ کاراکتر باشد').max(50, 'کد نباید بیشتر از ۵۰ کاراکتر باشد').required('کد الزامی است'),
@ -50,22 +51,6 @@ const schema = yup.object({
valid_to: yup.string().nullable(),
});
const formatDateTimeLocal = (dateString?: string): string => {
if (!dateString) return '';
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
} catch {
return '';
}
};
// Convert input value (YYYY-MM-DDTHH:mm) to API format (YYYY-MM-DDTHH:mm:00Z)
const toApiDateTime = (value?: string): string | undefined => {
if (!value) return undefined;

View File

@ -81,14 +81,14 @@ const DiscountCodesListPage = () => {
}
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"
title="کد تخفیف جدید"
data-testid="create-discount-button"
>
<Plus className="h-5 w-5" />
</button>
<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"
title="کد تخفیف جدید"
data-testid="create-discount-button"
>
<Plus className="h-5 w-5" />
</button>
);
return (
@ -114,26 +114,26 @@ const DiscountCodesListPage = () => {
</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">
{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">
<EmptyState
icon={Ticket}
title="هیچ کد تخفیفی یافت نشد"
description="برای شروع یک کد تخفیف ایجاد کنید"
actionLabel={
<>
<Plus className="h-4 w-4 ml-2" />
ایجاد کد تخفیف
<Plus className="h-4 w-4 ml-2" />
ایجاد کد تخفیف
</>
}
onAction={handleCreate}
/>
</div>
) : (
<Table columns={columns} data={discountCodes as any[]} />
)}
</div>
) : (
<Table columns={columns} data={discountCodes as any[]} />
)}
<DeleteConfirmModal
isOpen={!!deleteId}
@ -143,7 +143,7 @@ const DiscountCodesListPage = () => {
message="آیا از حذف این کد تخفیف اطمینان دارید؟ این عمل قابل بازگشت نیست."
isLoading={isDeleting}
/>
</div>
</div>
</PageContainer>
);
};

View File

@ -3,9 +3,12 @@ import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { CreditCard } from 'lucide-react';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { PageContainer } from '@/components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { formatDateTime } from '@/utils/formatters';
import { usePaymentCard, useUpdatePaymentCard } from '../core/_hooks';
import { persianToEnglish } from '@/utils/numberUtils';
@ -34,41 +37,6 @@ const formatCardNumber = (value: string): string => {
return groups ? groups.join(' ') : cleaned;
};
const ToggleSwitch = ({
checked,
onChange,
disabled,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) => {
return (
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only"
/>
<div
className={`relative w-11 h-6 rounded-full transition-colors ${
checked
? 'bg-primary-600'
: 'bg-gray-300 dark:bg-gray-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</label>
);
};
const CardFormPage = () => {
const { data, isLoading, error } = usePaymentCard();
const { mutate: updateCard, isPending } = useUpdatePaymentCard();
@ -126,16 +94,6 @@ const CardFormPage = () => {
});
};
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (isLoading) {
return (
@ -171,22 +129,16 @@ const CardFormPage = () => {
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">
<CreditCard className="h-6 w-6" />
پرداخت کارت به کارت
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
مدیریت اطلاعات کارت و فعال/غیرفعال کردن روش پرداخت
</p>
</div>
</div>
<PageHeader
title="پرداخت کارت به کارت"
subtitle="مدیریت اطلاعات کارت و فعال/غیرفعال کردن روش پرداخت"
icon={CreditCard}
/>
{data?.updated_at && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-200">
آخرین بهروزرسانی: {formatDate(data.updated_at)}
آخرین بهروزرسانی: {formatDateTime(data.updated_at)}
</p>
</div>
)}

View File

@ -1,54 +1,12 @@
import React from 'react';
import { CreditCard, Loader2 } from 'lucide-react';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { PageContainer } from '@/components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { formatDateTime } from '@/utils/formatters';
import { useIPGStatus, useUpdateIPGStatus } from '../core/_hooks';
import { IPGStatus, IPG_LABELS } from '../core/_models';
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const ToggleSwitch = ({
checked,
onChange,
disabled,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) => {
return (
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only"
/>
<div
className={`relative w-11 h-6 rounded-full transition-colors ${
checked
? 'bg-primary-600'
: 'bg-gray-300 dark:bg-gray-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</label>
);
};
const IPGListPage = () => {
const { data, isLoading, error } = useIPGStatus();
const { mutate: updateStatus, isPending } = useUpdateIPGStatus();
@ -110,17 +68,11 @@ const IPGListPage = () => {
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">
<CreditCard className="h-6 w-6" />
مدیریت درگاههای پرداخت
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
فعال یا غیرفعال کردن درگاههای پرداخت
</p>
</div>
</div>
<PageHeader
title="مدیریت درگاه‌های پرداخت"
subtitle="فعال یا غیرفعال کردن درگاه‌های پرداخت"
icon={CreditCard}
/>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="p-6">
@ -146,7 +98,7 @@ const IPGListPage = () => {
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
آخرین بهروزرسانی: {formatDate(ipg.updated_at)}
آخرین بهروزرسانی: {formatDateTime(ipg.updated_at)}
</p>
</div>
<div className="flex items-center gap-4">

View File

@ -38,18 +38,18 @@ const PermissionsListPage = () => {
/>
<FiltersSection>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در عنوان یا توضیحات..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در عنوان یا توضیحات..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
</FiltersSection>
{/* Permissions Table */}
@ -61,9 +61,9 @@ const PermissionsListPage = () => {
icon={Shield}
title="هیچ دسترسی یافت نشد"
description={filters.search
? "نتیجه‌ای برای جستجوی شما یافت نشد"
: "دسترسی‌های سیستم در اینجا نمایش داده می‌شوند"
}
? "نتیجه‌ای برای جستجوی شما یافت نشد"
: "دسترسی‌های سیستم در اینجا نمایش داده می‌شوند"
}
/>
</div>
) : (

View File

@ -60,29 +60,29 @@ const ProductOptionsListPage = () => {
subtitle="تنظیمات گزینه‌های قابل انتخاب برای محصولات"
icon={Settings}
actions={
<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"
title="گزینه محصول جدید"
>
<Plus className="h-5 w-5" />
</button>
<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"
title="گزینه محصول جدید"
>
<Plus className="h-5 w-5" />
</button>
}
/>
<FiltersSection>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در نام گزینه..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در نام گزینه..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
</FiltersSection>
{/* Product Options Table */}

View File

@ -11,17 +11,7 @@ import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination';
import { Filter, CheckCircle, XCircle, Trash2, MessageSquare, Star } from 'lucide-react';
import { formatWithThousands, persianToEnglish } from '@/utils/numberUtils';
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
import { formatDateTime } from '@/utils/formatters';
const getStatusColor = (status: CommentStatus) => {
const colors = {
@ -141,7 +131,7 @@ const ProductCommentsListPage = () => {
key: 'created_at',
label: 'تاریخ ایجاد',
align: 'right',
render: (val) => formatDate(val),
render: (val) => formatDateTime(val),
},
{
key: 'actions',

View File

@ -6,6 +6,7 @@ import { Button } from '../../../components/ui/Button';
import { useProduct } from '../core/_hooks';
import { PRODUCT_TYPE_LABELS } from '../core/_models';
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
import { formatPrice, formatDate } from '../../../utils/formatters';
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
type NormalizedMedia = {
@ -65,10 +66,6 @@ const ProductDetailPage = () => {
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات محصول</div>;
if (!product) return <div>محصول یافت نشد</div>;
const formatPrice = (price: number) => {
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
};
const formatNumber = (num: number) => {
return new Intl.NumberFormat('fa-IR').format(num);
};
@ -686,7 +683,7 @@ const ProductDetailPage = () => {
</span>
</div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{new Date(product.created_at).toLocaleDateString('fa-IR')}
{formatDate(product.created_at)}
</p>
</div>
@ -698,7 +695,7 @@ const ProductDetailPage = () => {
</span>
</div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{new Date(product.updated_at).toLocaleDateString('fa-IR')}
{formatDate(product.updated_at)}
</p>
</div>
</div>

View File

@ -97,13 +97,13 @@ const ProductsListPage = () => {
}
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"
title="محصول جدید"
>
<Plus className="h-5 w-5" />
</button>
<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"
title="محصول جدید"
>
<Plus className="h-5 w-5" />
</button>
);
return (
@ -117,79 +117,79 @@ const ProductsListPage = () => {
/>
<FiltersSection isLoading={isLoading} columns={4}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در نام محصول..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
دستهبندی
</label>
<select
value={filters.category_id}
onChange={handleCategoryChange}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه دستهبندیها</option>
{(categories || []).map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت
</label>
<select
value={filters.status}
onChange={handleStatusChange}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه وضعیتها</option>
<option value="active">فعال</option>
<option value="inactive">غیرفعال</option>
<option value="draft">پیشنویس</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
محدوده قیمت
</label>
<div className="flex gap-2">
<input
type="text"
inputMode="numeric"
placeholder="حداقل (مثال: ۱۰۰۰۰)"
value={filters.min_price}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setFilters(prev => ({ ...prev, min_price: converted, page: 1 }));
}}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
/>
<input
type="text"
inputMode="numeric"
placeholder="حداکثر (مثال: ۵۰۰۰۰۰)"
value={filters.max_price}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setFilters(prev => ({ ...prev, max_price: converted, page: 1 }));
}}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در نام محصول..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
دستهبندی
</label>
<select
value={filters.category_id}
onChange={handleCategoryChange}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه دستهبندیها</option>
{(categories || []).map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت
</label>
<select
value={filters.status}
onChange={handleStatusChange}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه وضعیتها</option>
<option value="active">فعال</option>
<option value="inactive">غیرفعال</option>
<option value="draft">پیشنویس</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
محدوده قیمت
</label>
<div className="flex gap-2">
<input
type="text"
inputMode="numeric"
placeholder="حداقل (مثال: ۱۰۰۰۰)"
value={filters.min_price}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setFilters(prev => ({ ...prev, min_price: converted, page: 1 }));
}}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
/>
<input
type="text"
inputMode="numeric"
placeholder="حداکثر (مثال: ۵۰۰۰۰۰)"
value={filters.max_price}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setFilters(prev => ({ ...prev, max_price: converted, page: 1 }));
}}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
/>
</div>
</div>
</FiltersSection>
{isLoading ? (

View File

@ -11,68 +11,9 @@ import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination';
import { Filter, TrendingUp, Users, DollarSign, Hash, X } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils';
import { formatCurrency, formatDateTime } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
const formatCurrency = (amount: number) => {
return formatWithThousands(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const CustomerDiscountUsageSkeleton = () => (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-28 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
</div>
</div>
</div>
))}
</div>
{/* Table Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<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(7)].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-20"></div>
</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} className="animate-pulse">
{[...Array(7)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
const CustomerDiscountUsagePage = () => {
const [filters, setFilters] = useState<CustomerDiscountUsageFilters>({
@ -160,7 +101,7 @@ const CustomerDiscountUsagePage = () => {
discount_name: usage.discount_name,
order_number: usage.order_number || '-',
amount: formatCurrency(usage.amount),
used_at: formatDate(usage.used_at),
used_at: formatDateTime(usage.used_at),
}));
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
@ -312,7 +253,7 @@ const CustomerDiscountUsagePage = () => {
{/* Table */}
{isLoading ? (
<CustomerDiscountUsageSkeleton />
<ReportSkeleton summaryCardCount={4} tableColumnCount={7} />
) : error ? (
<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-red-600 dark:text-red-400">خطا در دریافت دادهها</p>

View File

@ -10,68 +10,9 @@ import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination';
import { Filter, TrendingUp, Users, DollarSign, Hash, X } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils';
import { formatCurrency, formatDateTime } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
const formatCurrency = (amount: number) => {
return formatWithThousands(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const DiscountUsageReportSkeleton = () => (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
</div>
</div>
</div>
))}
</div>
{/* Table Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<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(6)].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-20"></div>
</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} className="animate-pulse">
{[...Array(6)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
const DiscountUsageReportPage = () => {
const [filters, setFilters] = useState<DiscountUsageFilters>({
@ -166,8 +107,8 @@ const DiscountUsageReportPage = () => {
usage_count: formatWithThousands(usage.usage_count),
total_amount: formatCurrency(usage.total_amount),
unique_users: formatWithThousands(usage.unique_users),
first_used_at: formatDate(usage.first_used_at),
last_used_at: formatDate(usage.last_used_at),
first_used_at: formatDateTime(usage.first_used_at),
last_used_at: formatDateTime(usage.last_used_at),
}));
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
@ -347,7 +288,7 @@ const DiscountUsageReportPage = () => {
{/* Table */}
{isLoading ? (
<DiscountUsageReportSkeleton />
<ReportSkeleton summaryCardCount={5} tableColumnCount={6} />
) : error ? (
<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-red-600 dark:text-red-400">خطا در دریافت دادهها</p>

View File

@ -11,21 +11,8 @@ import { Pagination } from '@/components/ui/Pagination';
import { Filter, TrendingUp, Users, DollarSign, CreditCard, CheckCircle, XCircle, X } from 'lucide-react';
import { formatWithThousands, persianToEnglish } from '@/utils/numberUtils';
import { PieChart } from '@/components/charts/PieChart';
const formatCurrency = (amount: number) => {
return formatWithThousands(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
import { formatCurrency, formatDateTime } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
const formatPercentage = (value: number) => {
return formatWithThousands(value.toFixed(2)) + '%';
@ -41,85 +28,6 @@ const getPaymentTypeLabel = (type: string): string => {
return labels[type] || type;
};
const PaymentMethodsReportSkeleton = () => (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
</div>
</div>
))}
</div>
{/* Pie Chart and Total Amount Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div className="lg:col-span-2 bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
<div className="h-16 w-16 bg-gray-200 dark:bg-gray-700 rounded-full mx-auto mb-4"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto mb-2"></div>
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-40 mx-auto"></div>
</div>
</div>
{/* Payment Type Cards Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="border-2 border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-700/50">
<div className="h-5 bg-gray-200 dark:bg-gray-600 rounded w-32 mb-4"></div>
<div className="space-y-2.5">
{[...Array(5)].map((_, j) => (
<div key={j} className="flex justify-between">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-16"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-12"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Table Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<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(10)].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-20"></div>
</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} className="animate-pulse">
{[...Array(10)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
const PaymentMethodsReportPage = () => {
const [filters, setFilters] = useState<PaymentMethodsFilters>({
@ -232,8 +140,8 @@ const PaymentMethodsReportPage = () => {
total_attempts: formatWithThousands(method.total_attempts),
total_amount: formatCurrency(method.total_amount),
success_rate: formatPercentage(method.success_rate),
first_used_at: formatDate(method.first_used_at),
last_used_at: formatDate(method.last_used_at),
first_used_at: formatDateTime(method.first_used_at),
last_used_at: formatDateTime(method.last_used_at),
})) || [];
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
@ -485,7 +393,7 @@ const PaymentMethodsReportPage = () => {
{/* Table */}
{isLoading ? (
<PaymentMethodsReportSkeleton />
<ReportSkeleton summaryCardCount={4} tableColumnCount={10} showChart={true} showPaymentTypeCards={true} />
) : error ? (
<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-red-600 dark:text-red-400">خطا در دریافت دادهها</p>

View File

@ -11,93 +11,13 @@ import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination';
import { Filter, Truck, DollarSign, Package, Users, Clock, X } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils';
const formatCurrency = (amount: number) => {
return formatWithThousands(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
import { formatCurrency, formatDateTime } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
const formatWeight = (weight: number) => {
return formatWithThousands(weight) + ' گرم';
};
const ShipmentsByMethodReportSkeleton = () => (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
</div>
</div>
))}
</div>
{/* Method Summaries Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="space-y-1">
{[...Array(6)].map((_, j) => (
<div key={j} className="flex justify-between">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Table Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<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(9)].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-20"></div>
</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} className="animate-pulse">
{[...Array(9)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
const ShipmentsByMethodReportPage = () => {
const [filters, setFilters] = useState<ShipmentsByMethodFilters>({
limit: 50,
@ -210,7 +130,7 @@ const ShipmentsByMethodReportPage = () => {
total_weight: formatWeight(shipment.total_weight),
status: shipment.status,
payment_status: shipment.payment_status,
created_at: formatDate(shipment.created_at),
created_at: formatDateTime(shipment.created_at),
})) || [];
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
@ -555,7 +475,7 @@ const ShipmentsByMethodReportPage = () => {
{/* Table */}
{isLoading ? (
<ShipmentsByMethodReportSkeleton />
<ReportSkeleton summaryCardCount={8} tableColumnCount={9} showMethodSummaries={true} />
) : error ? (
<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-red-600 dark:text-red-400">خطا در دریافت دادهها</p>

View File

@ -4,6 +4,7 @@ import { Button } from '../../../components/ui/Button';
import { useRole } from '../core/_hooks';
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
import { formatDate } from '../../../utils/formatters';
const RoleDetailPage = () => {
const navigate = useNavigate();
@ -167,7 +168,7 @@ const RoleDetailPage = () => {
</span>
</div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{new Date(role.created_at).toLocaleDateString('fa-IR')}
{formatDate(role.created_at)}
</p>
</div>
@ -179,7 +180,7 @@ const RoleDetailPage = () => {
</span>
</div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{new Date(role.updated_at).toLocaleDateString('fa-IR')}
{formatDate(role.updated_at)}
</p>
</div>
</div>

View File

@ -72,13 +72,13 @@ const RolesListPage = () => {
}
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"
title="نقش جدید"
>
<Plus className="h-5 w-5" />
</button>
<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"
title="نقش جدید"
>
<Plus className="h-5 w-5" />
</button>
);
return (
@ -91,18 +91,18 @@ const RolesListPage = () => {
/>
<FiltersSection isLoading={isLoading} columns={2}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در نام یا توضیحات نقش..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در نام یا توضیحات نقش..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
</FiltersSection>
{isLoading ? (
@ -113,12 +113,12 @@ const RolesListPage = () => {
icon={Shield}
title="هیچ نقش یافت نشد"
description={filters.search
? "نتیجه‌ای برای جستجوی شما یافت نشد"
? "نتیجه‌ای برای جستجوی شما یافت نشد"
: "شما هنوز هیچ نقش ایجاد نکرده‌اید"}
actionLabel={
<>
<Plus className="h-4 w-4 ml-2" />
اولین نقش را ایجاد کنید
<Plus className="h-4 w-4 ml-2" />
اولین نقش را ایجاد کنید
</>
}
onAction={handleCreate}

View File

@ -6,6 +6,7 @@ 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 { PageHeader } from '@/components/layout/PageHeader';
import { Truck } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';
@ -125,12 +126,10 @@ const ShippingMethodFormPage = () => {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Truck className="h-6 w-6" />
{isEdit ? 'ویرایش روش ارسال' : 'ایجاد روش ارسال'}
</h1>
</div>
<PageHeader
title={isEdit ? 'ویرایش روش ارسال' : 'ایجاد روش ارسال'}
icon={Truck}
/>
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { PageContainer } from '@/components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader';
import { Plus, Edit3, Trash2, Truck } from 'lucide-react';
import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks';
import { ShippingMethod } from '../core/_models';
@ -81,22 +82,20 @@ const ShippingMethodsListPage = () => {
return (
<PageContainer>
<div className="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">
<Truck className="h-6 w-6" />
مدیریت روشهای ارسال
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">تعریف و مدیریت روشهای ارسال سفارش</p>
</div>
<button
onClick={handleCreate}
className="flex items-center 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"
title="روش ارسال جدید"
>
<Plus className="h-5 w-5" />
</button>
</div>
<PageHeader
title="مدیریت روش‌های ارسال"
subtitle="تعریف و مدیریت روش‌های ارسال سفارش"
icon={Truck}
actions={
<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"
title="روش ارسال جدید"
>
<Plus className="h-5 w-5" />
</button>
}
/>
<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">

View File

@ -18,7 +18,8 @@ import {
TicketStatus,
TicketSubject,
} from "../core/_models";
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
import { PageContainer, SectionTitle } from "@/components/ui/Typography";
import { PageHeader } from "@/components/layout/PageHeader";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Table } from "@/components/ui/Table";
@ -578,12 +579,10 @@ const TicketConfigPage = () => {
return (
<PageContainer className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<PageTitle className="flex items-center gap-2">
<Settings className="h-6 w-6" />
تنظیمات تیکت
</PageTitle>
</div>
<PageHeader
title="تنظیمات تیکت"
icon={Settings}
/>
<div className="card p-2 flex flex-wrap gap-2">
<Button

View File

@ -3,6 +3,7 @@ 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 { formatDate } from '../../../utils/formatters';
import { PageContainer } from '../../../components/ui/Typography';
import { Button } from '../../../components/ui/Button';
import { Modal } from '../../../components/ui/Modal';
@ -259,7 +260,7 @@ const UserAdminDetailPage: React.FC = () => {
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">تاریخ ثبتنام</p>
<p className="text-gray-900 dark:text-gray-100">
{new Date(user.created_at).toLocaleDateString('fa-IR')}
{formatDate(user.created_at)}
</p>
</div>
</div>
@ -271,7 +272,7 @@ const UserAdminDetailPage: React.FC = () => {
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">آخرین بهروزرسانی</p>
<p className="text-gray-900 dark:text-gray-100">
{new Date(user.updated_at).toLocaleDateString('fa-IR')}
{formatDate(user.updated_at)}
</p>
</div>
</div>

View File

@ -7,6 +7,7 @@ import { User, ArrowLeft, Save, UserPlus } from 'lucide-react';
import { useUser, useCreateUser, useUpdateUser } from '../core/_hooks';
import { CreateUserRequest, UpdateUserRequest } from '../core/_models';
import { PageContainer } from '../../../components/ui/Typography';
import { PageHeader } from '../../../components/layout/PageHeader';
import { Button } from '../../../components/ui/Button';
import { Input } from '../../../components/ui/Input';
@ -167,15 +168,11 @@ const UserAdminFormPage: React.FC = () => {
<ArrowLeft className="h-4 w-4" />
بازگشت
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
{isEdit ? <User className="h-6 w-6" /> : <UserPlus className="h-6 w-6" />}
{isEdit ? 'ویرایش کاربر' : 'ایجاد کاربر جدید'}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{isEdit ? 'ویرایش اطلاعات کاربر' : 'افزودن کاربر جدید به سیستم'}
</p>
</div>
<PageHeader
title={isEdit ? 'ویرایش کاربر' : 'ایجاد کاربر جدید'}
subtitle={isEdit ? 'ویرایش اطلاعات کاربر' : 'افزودن کاربر جدید به سیستم'}
icon={isEdit ? User : UserPlus}
/>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">

View File

@ -265,42 +265,42 @@ const UsersAdminListPage: React.FC = () => {
)}
<FiltersSection isLoading={false} columns={3}>
<Input
placeholder="جستجو بر اساس نام، شماره تلفن یا ایمیل..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
data-testid="search-users-input"
/>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as any)}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
data-testid="status-filter-select"
>
<option value="all">همه کاربران</option>
<option value="verified">تأیید شده</option>
<option value="unverified">تأیید نشده</option>
</select>
<div className="flex gap-2">
<Button
onClick={handleSearch}
className="flex items-center gap-2"
data-testid="search-button"
<Input
placeholder="جستجو بر اساس نام، شماره تلفن یا ایمیل..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
data-testid="search-users-input"
/>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as any)}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
data-testid="status-filter-select"
>
<Search className="h-4 w-4" />
جستجو
</Button>
<Button
variant="secondary"
onClick={handleClearFilters}
className="flex items-center gap-2"
data-testid="clear-filters-button"
>
<Filter className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
<option value="all">همه کاربران</option>
<option value="verified">تأیید شده</option>
<option value="unverified">تأیید نشده</option>
</select>
<div className="flex gap-2">
<Button
onClick={handleSearch}
className="flex items-center gap-2"
data-testid="search-button"
>
<Search className="h-4 w-4" />
جستجو
</Button>
<Button
variant="secondary"
onClick={handleClearFilters}
className="flex items-center gap-2"
data-testid="clear-filters-button"
>
<Filter className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
</FiltersSection>
{/* Users Table */}

View File

@ -1,54 +1,12 @@
import React from 'react';
import { Wallet, Loader2 } from 'lucide-react';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { PageContainer } from '@/components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { formatDateTime } from '@/utils/formatters';
import { useWalletStatus, useUpdateWalletStatus } from '../core/_hooks';
import { WalletStatus, WALLET_LABELS } from '../core/_models';
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const ToggleSwitch = ({
checked,
onChange,
disabled,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) => {
return (
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only"
/>
<div
className={`relative w-11 h-6 rounded-full transition-colors ${
checked
? 'bg-primary-600'
: 'bg-gray-300 dark:bg-gray-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</label>
);
};
const WalletListPage = () => {
const { data, isLoading, error } = useWalletStatus();
const { mutate: updateStatus, isPending } = useUpdateWalletStatus();
@ -110,17 +68,11 @@ const WalletListPage = () => {
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">
<Wallet className="h-6 w-6" />
مدیریت کیف پول
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
فعال یا غیرفعال کردن کیفهای پول
</p>
</div>
</div>
<PageHeader
title="مدیریت کیف پول"
subtitle="فعال یا غیرفعال کردن کیف‌های پول"
icon={Wallet}
/>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="p-6">
@ -146,7 +98,7 @@ const WalletListPage = () => {
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
آخرین بهروزرسانی: {formatDate(wallet.updated_at)}
آخرین بهروزرسانی: {formatDateTime(wallet.updated_at)}
</p>
</div>
<div className="flex items-center gap-4">

View File

@ -70,3 +70,22 @@ export const formatNumber = (num: number): string => {
return new Intl.NumberFormat('fa-IR').format(num);
};
/**
* Format date string to local datetime input format (YYYY-MM-DDTHH:mm)
*/
export const formatDateTimeLocal = (dateString?: string | Date): string => {
if (!dateString) return '';
try {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
if (isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
} catch {
return '';
}
};