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:
parent
6dd2429920
commit
c46fd2ba0e
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue